mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
4
Commits
c40dc6c24c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3da9fa1bd | ||
|
|
56aa164708 | ||
|
|
df5ae151ab | ||
|
|
10ae7bf39c |
@@ -181,9 +181,11 @@ HOST_TESTED = [
|
|||||||
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
# The first run in the page's order: the four documents at their hashes,
|
# The first run in the page's order: the four documents at their hashes,
|
||||||
# the press step breathing teal, the press taken as the acceptance, the
|
# the press step breathing teal, the press taken as the acceptance, the
|
||||||
# form steps through main.c's hooks (extensions.md stays with
|
# form steps through main.c's hooks; and the on-demand Extensions
|
||||||
# exthost.service, which covers it).
|
# advisory, accepted at its hash with the typed phrase.
|
||||||
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
("forgectrl", "docs/advisories/extensions.md", "forgectrl:tests/host_setup.sh",
|
||||||
|
"forgectrl:.github/workflows/build.yml"),
|
||||||
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
||||||
"forgectrl:.github/workflows/build.yml"),
|
"forgectrl:.github/workflows/build.yml"),
|
||||||
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||||
@@ -207,6 +209,34 @@ HOST_TESTED = [
|
|||||||
# store that holds only hashes, and the extension host's credential
|
# store that holds only hashes, and the extension host's credential
|
||||||
# (mode 0600, exactly the host's capabilities).
|
# (mode 0600, exactly the host's capabilities).
|
||||||
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The logging tree's paths, the level and target validators, the
|
||||||
|
# configured levels.
|
||||||
|
("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||||
|
# The extension host, each part to the test that drives it (the
|
||||||
|
# freeze, the supervisor, the machine client and the groups stay with
|
||||||
|
# exthost.armed-freeze, which covers them).
|
||||||
|
("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
# A package's page asking its own service (forgeext call).
|
||||||
|
("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
# Archives in and refused, and the signed index.
|
||||||
|
("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
# The daemon as built, end to end: its command line, its log, the
|
||||||
|
# killed host under the init script's wrapper.
|
||||||
|
("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
|
("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# This repository's root, when the package runs from a checkout of it; a
|
# This repository's root, when the package runs from a checkout of it; a
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
import stat
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -488,8 +487,6 @@ FWUP = "/usr/bin/fwup"
|
|||||||
EXT_ROOT = "/data/forgefirm/ext"
|
EXT_ROOT = "/data/forgefirm/ext"
|
||||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||||
SAFE_FILE = "/run/forgefirm/ext-safe"
|
SAFE_FILE = "/run/forgefirm/ext-safe"
|
||||||
HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log"
|
|
||||||
API_DIR = "/run/forgefirm/ext/api"
|
|
||||||
REF_ID = "org.forgetest.reference"
|
REF_ID = "org.forgetest.reference"
|
||||||
REF_KEY = "forgetest-reference"
|
REF_KEY = "forgetest-reference"
|
||||||
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
||||||
@@ -841,323 +838,6 @@ def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
|
|||||||
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
|
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
|
||||||
|
|
||||||
|
|
||||||
@test("exthost.service", title="A package's service runs confined under the extension host",
|
|
||||||
subsystem="exthost", kind="auto", hardware="takeover", est_min=5,
|
|
||||||
covers=[("forgeext", "**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/logs.*"),
|
|
||||||
("forgectrl", "docs/advisories/extensions.md")],
|
|
||||||
requires=["exthost.platform"],
|
|
||||||
description="The extension host is one running process (/usr/bin/forgeext run) and the status "
|
|
||||||
"file is its word, and with extensions off it runs nothing and says why. The test builds a reference "
|
|
||||||
"package on the board, signs it with a key it makes there, and adds that key as an owner "
|
|
||||||
"key: an install without the community consent is refused, with it the package is "
|
|
||||||
"installed at the community tier. It turns extensions on over the advisory. The service "
|
|
||||||
"then runs as its pool account with no_new_privs and a seccomp filter, in its own group "
|
|
||||||
"under /sys/fs/cgroup/ffx with the limits of a service (25 percent of the core, 48 MiB, 32 "
|
|
||||||
"processes), with its chain in the rule table. From the inside it reads /etc and its "
|
|
||||||
"package, writes its data directory and nothing else, cannot read the settings file, the "
|
|
||||||
"setup record, or the pulse device, is refused by the machine on loopback and on its LAN "
|
|
||||||
"address even on the port it declared, cannot connect on a port it did not declare, and "
|
|
||||||
"cannot open a netlink socket. Its one way to the machine is its API socket (root's and its "
|
|
||||||
"account's, 0660, refused to another pool account): GET /v0/self names it and what it may use, "
|
|
||||||
"GET /v0/machine/mode is forgectrl's answer relayed, POST /v0/camera gives it a JPEG from the "
|
|
||||||
"lid camera it holds (with the camera's lamp at 0 and at 1023 two different pictures, the lit "
|
|
||||||
"one the larger) and refuses the head camera and a lamp past its range, a hold it was not granted is 403, a path "
|
|
||||||
"the API does not have 404, a path with .. 400; its output is in the forgeext log under its id. Safe mode "
|
|
||||||
"stops it and its end starts it again. A host killed outright takes its services with it "
|
|
||||||
"at once (the init wrapper), comes back, and starts the service again. ext_enabled=0 "
|
|
||||||
"stops it and leaves no group and no chain. The package, the key, the setting, and the "
|
|
||||||
"setup record are put back as found, the record under a forgectrl restart. The layer "
|
|
||||||
"content (the recipe, the init script's install, the image list) is in the platform "
|
|
||||||
"identity of every fingerprint.")
|
|
||||||
def service(ctx):
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
ctx.check(os.path.isfile(FORGEEXT) and os.access("/etc/init.d/forgeext", os.X_OK),
|
|
||||||
"the image has no forgeext, or no init script for it")
|
|
||||||
hosts = _host_pids()
|
|
||||||
ev["host_pids"] = hosts
|
|
||||||
ctx.check(len(hosts) == 1, "the extension host is not one running process: %s", hosts)
|
|
||||||
ctx.check(_until(ctx, lambda: _host_status().get("pid") == hosts[0], 10),
|
|
||||||
"the status file %s is not the running host's: %s", HOST_STATUS, _host_status().get("pid"))
|
|
||||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
|
||||||
prior = fc.settings().get("ext_enabled") or ""
|
|
||||||
raw = read_file(record_path())
|
|
||||||
ctx.check(raw, "no setup record at %s", record_path())
|
|
||||||
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
|
|
||||||
ctx.check(REF_ID not in [x.get("id") for x in _forgeext("list").get("packages", [])],
|
|
||||||
"%s is already installed", REF_ID)
|
|
||||||
found_tree = _tree(EXT_ROOT)
|
|
||||||
data_dir = os.path.dirname(EXT_ROOT)
|
|
||||||
dir_mode = os.stat(data_dir).st_mode & 0o7777
|
|
||||||
ev["data_dir_mode_found"] = "%04o" % dir_mode
|
|
||||||
if prior != "1":
|
|
||||||
st = _host_status()
|
|
||||||
ev["off"] = {k: st.get(k) for k in ("enabled", "off_reason")}
|
|
||||||
ctx.check(st.get("enabled") is False and "ext_enabled" in (st.get("off_reason") or "")
|
|
||||||
and not [x for x in st.get("services", []) if x.get("state") == "running"],
|
|
||||||
"with extensions off the host does not say so: %s", ev["off"])
|
|
||||||
|
|
||||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
|
||||||
etag = hdrs.get("etag")
|
|
||||||
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
|
|
||||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
|
||||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
|
||||||
uid = None
|
|
||||||
|
|
||||||
def cg(name):
|
|
||||||
return _read("%s/%s/%s" % (POOL_GROUP, REF_ID, name)).strip()
|
|
||||||
|
|
||||||
def chains():
|
|
||||||
return subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True, timeout=30).stdout
|
|
||||||
|
|
||||||
def running(other_than=0):
|
|
||||||
# The status file outlives a killed host: a pid that is the old one is no news.
|
|
||||||
x = _svc(REF_ID)
|
|
||||||
return x if x.get("state") == "running" and x.get("pid") and x["pid"] != other_than and os.path.exists("/proc/%d" % x["pid"]) else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
lan = lan_ip()
|
|
||||||
archive, pub = _pack_reference(work, lan)
|
|
||||||
r = _forgeext("inspect", archive)
|
|
||||||
ev["inspect_without_the_key"] = r.get("tier")
|
|
||||||
ctx.check(r.get("ok") and r.get("tier") == "unverified", "before its key is the owner's the package reads %s", r)
|
|
||||||
shutil.copy(pub, owner_key)
|
|
||||||
os.chmod(owner_key, 0o644)
|
|
||||||
r = _forgeext("inspect", archive)
|
|
||||||
ctx.check(r.get("ok") and r.get("tier") == "community", "with the owner's key the package reads %s", r)
|
|
||||||
r = _forgeext("install", archive)
|
|
||||||
ev["install_without_consent"] = r.get("error")
|
|
||||||
ctx.log("install without the consent -> %s", r.get("error"))
|
|
||||||
ctx.check(r.get("ok") is False, "a community package was installed without the consent")
|
|
||||||
r = _forgeext("install", archive, "--consent-community")
|
|
||||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
|
||||||
ctx.check(_forgeext("check", REF_ID).get("ok") is True, "the installed tree fails its integrity check")
|
|
||||||
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
|
||||||
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
|
|
||||||
x = _until(ctx, running, 90, poll=0.5)
|
|
||||||
ev["status_at_start"] = _host_status()
|
|
||||||
ctx.check(x, "the service is not running: %s", {k: ev["status_at_start"].get(k) for k in ("enabled", "off_reason", "not_ready")}
|
|
||||||
if not _svc(REF_ID) else _svc(REF_ID))
|
|
||||||
pid = x["pid"]
|
|
||||||
uid = POOL_FIRST + int(x["account"][3:])
|
|
||||||
proc = _read("/proc/%d/status" % pid)
|
|
||||||
ev["service"] = {"pid": pid, "account": x["account"], "cgroup": _read("/proc/%d/cgroup" % pid).strip(),
|
|
||||||
"cpu.max": cg("cpu.max"), "memory.max": cg("memory.max"), "pids.max": cg("pids.max"),
|
|
||||||
"memory.current": cg("memory.current")}
|
|
||||||
ctx.log("the service: %s", ev["service"])
|
|
||||||
ctx.check(("Uid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc and ("Gid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc,
|
|
||||||
"the service does not run as %s alone", x["account"])
|
|
||||||
ctx.check("NoNewPrivs:\t1" in proc and "Seccomp:\t2" in proc, "no no_new_privs or no seccomp filter on the service")
|
|
||||||
ctx.check(ev["service"]["cgroup"] == "0::/ffx/" + REF_ID, "the service's group is %s", ev["service"]["cgroup"])
|
|
||||||
ctx.check(cg("cpu.max").split() == ["25000", "100000"] and cg("memory.max") == str(48 << 20) and cg("pids.max") == "32",
|
|
||||||
"the group's limits are not a service's: %s", ev["service"])
|
|
||||||
table = chains()
|
|
||||||
ctx.check(("chain u%d " % uid) in table and REF_DEST in table, "the service's chain is not in the rule table")
|
|
||||||
|
|
||||||
report_path = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
|
||||||
ctx.check(_until(ctx, lambda: os.path.isfile(report_path), 60, poll=0.5), "the service left no report")
|
|
||||||
rep = json.loads(_read(report_path))
|
|
||||||
ev["from_the_inside"] = rep
|
|
||||||
ctx.log("from the inside: %s", rep)
|
|
||||||
want = {"uid": uid, "gid": uid, "groups": [], "read_etc": "ok", "read_pkg": "ok", "write_data": "ok",
|
|
||||||
"read_settings": "EACCES", "read_setup": "EACCES", "write_pkg": "EACCES", "write_tmp": "EACCES",
|
|
||||||
"pulse_device": "EACCES", "loopback_declared_port": "ECONNREFUSED",
|
|
||||||
"loopback_undeclared_port": "EACCES", "unix_socket": "ok", "netlink_socket": "EPERM"}
|
|
||||||
if lan:
|
|
||||||
want["lan_declared_port"] = "ECONNREFUSED"
|
|
||||||
for k, v in want.items():
|
|
||||||
ctx.check(rep.get(k) == v, "from the inside, %s is %r, expected %r", k, rep.get(k), v)
|
|
||||||
# LC_CTYPE is the interpreter's own doing (it coerces the C locale at its start)
|
|
||||||
fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "FFX_API", "PYTHONDONTWRITEBYTECODE",
|
|
||||||
"PYTHONUNBUFFERED"}
|
|
||||||
ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed,
|
|
||||||
"its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env"))
|
|
||||||
# its one way to the machine: the API socket, and the broker behind it
|
|
||||||
me = rep.get("api_self") or [0, {}]
|
|
||||||
ev["api"] = {k: rep.get(k) for k in ("api_self", "api_mode", "api_hold", "api_nowhere", "api_traversal")}
|
|
||||||
ctx.check(me[0] == 200 and me[1].get("id") == REF_ID and me[1].get("version") == "1.0.0"
|
|
||||||
and sorted(me[1].get("capabilities") or []) == sorted(["net.outbound:%s:443" % REF_DEST, "storage:1",
|
|
||||||
"machine.read", "settings.own",
|
|
||||||
"camera.lid"]),
|
|
||||||
"GET /v0/self from the inside: %s", me)
|
|
||||||
|
|
||||||
# a camera: the one it was granted, and not the one it was not
|
|
||||||
cam = rep.get("api_camera") or [0, "", False, False]
|
|
||||||
ev["api_camera"] = {"status": cam[0], "bytes": cam[1] if cam[0] == 200 else None,
|
|
||||||
"jpeg": cam[2], "image_type": cam[3],
|
|
||||||
"why": cam[1] if cam[0] != 200 else None}
|
|
||||||
ctx.log("the lid camera, from inside the sandbox: %s", ev["api_camera"])
|
|
||||||
ctx.check(cam[0] == 200 and cam[2] and cam[3] and isinstance(cam[1], int) and cam[1] > 4096,
|
|
||||||
"the granted camera did not come back as a JPEG: %s", ev["api_camera"])
|
|
||||||
head_ = rep.get("api_camera_head") or [0, ""]
|
|
||||||
ev["api_camera_head"] = head_[:2]
|
|
||||||
ctx.check(head_[0] == 403, "the head camera, which it was not granted -> %s", head_[:2])
|
|
||||||
badcam = rep.get("api_camera_bad") or [0, ""]
|
|
||||||
ev["api_camera_bad"] = badcam[:2]
|
|
||||||
ctx.check(badcam[0] == 400, "a camera there is none of -> %s", badcam[:2])
|
|
||||||
# The camera's own lamp for one frame: the machine lights it for
|
|
||||||
# the shot and puts its level back, so a frame with the lamp off
|
|
||||||
# and one with it full are two different pictures, the lit one the
|
|
||||||
# larger, and a level past the range is refused before the machine
|
|
||||||
# is asked.
|
|
||||||
dark = rep.get("api_camera_dark") or [0, "", False, False]
|
|
||||||
lit = rep.get("api_camera_lit") or [0, "", False, False]
|
|
||||||
ev["api_camera_lamp"] = {"dark": dark[:3], "lit": lit[:3]}
|
|
||||||
ctx.log("the lid camera with its lamp at 0 and at 1023: %s", ev["api_camera_lamp"])
|
|
||||||
ctx.check(dark[0] == 200 and dark[2] and lit[0] == 200 and lit[2],
|
|
||||||
"a frame with the lamp named did not come back as a JPEG: %s", ev["api_camera_lamp"])
|
|
||||||
ctx.check(isinstance(lit[1], int) and isinstance(dark[1], int) and lit[1] > dark[1],
|
|
||||||
"the lamp did not reach the camera: %d bytes at 1023, %d at 0", lit[1] or 0, dark[1] or 0)
|
|
||||||
lampbad = rep.get("api_camera_lamp_bad") or [0, ""]
|
|
||||||
ev["api_camera_lamp_bad"] = lampbad[:2]
|
|
||||||
ctx.check(lampbad[0] == 400 and "lamp" in str(lampbad[1]), "a lamp past its range -> %s", lampbad[:2])
|
|
||||||
|
|
||||||
# A package's capture yields to somebody watching a camera: it is
|
|
||||||
# never the person standing at the machine.
|
|
||||||
import urllib.request
|
|
||||||
shot_file = os.path.join(EXT_ROOT, "data", REF_ID, "shot")
|
|
||||||
if os.path.exists(shot_file):
|
|
||||||
os.remove(shot_file)
|
|
||||||
held = urllib.request.urlopen(
|
|
||||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=15)
|
|
||||||
try:
|
|
||||||
held.read(8192)
|
|
||||||
watching = _until(ctx, lambda: (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5)
|
|
||||||
ctx.check(watching, "no viewer was counted while the stream was held")
|
|
||||||
_write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now")
|
|
||||||
yielded = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None,
|
|
||||||
30, poll=0.5)
|
|
||||||
ev["camera_while_watched"] = yielded
|
|
||||||
ctx.log("the package's capture while a viewer watches: %s", yielded)
|
|
||||||
ctx.check(yielded and yielded[0] == 409,
|
|
||||||
"a package's capture did not yield to a viewer: %s", yielded)
|
|
||||||
ctx.check(yielded and "watching" in str(yielded[1]),
|
|
||||||
"it yielded without the machine's own words: %s", yielded)
|
|
||||||
finally:
|
|
||||||
held.close()
|
|
||||||
os.remove(shot_file)
|
|
||||||
_until(ctx, lambda: not (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5)
|
|
||||||
_write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now")
|
|
||||||
again = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None,
|
|
||||||
40, poll=0.5)
|
|
||||||
ev["camera_after_watching"] = [again[0], again[1]] if again else None
|
|
||||||
ctx.check(again and again[0] == 200 and again[2],
|
|
||||||
"the package's capture did not come back once the viewer stopped: %s", again)
|
|
||||||
|
|
||||||
# its own settings: read, set, and what the schema will not take
|
|
||||||
got = rep.get("api_settings") or [0, {}]
|
|
||||||
ev["api_settings"] = got
|
|
||||||
ctx.check(got[0] == 200 and (got[1] or {}).get("settings", {}).get("threshold") == 40
|
|
||||||
and (got[1] or {}).get("settings", {}).get("when") == "end"
|
|
||||||
and len((got[1] or {}).get("schema") or []) == 3,
|
|
||||||
"GET /v0/settings before anything is set, with its schema: %s", got)
|
|
||||||
set_ = rep.get("api_settings_set") or [0, {}]
|
|
||||||
ev["api_settings_set"] = set_
|
|
||||||
ctx.check(set_[0] == 200 and (set_[1] or {}).get("settings", {}).get("threshold") == 70
|
|
||||||
and (set_[1] or {}).get("settings", {}).get("note") == "set from inside"
|
|
||||||
and (set_[1] or {}).get("settings", {}).get("when") == "end",
|
|
||||||
"POST /v0/settings sets what it names and leaves the rest: %s", set_)
|
|
||||||
for key, why in (("api_settings_undeclared", "a setting the package does not declare"),
|
|
||||||
("api_settings_out_of_bounds", "a value outside its bounds")):
|
|
||||||
bad_ = rep.get(key) or [0, {}]
|
|
||||||
ev[key] = bad_
|
|
||||||
ctx.check(bad_[0] == 400 and (bad_[1] or {}).get("error"), "%s -> %s", why, bad_)
|
|
||||||
store = "%s/settings/%s.json" % (EXT_ROOT, REF_ID)
|
|
||||||
ev["settings_store"] = {"mode": "%04o" % (os.stat(store).st_mode & 0o7777) if os.path.exists(store) else None}
|
|
||||||
ctx.check(os.path.exists(store) and os.stat(store).st_mode & 0o7777 == 0o600,
|
|
||||||
"the settings store is not root's alone: %s", ev["settings_store"])
|
|
||||||
ctx.check(os.path.exists(store) and json.loads(_read(store)).get("threshold") == 70,
|
|
||||||
"the store does not hold what the package set")
|
|
||||||
mode_ = rep.get("api_mode") or [0, {}]
|
|
||||||
ctx.check(mode_[0] == 200 and mode_[1].get("mode") in ("grbl", "cloud") and "controller" in mode_[1],
|
|
||||||
"GET /v0/machine/mode from the inside is not forgectrl's answer: %s", mode_)
|
|
||||||
ctx.check((rep.get("api_hold") or [0])[0] == 403 and (rep.get("api_nowhere") or [0])[0] == 404
|
|
||||||
and (rep.get("api_traversal") or [0])[0] == 400,
|
|
||||||
"a hold it was not granted, a path the API does not have, a path with ..: %s %s %s",
|
|
||||||
rep.get("api_hold"), rep.get("api_nowhere"), rep.get("api_traversal"))
|
|
||||||
# the storage quota: the host measures what it holds and sets it aside
|
|
||||||
big = os.path.join(EXT_ROOT, "data", REF_ID, "toobig")
|
|
||||||
with open(big, "wb") as f:
|
|
||||||
f.write(b"x" * (2 << 20))
|
|
||||||
ctx.log("wrote 2 MiB into its data directory (it declares storage:1)")
|
|
||||||
|
|
||||||
def quarantined():
|
|
||||||
x = _svc(REF_ID)
|
|
||||||
return x if x.get("state") == "quarantined" else None
|
|
||||||
|
|
||||||
q = _until(ctx, quarantined, 90, poll=1.0)
|
|
||||||
ev["quota"] = {"state": (q or _svc(REF_ID)).get("state"), "reason": (q or _svc(REF_ID)).get("reason")}
|
|
||||||
ctx.log("over its quota: %s", ev["quota"])
|
|
||||||
ctx.check(q, "a service 2 MiB over its 1 MiB quota was not set aside: %s", _svc(REF_ID))
|
|
||||||
ctx.check("may hold" in ((q or {}).get("reason") or ""),
|
|
||||||
"it was set aside without the quota's reason: %s", (q or {}).get("reason"))
|
|
||||||
ctx.check(not os.path.exists("/proc/%d" % pid), "it is still running after being set aside")
|
|
||||||
# The operator's way out: clear the data, enable it again.
|
|
||||||
os.remove(big)
|
|
||||||
r = _forgeext("enable", REF_ID)
|
|
||||||
ctx.check(r.get("ok") is True, "enable after the quota -> %s", r.get("error"))
|
|
||||||
back = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
|
||||||
ctx.check(back, "it did not come back once its data was cleared and it was enabled: %s", _svc(REF_ID))
|
|
||||||
pid = (back or {}).get("pid") or pid
|
|
||||||
|
|
||||||
sock = "%s/%s.sock" % (API_DIR, REF_ID)
|
|
||||||
st_ = os.stat(sock)
|
|
||||||
ctx.check(stat.S_ISSOCK(st_.st_mode) and stat.S_IMODE(st_.st_mode) == 0o660 and (st_.st_uid, st_.st_gid) == (0, uid),
|
|
||||||
"its API socket is not root's and its account's at 0660: %o %d:%d", stat.S_IMODE(st_.st_mode), st_.st_uid, st_.st_gid)
|
|
||||||
other = POOL_FIRST + POOL_SIZE - 1 if uid != POOL_FIRST + POOL_SIZE - 1 else POOL_FIRST
|
|
||||||
rc, lines, errtext = _probe("-", other, "unix", sock)
|
|
||||||
ev["another_account_at_its_socket"] = lines
|
|
||||||
ctx.check(lines and lines[0].get("result") == "eacces", "another pool account at its socket: %s %s", lines, errtext)
|
|
||||||
ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1),
|
|
||||||
"the service's output is not in %s under its id", HOST_LOG)
|
|
||||||
|
|
||||||
_write(SAFE_FILE, "")
|
|
||||||
ctx.check(_until(ctx, lambda: not running() and "safe mode" in (_host_status().get("off_reason") or ""), 15),
|
|
||||||
"safe mode did not stop the service: %s", _host_status())
|
|
||||||
ctx.check(not os.path.exists("/proc/%d" % pid) and not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)),
|
|
||||||
"safe mode left the process or its group")
|
|
||||||
os.remove(SAFE_FILE)
|
|
||||||
x = _until(ctx, lambda: running(pid), 60, poll=0.5)
|
|
||||||
ctx.check(x, "out of safe mode the service did not start again: %s", _svc(REF_ID))
|
|
||||||
pid = x["pid"]
|
|
||||||
ctx.log("safe mode stopped it and its end started it again (pid %d)", pid)
|
|
||||||
|
|
||||||
host = _host_pids()
|
|
||||||
ctx.check(len(host) == 1, "the extension host is not one process: %s", host)
|
|
||||||
# Only once the service is in its quiet loop: a service still on its way up ends by
|
|
||||||
# itself when its first line meets the dead host's pipe, and that would prove nothing.
|
|
||||||
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
|
|
||||||
seen = set()
|
|
||||||
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 30, poll=0.2),
|
|
||||||
"the service's heartbeat does not advance")
|
|
||||||
t0 = time.time()
|
|
||||||
os.kill(host[0], signal.SIGKILL)
|
|
||||||
ctx.check(_until(ctx, lambda: not os.path.exists("/proc/%d" % pid), 2, poll=0.05),
|
|
||||||
"a killed host left its service running for 2 s: nobody would freeze it in an armed window")
|
|
||||||
ev["service_outlived_a_killed_host_s"] = round(time.time() - t0, 2)
|
|
||||||
x = _until(ctx, lambda: running(pid) if _host_status().get("pid") in _host_pids() else None, 60, poll=0.5)
|
|
||||||
ev["host_back_s"] = round(time.time() - t0, 1)
|
|
||||||
ctx.check(x, "the host did not come back and start the service again: %s", _host_status())
|
|
||||||
pid = x["pid"]
|
|
||||||
ctx.log("a killed host: its service gone in %.2f s, the host back and the service running after %.1f s",
|
|
||||||
ev["service_outlived_a_killed_host_s"], ev["host_back_s"])
|
|
||||||
|
|
||||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
|
||||||
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
|
||||||
ctx.check(_until(ctx, lambda: not running() and not os.path.exists("/proc/%d" % pid), 15),
|
|
||||||
"ext_enabled=0 did not stop the service: %s", _svc(REF_ID))
|
|
||||||
ctx.check(not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)) and ("chain u%d " % uid) not in chains(),
|
|
||||||
"ext_enabled=0 left the service's group or its chain")
|
|
||||||
ctx.check("ext_enabled" in (_host_status().get("off_reason") or ""), "the host does not say why nothing runs: %s",
|
|
||||||
_host_status().get("off_reason"))
|
|
||||||
finally:
|
|
||||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
|
||||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- the armed window
|
# ------------------------------------------------- the armed window
|
||||||
|
|
||||||
def _frozen(id_):
|
def _frozen(id_):
|
||||||
@@ -1170,7 +850,7 @@ def _frozen(id_):
|
|||||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
requires=["exthost.service"], actions=["button"],
|
requires=["exthost.platform"], actions=["button"],
|
||||||
steps=[OFFLINE_STEP,
|
steps=[OFFLINE_STEP,
|
||||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||||
"when it lights."],
|
"when it lights."],
|
||||||
@@ -1186,8 +866,8 @@ def _frozen(id_):
|
|||||||
"service is the same process throughout. A background capture is refused in words "
|
"service is the same process throughout. A background capture is refused in words "
|
||||||
"while the window is open and served again once it closes, which is the other half "
|
"while the window is open and served again once it closes, which is the other half "
|
||||||
"of keeping a package off the step stream: a capture costs kernel-side work that a "
|
"of keeping a package off the step stream: a capture costs kernel-side work that a "
|
||||||
"thread priority does not cover. Everything is put back as exthost.service puts it "
|
"thread priority does not cover. The package, the key, the setting and the setup "
|
||||||
"back.")
|
"record are put back as found, the record under a forgectrl restart.")
|
||||||
def armed_freeze(ctx):
|
def armed_freeze(ctx):
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
import json
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@ from ..catalog import test
|
|||||||
# write with the token), so both are the guard's domain.
|
# write with the token), so both are the guard's domain.
|
||||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||||
|
|
||||||
|
|
||||||
def lan_ip():
|
def lan_ip():
|
||||||
@@ -210,104 +208,3 @@ def auth(ctx):
|
|||||||
if st == 302:
|
if st == 302:
|
||||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||||
"the login redirect does not carry the path: %r", loc)
|
"the login redirect does not carry the path: %r", loc)
|
||||||
|
|
||||||
|
|
||||||
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
|
|
||||||
kind="auto", est_min=1,
|
|
||||||
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
|
|
||||||
description="An over-length value and an out-of-range value are refused (400) and leave the "
|
|
||||||
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
|
|
||||||
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
|
|
||||||
"level applies to the lamp at once, and clearing it returns the default.")
|
|
||||||
def settings_bounds(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
before = fc.settings()
|
|
||||||
ev["keys"] = len(before)
|
|
||||||
|
|
||||||
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
|
|
||||||
ev["overlong"] = st
|
|
||||||
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
|
|
||||||
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
|
|
||||||
|
|
||||||
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
|
|
||||||
ev["out_of_range"] = st
|
|
||||||
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
|
|
||||||
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
|
|
||||||
|
|
||||||
# The cloud download guard is bytes, so its range is far wider than the
|
|
||||||
# other numeric keys: check the far end is still a wall.
|
|
||||||
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
|
|
||||||
ev["pulse_bytes_out_of_range"] = st
|
|
||||||
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
|
|
||||||
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
|
|
||||||
|
|
||||||
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
|
|
||||||
ev["unknown_key"] = st
|
|
||||||
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
|
|
||||||
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
|
|
||||||
|
|
||||||
after = fc.settings()
|
|
||||||
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
|
|
||||||
"settings changed after refused writes")
|
|
||||||
ctx.log("settings unchanged after the refused writes")
|
|
||||||
|
|
||||||
# an accepted in-range write: rewrite a present key with its own value
|
|
||||||
key = None
|
|
||||||
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
|
|
||||||
v = before.get(k)
|
|
||||||
if isinstance(v, str) and v != "":
|
|
||||||
key = k
|
|
||||||
break
|
|
||||||
if key is None:
|
|
||||||
key, val = "ui_units", "metric"
|
|
||||||
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
|
|
||||||
else:
|
|
||||||
val = before[key]
|
|
||||||
st, body = fc.post("/settings", data={key: val})
|
|
||||||
ev["accepted"] = {"key": key, "value": val, "status": st}
|
|
||||||
ctx.log("POST /settings %s=%s -> %s", key, val, st)
|
|
||||||
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
|
|
||||||
final = fc.settings()
|
|
||||||
others_before = {k: v for k, v in before.items() if k != key}
|
|
||||||
others_after = {k: v for k, v in final.items() if k != key}
|
|
||||||
ctx.check(others_before == others_after, "other settings changed by the write")
|
|
||||||
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
|
|
||||||
|
|
||||||
# the lid lamp's idle level: resting at the setting, bounded, applied live
|
|
||||||
lamp_was = (before.get("lid_lamp_idle") or "").strip()
|
|
||||||
want = lamp_was or "236"
|
|
||||||
got = ctx.sysfs("pic/lid_led")
|
|
||||||
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
|
|
||||||
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
|
|
||||||
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
|
|
||||||
for bad in ("256", "-1", "bright"):
|
|
||||||
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
|
|
||||||
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
|
|
||||||
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
|
|
||||||
try_level = "100" if want != "100" else "120"
|
|
||||||
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
|
|
||||||
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
|
|
||||||
applied = None
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < 5:
|
|
||||||
applied = ctx.sysfs("pic/lid_led")
|
|
||||||
if applied == try_level:
|
|
||||||
break
|
|
||||||
ctx.sleep(0.2)
|
|
||||||
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
|
|
||||||
# an empty value clears the key: the query-string form carries it
|
|
||||||
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
|
|
||||||
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
|
|
||||||
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
|
|
||||||
t0 = time.time()
|
|
||||||
back = None
|
|
||||||
while time.time() - t0 < 5:
|
|
||||||
back = ctx.sysfs("pic/lid_led")
|
|
||||||
if back == want:
|
|
||||||
break
|
|
||||||
ctx.sleep(0.2)
|
|
||||||
ev["lid_lamp"].update({"applied": applied, "restored": back})
|
|
||||||
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
|
|
||||||
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
|
|
||||||
ctx.log("lid lamp follows the setting live and returns to %s", want)
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""image.* - the post-flash health of the running image (always-required)."""
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
import glob
|
|
||||||
import gzip
|
import gzip
|
||||||
import mmap
|
import mmap
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import struct
|
import struct
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
@@ -123,15 +121,15 @@ def fds_of(pid):
|
|||||||
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||||
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||||
"unthrottled), "
|
"unthrottled), "
|
||||||
"the init ordering, the extension host as one process that starts after forgectrl and "
|
"the extension host as one process on a machine with no package installed and "
|
||||||
"stops before it on a machine with no package installed and nothing running under a "
|
"nothing running under a pool account, no userspace watchdog running, "
|
||||||
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
"the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only. "
|
"into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. "
|
||||||
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
||||||
"in the state file and no ifup is running) and the console login is up; each "
|
"in the state file and no ifup is running) and the console login is up; each "
|
||||||
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
||||||
"client is running or on the image, nor its hook script; and IPv6 is the "
|
"client is running; and IPv6 is the "
|
||||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||||
"address up (a global address is logged, not checked).")
|
"address up (a global address is logged, not checked).")
|
||||||
def image_health(ctx):
|
def image_health(ctx):
|
||||||
@@ -284,17 +282,8 @@ def image_health(ctx):
|
|||||||
ctx.check(temps.get("soc_throttle") == 0,
|
ctx.check(temps.get("soc_throttle") == 0,
|
||||||
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||||
|
|
||||||
# 5. init ordering: controllers stop before the daemon
|
# 5. the extension host: one process, and a campaign's machine is
|
||||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
# extension-free (its init order is the rootfs lint's)
|
||||||
ev["rc6_kill"] = k
|
|
||||||
kg = [x for x in k if "grblhal" in x]
|
|
||||||
kf = [x for x in k if x.endswith("forgectrl")]
|
|
||||||
ctx.log("rc6.d: %s", " ".join(k))
|
|
||||||
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
|
|
||||||
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
|
|
||||||
|
|
||||||
# 5b. the extension host: one process, up after forgectrl and down
|
|
||||||
# before it, and a campaign's machine is extension-free
|
|
||||||
host = []
|
host = []
|
||||||
for pid in hw.pidof("forgeext"):
|
for pid in hw.pidof("forgeext"):
|
||||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||||
@@ -302,13 +291,6 @@ def image_health(ctx):
|
|||||||
host.append(pid)
|
host.append(pid)
|
||||||
ev["extension_host_pids"] = host
|
ev["extension_host_pids"] = host
|
||||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||||
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
|
|
||||||
sx = [x for x in s5 if x.endswith("forgeext")]
|
|
||||||
sf = [x for x in s5 if x.endswith("forgectrl")]
|
|
||||||
kx = [x for x in k if x.endswith("forgeext")]
|
|
||||||
ev["forgeext_links"] = {"start": sx, "kill": kx}
|
|
||||||
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
|
|
||||||
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
|
|
||||||
ev["extension_packages"] = hw.ext_packages()
|
ev["extension_packages"] = hw.ext_packages()
|
||||||
ev["pool_processes"] = len(hw.pool_pids())
|
ev["pool_processes"] = len(hw.pool_pids())
|
||||||
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||||
@@ -316,12 +298,9 @@ def image_health(ctx):
|
|||||||
ev["extension_packages"])
|
ev["extension_packages"])
|
||||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||||
|
|
||||||
# 6. logging lever present, no userspace watchdog daemon
|
# 6. no userspace watchdog daemon running (the logging init and the
|
||||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
# absent watchdog init script are the rootfs lint's); kernel threads
|
||||||
ev["logging_init"] = logging
|
# (the imx2_wdt kthread is expected) have an empty cmdline
|
||||||
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
|
|
||||||
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
|
|
||||||
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
|
|
||||||
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||||
ev["userspace_watchdog_pids"] = user_wd
|
ev["userspace_watchdog_pids"] = user_wd
|
||||||
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||||
@@ -351,8 +330,9 @@ def image_health(ctx):
|
|||||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||||
# sshd host keys). The
|
# sshd host keys). The dev image's factory slots under /factory are
|
||||||
# dev image alone mounts the factory slots under /factory.
|
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||||
|
# factory slot are the rootfs lint's.
|
||||||
mounts = {}
|
mounts = {}
|
||||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
@@ -370,8 +350,6 @@ def image_health(ctx):
|
|||||||
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||||
ctx.check("rw" in mount_opts("/var/lib"),
|
ctx.check("rw" in mount_opts("/var/lib"),
|
||||||
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
||||||
rcs = _read("/etc/default/rcS", "") or ""
|
|
||||||
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
|
|
||||||
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||||
ev["factory_mounts"] = factory
|
ev["factory_mounts"] = factory
|
||||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||||
@@ -382,8 +360,6 @@ def image_health(ctx):
|
|||||||
if os.path.exists("/dev/mmcblk2p%d" % n):
|
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||||
p = "/factory/img%d" % n
|
p = "/factory/img%d" % n
|
||||||
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
||||||
else:
|
|
||||||
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
|
|
||||||
if os.path.isfile("/data/forgefirm/users"):
|
if os.path.isfile("/data/forgefirm/users"):
|
||||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||||
if l.strip() and not l.startswith("#")]
|
if l.strip() and not l.startswith("#")]
|
||||||
@@ -428,14 +404,10 @@ def image_health(ctx):
|
|||||||
for pid, ppid, _comm, cmd in v4:
|
for pid, ppid, _comm, cmd in v4:
|
||||||
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
# no DHCPv6 client: not running, not on the image
|
# no DHCPv6 client running (none on the image is the rootfs lint's)
|
||||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||||
ev["dhcp6_running"] = alive
|
ev["dhcp6_running"] = alive
|
||||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||||
for name in DHCP6_CLIENTS:
|
|
||||||
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
|
||||||
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
|
||||||
"the DHCPv6 hook script is on the image")
|
|
||||||
# IPv6 is the kernel's own. A global address is evidence only: a
|
# IPv6 is the kernel's own. A global address is evidence only: a
|
||||||
# network whose router advertisement offers no SLAAC prefix gives none.
|
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||||
|
|||||||
@@ -559,78 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
|
|||||||
return m if isinstance(m, dict) else {}
|
return m if isinstance(m, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cancel / abort
|
|
||||||
|
|
||||||
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
|
|
||||||
kind="auto", mode="grbl", est_min=2,
|
|
||||||
covers=_MOTION_COVERS, requires=["motion.pacing"],
|
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel."],
|
|
||||||
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
|
|
||||||
"position preserved; a ^X abort mid-move (what the sender's Stop sends) "
|
|
||||||
"decelerates under control into Alarm with machine position retained, leaves the "
|
|
||||||
"head where it stopped - no return-to-start, that is the lid policy's move alone - "
|
|
||||||
"and $X recovers to Idle with a subsequent jog running (no driver wedge: the rail "
|
|
||||||
"never cycled).")
|
|
||||||
def cancel_abort(ctx):
|
|
||||||
ev = ctx.evidence
|
|
||||||
with ctx.grbl() as g:
|
|
||||||
clean_slate(ctx, g)
|
|
||||||
start = g.status_report()["MPos"]
|
|
||||||
# jog cancel
|
|
||||||
g.command("$J=G91X40F2400")
|
|
||||||
ctx.sleep(0.4)
|
|
||||||
g.realtime(0x85)
|
|
||||||
st = wait_state(ctx, g, "Idle", 5)
|
|
||||||
ctx.check(st is not None, "not Idle within 5 s of the jog cancel")
|
|
||||||
p1 = st["MPos"]
|
|
||||||
moved1 = p1[0] - start[0]
|
|
||||||
ev["cancel_moved_mm"] = round(moved1, 3)
|
|
||||||
ctx.log("jog cancel: moved %.3f mm of 40 (state %s)", moved1, st["state"])
|
|
||||||
ctx.check(0.5 < moved1 < 39.0, "jog cancel did not stop short of the target (%.3f mm)", moved1)
|
|
||||||
# ^X abort mid-move
|
|
||||||
g.command("G91")
|
|
||||||
g.command("G1X30F600", timeout=0.5)
|
|
||||||
ctx.sleep(1.0)
|
|
||||||
g.realtime(0x18)
|
|
||||||
st = wait_state(ctx, g, "Alarm", 5)
|
|
||||||
ctx.check(st is not None, "^X did not land in Alarm within 5 s")
|
|
||||||
p2 = st["MPos"]
|
|
||||||
moved2 = p2[0] - p1[0]
|
|
||||||
ev["abort_moved_mm"] = round(moved2, 3)
|
|
||||||
ctx.log("^X abort: state %s, moved %.3f mm of 30, position retained %s", st["state"], moved2, p2)
|
|
||||||
ctx.check(0.5 < moved2 < 29.5, "abort position not retained/plausible (%.3f mm)", moved2)
|
|
||||||
r = g.command("$X")
|
|
||||||
ctx.log("$X -> %s", r)
|
|
||||||
st = wait_state(ctx, g, "Idle", 5)
|
|
||||||
ctx.check(st is not None, "$X did not recover to Idle")
|
|
||||||
# A sender abort is not a lid cancel: it stops where it stopped and
|
|
||||||
# the head stays there. Only the lid/interlock policy returns to the
|
|
||||||
# job start, and an unasked-for return move would be a surprise to
|
|
||||||
# whoever pressed Stop.
|
|
||||||
text = drain_text(g, 2.0)
|
|
||||||
ev["abort_returned_home"] = "returned to the job start" in text
|
|
||||||
p3 = g.status_report()["MPos"]
|
|
||||||
ev["abort_drift_after_recovery_mm"] = round(abs(p3[0] - p2[0]), 3)
|
|
||||||
ctx.log("after $X: %s (moved %.3f mm since the abort)", p3, ev["abort_drift_after_recovery_mm"])
|
|
||||||
ctx.check(not ev["abort_returned_home"], "a sender abort triggered the return-to-start motion")
|
|
||||||
ctx.check(ev["abort_drift_after_recovery_mm"] <= 0.05,
|
|
||||||
"the head moved %.3f mm on its own after a sender abort",
|
|
||||||
ev["abort_drift_after_recovery_mm"])
|
|
||||||
# a jog after the abort proves the drivers are alive; return to start
|
|
||||||
back = -(p2[0] - start[0])
|
|
||||||
r = g.command("$J=G91X%.3fF2400" % back)
|
|
||||||
ctx.check(not any(x.startswith("error") for x in r), "return jog refused: %s", r)
|
|
||||||
peak, states, st = wait_idle(ctx, g, 30)
|
|
||||||
ctx.check("TIMEOUT" not in states, "return jog did not complete: %s", states)
|
|
||||||
final = st["MPos"]
|
|
||||||
drift = abs(final[0] - start[0])
|
|
||||||
ev["final_drift_mm"] = round(drift, 3)
|
|
||||||
ctx.log("returned: drift %.3f mm", drift)
|
|
||||||
ctx.check(drift <= 0.05, "position drift %.3f mm after cancel/abort/return", drift)
|
|
||||||
g.command("G90")
|
|
||||||
machine_idle(ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- dead-man
|
# ---------------------------------------------------------------- dead-man
|
||||||
|
|
||||||
def _kernel_x_mm(ctx):
|
def _kernel_x_mm(ctx):
|
||||||
@@ -656,7 +584,7 @@ def _return_x(ctx, delta_mm):
|
|||||||
"a kill during the web-service homing",
|
"a kill during the web-service homing",
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
requires=["motion.pacing", "kernel.k1-k2"],
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
||||||
"With cloud mode enabled the test also homes through the web service and kills the "
|
"With cloud mode enabled the test also homes through the web service and kills the "
|
||||||
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""setup.* - the first-run setup: the record and the
|
"""setup.* - what the setup tests share: the setup record and its
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
builders, the files of the data directory, a takeover that installs a
|
||||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
record, and the HTTPS client. The tests are setup_dark's and
|
||||||
return.
|
setup_burns'; this module registers none.
|
||||||
|
|
||||||
The daemon reads the setup record (setup.json in the
|
The daemon reads the setup record (setup.json in the
|
||||||
data directory) and the account record (users) once, at its start, and
|
data directory) and the account record (users) once, at its start, and
|
||||||
@@ -27,19 +27,15 @@ import posixpath
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from ..catalog import test
|
|
||||||
from .. import hw
|
from .. import hw
|
||||||
|
|
||||||
RECORD = "setup.json"
|
RECORD = "setup.json"
|
||||||
USERS = "users"
|
USERS = "users"
|
||||||
SSH_FLAG = "ssh-enabled"
|
SSH_FLAG = "ssh-enabled"
|
||||||
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
|
||||||
|
|
||||||
# The first run's form wizards, all at version 1 (wiz.c).
|
# The first run's form wizards, all at version 1 (wiz.c).
|
||||||
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||||
@@ -52,8 +48,6 @@ REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
|
|||||||
# The setting that counts the intake fans: the gate holds without it.
|
# The setting that counts the intake fans: the gate holds without it.
|
||||||
INTAKE_KEY = "cool_intake_fans"
|
INTAKE_KEY = "cool_intake_fans"
|
||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
LOGIN_FAILS = 5
|
|
||||||
LOGIN_LOCK_S = 30
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- files
|
# ----------------------------------------------------------------- files
|
||||||
@@ -284,19 +278,6 @@ def decode(content):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def tls_base():
|
|
||||||
return (os.environ.get("FORGECTRL_TLS_URL") or TLS_BASE).rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_open(host, port, timeout=2.0):
|
|
||||||
try:
|
|
||||||
s = socket.create_connection((host, port), timeout=timeout)
|
|
||||||
s.close()
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def session_from_cookie(value):
|
def session_from_cookie(value):
|
||||||
"""The ffsid session id out of a Set-Cookie value, or None."""
|
"""The ffsid session id out of a Set-Cookie value, or None."""
|
||||||
for part in (value or "").split(";"):
|
for part in (value or "").split(";"):
|
||||||
@@ -318,218 +299,6 @@ def wiz(fc):
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
def mode(fc):
|
|
||||||
st, body = fc.get("/mode")
|
|
||||||
return body if (st == 200 and isinstance(body, dict)) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def press_status(fc):
|
|
||||||
"""GET /wiz/advisories/press, the page's poll at the press step: the
|
|
||||||
daemon takes a pressed button and records the acceptance on this
|
|
||||||
read, so a press nobody polls for stays 'pressed' and unaccepted."""
|
|
||||||
st, body = fc.get("/wiz/advisories/press")
|
|
||||||
return body if (st == 200 and isinstance(body, dict)) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def wiz_summary(w):
|
|
||||||
return {k: w.get(k) for k in ("first_run", "gate", "why", "recommended",
|
|
||||||
"advisories_complete", "acceptance_done", "account",
|
|
||||||
"fans_counted", "completed", "versions")}
|
|
||||||
|
|
||||||
|
|
||||||
def recommended(w):
|
def recommended(w):
|
||||||
"""The steps a GET /wiz body recommends: {id: reason}."""
|
"""The steps a GET /wiz body recommends: {id: reason}."""
|
||||||
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
|
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
|
||||||
|
|
||||||
|
|
||||||
def wait_settled(ctx):
|
|
||||||
"""The supervisor settled (the runner's own wait): the /mode body."""
|
|
||||||
return ctx.takeover().wait_settled()
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------- the login
|
|
||||||
|
|
||||||
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
|
|
||||||
kind="auto", est_min=2,
|
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
|
||||||
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
|
||||||
("forgectrl", "src/ui/login.*")],
|
|
||||||
requires=["forgectrl.auth"], hardware="takeover",
|
|
||||||
description="The test makes its own account: the account record is moved aside under a "
|
|
||||||
"forgectrl restart, the account route creates a temporary one with a password "
|
|
||||||
"only the test knows, and the real record comes back under another restart at "
|
|
||||||
"the end, the system accounts replayed and the temporary home removed. The data "
|
|
||||||
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
|
|
||||||
"the account opens it for search and nothing else (0711), and tried as the account, "
|
|
||||||
"its home can be entered and written while the data directory cannot be listed and "
|
|
||||||
"the account record cannot be read. Over "
|
|
||||||
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
|
|
||||||
"certificate fingerprint; POST /login with a wrong password is refused (401); "
|
|
||||||
"five failures lock the address (429, wait at most 30 s) and the right password "
|
|
||||||
"is refused inside the lock too; after the lock a wrong name is 401 and the "
|
|
||||||
"right pair sets the ffsid cookie (HttpOnly, Secure, SameSite=Strict); GET /wiz "
|
|
||||||
"with the cookie reports the session; POST /logout ends it.")
|
|
||||||
def account_login(ctx):
|
|
||||||
fc = ctx.forgectrl
|
|
||||||
ev = ctx.evidence
|
|
||||||
name = "forgetest"
|
|
||||||
pw = "".join(random.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(16))
|
|
||||||
before = wiz(fc)
|
|
||||||
ev["account_before"] = (before.get("users") or {}).get("name")
|
|
||||||
homes = "/data/forgefirm/home"
|
|
||||||
dir_mode = os.stat(data_dir()).st_mode & 0o7777
|
|
||||||
ev["data_dir_mode_found"] = "%04o" % dir_mode
|
|
||||||
try:
|
|
||||||
with installed(ctx, {users_path(): None}):
|
|
||||||
w = wiz(fc)
|
|
||||||
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
|
|
||||||
# the data directory as a strict umask leaves it: the account's render has to open it
|
|
||||||
os.chmod(data_dir(), 0o700)
|
|
||||||
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
|
|
||||||
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
|
|
||||||
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
|
|
||||||
w = wiz(fc)
|
|
||||||
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
|
|
||||||
reach = home_reach(name)
|
|
||||||
ev["home_reach"] = reach
|
|
||||||
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
|
|
||||||
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
|
|
||||||
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
|
|
||||||
"the account cannot use its own home %s: %s", reach.get("home"), reach)
|
|
||||||
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
|
|
||||||
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
|
|
||||||
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
|
|
||||||
"the account reads what is not its own: %s", reach)
|
|
||||||
login_checks(ctx, name, pw)
|
|
||||||
finally:
|
|
||||||
# as found; the replay below is the product's own say on the mode
|
|
||||||
os.chmod(data_dir(), dir_mode)
|
|
||||||
rc, out = hw.initd("forgefirm-users", "reload")
|
|
||||||
ev["users_reload_rc"] = rc
|
|
||||||
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
|
|
||||||
if name != ev["account_before"]:
|
|
||||||
import shutil
|
|
||||||
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
|
|
||||||
after = wiz(fc)
|
|
||||||
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
|
|
||||||
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
|
|
||||||
ev["account_before"])
|
|
||||||
|
|
||||||
|
|
||||||
# Run as root: becomes the account and tries its own home, and what is
|
|
||||||
# not its own. One JSON line.
|
|
||||||
HOME_REACH = r'''
|
|
||||||
import errno, json, os, pwd, sys
|
|
||||||
p = pwd.getpwnam(sys.argv[1])
|
|
||||||
os.setgroups([])
|
|
||||||
os.setgid(p.pw_gid)
|
|
||||||
os.setuid(p.pw_uid)
|
|
||||||
|
|
||||||
|
|
||||||
def tried(fn):
|
|
||||||
try:
|
|
||||||
fn()
|
|
||||||
return "ok"
|
|
||||||
except OSError as e:
|
|
||||||
return errno.errorcode.get(e.errno, str(e.errno))
|
|
||||||
|
|
||||||
|
|
||||||
def write():
|
|
||||||
path = os.path.join(p.pw_dir, ".forgetest-reach")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write("x")
|
|
||||||
os.remove(path)
|
|
||||||
|
|
||||||
|
|
||||||
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
|
|
||||||
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
|
|
||||||
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def home_reach(name):
|
|
||||||
"""What the account can do with its own home and with the data
|
|
||||||
directory around it, tried as the account."""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
|
|
||||||
try:
|
|
||||||
return json.loads(p.stdout)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": (p.stderr or p.stdout).strip()[-200:]}
|
|
||||||
|
|
||||||
|
|
||||||
def login_checks(ctx, name, pw):
|
|
||||||
"""The login rules against an account whose password the test knows."""
|
|
||||||
ev = ctx.evidence
|
|
||||||
base = tls_base()
|
|
||||||
|
|
||||||
st, body, hdrs = request(base, "GET", "/wiz")
|
|
||||||
w = decode(body)
|
|
||||||
ctx.log("GET %s/wiz -> %s", base, st)
|
|
||||||
ctx.check(st == 200 and isinstance(w, dict), "GET /wiz over HTTPS -> %s", st)
|
|
||||||
ctx.check(w.get("session") is False, "a session is reported without a cookie")
|
|
||||||
ev["tls_fingerprint"] = w.get("tls_fingerprint")
|
|
||||||
ctx.check(w.get("tls_fingerprint"), "GET /wiz carries no certificate fingerprint")
|
|
||||||
|
|
||||||
def attempt(n, p):
|
|
||||||
st, body, hdrs = request(base, "POST", "/login", data={"name": n, "password": p})
|
|
||||||
return st, decode(body), hdrs
|
|
||||||
|
|
||||||
# a lock left by an earlier run: wait it out first
|
|
||||||
st, body, hdrs = attempt(name, "not-" + pw)
|
|
||||||
if st == 429:
|
|
||||||
wait = int((body or {}).get("wait") or LOGIN_LOCK_S) if isinstance(body, dict) else LOGIN_LOCK_S
|
|
||||||
ctx.log("the address is locked from before (%s s): waiting", wait)
|
|
||||||
ctx.sleep(min(wait, LOGIN_LOCK_S) + 1)
|
|
||||||
st, body, hdrs = attempt(name, "not-" + pw)
|
|
||||||
codes = [st]
|
|
||||||
ctx.check(st == 401, "a wrong password -> %s, expected 401", st)
|
|
||||||
for i in range(LOGIN_FAILS + 1):
|
|
||||||
st, body, hdrs = attempt(name, "not-%s-%d" % (pw, i))
|
|
||||||
codes.append(st)
|
|
||||||
if st == 429:
|
|
||||||
break
|
|
||||||
ev["wrong_codes"] = codes
|
|
||||||
ctx.log("wrong password attempts -> %s", codes)
|
|
||||||
ctx.check(codes[-1] == 429 and all(c == 401 for c in codes[:-1]),
|
|
||||||
"the throttle did not lock after wrong attempts: %s", codes)
|
|
||||||
ctx.check(len(codes) - 1 <= LOGIN_FAILS, "the lock came after %d failures, the rule is %d",
|
|
||||||
len(codes) - 1, LOGIN_FAILS)
|
|
||||||
wait = body.get("wait") if isinstance(body, dict) else None
|
|
||||||
ev["wait"] = wait
|
|
||||||
ctx.check(isinstance(wait, int) and 0 < wait <= LOGIN_LOCK_S, "the lock reports wait=%r", wait)
|
|
||||||
st, body, hdrs = attempt(name, pw)
|
|
||||||
ev["right_inside_lock"] = st
|
|
||||||
ctx.check(st == 429, "the right password inside the lock -> %s, expected 429", st)
|
|
||||||
ctx.log("locked for %s s; the right password is refused inside the lock", wait)
|
|
||||||
ctx.sleep(wait + 1)
|
|
||||||
|
|
||||||
st, body, hdrs = attempt("not-" + name, pw)
|
|
||||||
ev["wrong_name"] = st
|
|
||||||
ctx.check(st == 401, "a wrong name with the right password -> %s, expected 401", st)
|
|
||||||
st, body, hdrs = attempt(name, pw)
|
|
||||||
cookie = hdrs.get("set-cookie") or ""
|
|
||||||
sid = session_from_cookie(cookie)
|
|
||||||
ev["login"] = st
|
|
||||||
ev["cookie_flags"] = sorted(cookie_flags(cookie))
|
|
||||||
ctx.log("POST /login (right) -> %s, cookie flags %s", st, ev["cookie_flags"])
|
|
||||||
ctx.check(st == 200, "the right name and password -> %s, expected 200", st)
|
|
||||||
ctx.check(sid and len(sid) == 64 and all(c in "0123456789abcdef" for c in sid),
|
|
||||||
"no ffsid session id in Set-Cookie: %r", cookie)
|
|
||||||
ctx.check({"httponly", "secure", "samesite"} <= cookie_flags(cookie),
|
|
||||||
"the cookie lacks HttpOnly, Secure, or SameSite: %r", cookie)
|
|
||||||
ctx.check("samesite=strict" in cookie.lower(), "the cookie is not SameSite=Strict: %r", cookie)
|
|
||||||
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
|
|
||||||
w = decode(body)
|
|
||||||
ctx.check(st == 200 and isinstance(w, dict) and w.get("session") is True,
|
|
||||||
"GET /wiz with the cookie does not report the session (%s %s)", st,
|
|
||||||
w.get("session") if isinstance(w, dict) else w)
|
|
||||||
st, body, hdrs = request(base, "POST", "/logout", headers={"Cookie": "ffsid=" + sid})
|
|
||||||
ev["logout"] = st
|
|
||||||
ctx.check(st == 200, "POST /logout -> %s", st)
|
|
||||||
ctx.check("max-age=0" in (hdrs.get("set-cookie") or "").lower(), "the logout did not clear the cookie")
|
|
||||||
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
|
|
||||||
w = decode(body)
|
|
||||||
ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout")
|
|
||||||
ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -120,6 +121,89 @@ class MachineNameTests(unittest.TestCase):
|
|||||||
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
|
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
|
||||||
|
|
||||||
|
|
||||||
|
# forgefirm-users rendering a record, as the image runs it, in a private
|
||||||
|
# mount namespace as root: /run a fresh tmpfs, /etc read-only with the
|
||||||
|
# scratch tree's account files over the host's, and the scratch data
|
||||||
|
# directory over /data (made for the run where the host has none, as
|
||||||
|
# fflog_e2e.sh does, and removed after). The data directory is root's, as
|
||||||
|
# on the machine, whoever made the scratch tree. Then, as the account's
|
||||||
|
# own uid, what it can do with its home and with the data directory
|
||||||
|
# around it. Exit 97: the namespace could not be set up.
|
||||||
|
ACCOUNTS = r"""
|
||||||
|
T=$1; SCRIPT=$2
|
||||||
|
made=
|
||||||
|
[ -d /data ] || { mkdir /data && made=1; } || exit 97
|
||||||
|
chown -R 0:0 "$T/data" &&
|
||||||
|
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
|
||||||
|
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc &&
|
||||||
|
mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; }
|
||||||
|
sh "$SCRIPT" reload
|
||||||
|
echo "rc=$?"
|
||||||
|
echo "mode=$(stat -c %a /data/forgefirm)"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import errno, json, os, pwd
|
||||||
|
p = pwd.getpwnam("bench")
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(p.pw_gid)
|
||||||
|
os.setuid(p.pw_uid)
|
||||||
|
def tried(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
return "ok"
|
||||||
|
except OSError as e:
|
||||||
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
def write():
|
||||||
|
path = os.path.join(p.pw_dir, ".reach")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
os.remove(path)
|
||||||
|
print("reach=" + json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)),
|
||||||
|
"write": tried(write), "list_data_dir": tried(lambda: os.listdir("/data/forgefirm")),
|
||||||
|
"read_record": tried(lambda: open("/data/forgefirm/users").read())}))
|
||||||
|
PY
|
||||||
|
cd /
|
||||||
|
umount /data
|
||||||
|
[ -z "$made" ] || rmdir /data
|
||||||
|
rm -rf "$T/data"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
|
||||||
|
class AccountRenderTests(unittest.TestCase):
|
||||||
|
"""forgefirm-users, the layer's own init script, rendering the record."""
|
||||||
|
|
||||||
|
def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self):
|
||||||
|
tree = tempfile.mkdtemp(prefix="forgetest-users.")
|
||||||
|
self.addCleanup(shutil.rmtree, tree, ignore_errors=True)
|
||||||
|
os.makedirs(os.path.join(tree, "etc"))
|
||||||
|
for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"),
|
||||||
|
("group", "root:x:0:\n"), ("gshadow", "root:*::\n")):
|
||||||
|
with open(os.path.join(tree, "etc", name), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
data = os.path.join(tree, "data", "forgefirm")
|
||||||
|
os.makedirs(data)
|
||||||
|
os.chmod(data, 0o700) # as a strict umask leaves it
|
||||||
|
with open(os.path.join(data, "users"), "w") as f:
|
||||||
|
f.write("bench:$6$salt$hash:1000\n")
|
||||||
|
os.chmod(os.path.join(data, "users"), 0o600)
|
||||||
|
try:
|
||||||
|
p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree,
|
||||||
|
os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
p = None
|
||||||
|
if p is None or "rc=" not in p.stdout:
|
||||||
|
if p is not None and p.returncode == 97:
|
||||||
|
raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip())
|
||||||
|
self.skipTest("no namespace can be made here (root through sudo -n)")
|
||||||
|
self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr)
|
||||||
|
self.assertEqual(field(p.stdout, "mode"), "711", p.stdout)
|
||||||
|
reach = json.loads(field(p.stdout, "reach"))
|
||||||
|
self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach)
|
||||||
|
self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach)
|
||||||
|
self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach)
|
||||||
|
|
||||||
|
|
||||||
class NetworkBootTests(unittest.TestCase):
|
class NetworkBootTests(unittest.TestCase):
|
||||||
STANZA = ("auto wlan0\n"
|
STANZA = ("auto wlan0\n"
|
||||||
"iface wlan0 inet dhcp\n"
|
"iface wlan0 inet dhcp\n"
|
||||||
@@ -255,6 +339,14 @@ class RootfsLintTests(unittest.TestCase):
|
|||||||
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
|
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
|
||||||
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
|
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
|
||||||
self.put("etc/nsswitch.conf", "hosts: files dns\n")
|
self.put("etc/nsswitch.conf", "hosts: files dns\n")
|
||||||
|
for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"):
|
||||||
|
self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755)
|
||||||
|
os.makedirs(self.path("etc/rc6.d"))
|
||||||
|
for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal",
|
||||||
|
"rc6.d/K90forgectrl"):
|
||||||
|
os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link))
|
||||||
|
self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n")
|
||||||
|
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n")
|
||||||
|
|
||||||
def path(self, rel):
|
def path(self, rel):
|
||||||
return os.path.join(self.root, rel)
|
return os.path.join(self.root, rel)
|
||||||
@@ -313,6 +405,33 @@ class RootfsLintTests(unittest.TestCase):
|
|||||||
os.makedirs(self.path("etc/avahi"))
|
os.makedirs(self.path("etc/avahi"))
|
||||||
self.assertFails("mdns")
|
self.assertFails("mdns")
|
||||||
|
|
||||||
|
def test_the_extension_host_starting_before_forgectrl_fails(self):
|
||||||
|
os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext"))
|
||||||
|
self.assertFails("init-order")
|
||||||
|
|
||||||
|
def test_no_logging_init_script_fails(self):
|
||||||
|
os.remove(self.path("etc/init.d/forgefirm-logging"))
|
||||||
|
self.assertFails("logging")
|
||||||
|
|
||||||
|
def test_a_userspace_watchdog_init_script_fails(self):
|
||||||
|
self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755)
|
||||||
|
self.assertFails("watchdog")
|
||||||
|
|
||||||
|
def test_rcs_without_the_read_only_rootfs_fails(self):
|
||||||
|
self.put("etc/default/rcS", "VERBOSE=no\n")
|
||||||
|
self.assertFails("read-only")
|
||||||
|
|
||||||
|
def test_a_dhcpv6_client_on_the_image_fails(self):
|
||||||
|
os.makedirs(self.path("sbin"))
|
||||||
|
os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6"))
|
||||||
|
self.assertFails("dhcpv6")
|
||||||
|
|
||||||
|
def test_a_factory_slot_in_fstab_fails_a_release_image_only(self):
|
||||||
|
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n")
|
||||||
|
self.assertFails("factory", "release")
|
||||||
|
p = self.lint("dev")
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# https://community.openglow.org
|
# https://community.openglow.org
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
"""The setup.* suite on the host: the registration (the operator tests'
|
||||||
takeover tests, the operator tests' hands), the record builders, the
|
hands, the intake tests' precheck), the record builders, the cookie
|
||||||
cookie parsing, and the settle rule for a gated supervisor."""
|
parsing, and the settle rule for a gated supervisor."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -18,19 +18,11 @@ import helpers # noqa: E402
|
|||||||
from forgetest import baseline, catalog # noqa: E402
|
from forgetest import baseline, catalog # noqa: E402
|
||||||
from forgetest.suite import setup # noqa: E402
|
from forgetest.suite import setup # noqa: E402
|
||||||
|
|
||||||
IDS = ("setup.account-login",)
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.reg = catalog.load_suite()
|
self.reg = catalog.load_suite()
|
||||||
|
|
||||||
def test_every_test_is_registered_with_its_kind(self):
|
|
||||||
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
|
|
||||||
self.assertEqual(sorted(kinds), sorted(IDS))
|
|
||||||
for tid in IDS:
|
|
||||||
self.assertEqual(kinds[tid], "auto", tid)
|
|
||||||
|
|
||||||
def test_recommended_reads_the_status_document(self):
|
def test_recommended_reads_the_status_document(self):
|
||||||
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
|
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
|
||||||
{"id": "laser.focus", "reason": "the tray was replaced"},
|
{"id": "laser.focus", "reason": "the tray was replaced"},
|
||||||
@@ -59,17 +51,6 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
|
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
|
||||||
|
|
||||||
def test_the_login_test_makes_its_own_account(self):
|
|
||||||
# No bench credentials, no precheck: the test installs a temporary
|
|
||||||
# account under a takeover and restores the real one.
|
|
||||||
t = self.reg["setup.account-login"]
|
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
|
||||||
self.assertEqual(t.hardware, "takeover")
|
|
||||||
with open(setup.__file__) as f:
|
|
||||||
src = f.read()
|
|
||||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
|
||||||
self.assertNotIn(k, src)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordTests(unittest.TestCase):
|
class RecordTests(unittest.TestCase):
|
||||||
STATUS = {"sheet_id": "ABCDE-FGHIJ",
|
STATUS = {"sheet_id": "ABCDE-FGHIJ",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "22a2cfc04242ddc50ed0a54e967a59634f3f4d6d"
|
SRCREV = "6c2bb389fa21ebefbde200e03e9b6487e9940369"
|
||||||
PV = "0.1.32"
|
PV = "0.1.33"
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "31cdb4005668286c9faa5b4b834f0cbc3fdbbe9b"
|
SRCREV = "bb75b23088b6ecce502ee3122bfb089b1c2641f1"
|
||||||
PV = "0.1.2"
|
PV = "0.1.4"
|
||||||
|
|||||||
@@ -29,6 +29,18 @@
|
|||||||
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
|
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
|
||||||
# libnss_mdns, and the hosts line of nsswitch.conf names no
|
# libnss_mdns, and the hosts line of nsswitch.conf names no
|
||||||
# mdns source.
|
# mdns source.
|
||||||
|
# init-order runlevel 5 starts forgectrl before forgeext, and runlevel 6
|
||||||
|
# stops grblhal before forgectrl and forgeext before
|
||||||
|
# forgectrl: the links sort in that order.
|
||||||
|
# logging a ForgeFIRM logging init script is on the image
|
||||||
|
# (forgefirm-logging or forgefirm-logrotate).
|
||||||
|
# watchdog no userspace watchdog init script (/etc/init.d/watchdog):
|
||||||
|
# the kernel feeds the hardware watchdog.
|
||||||
|
# read-only /etc/default/rcS sets ROOTFS_READ_ONLY=yes.
|
||||||
|
# dhcpv6 no DHCPv6 client (udhcpc6, dhcp6c, odhcp6c) in a bin
|
||||||
|
# directory, and no /usr/share/udhcpc/default6.script.
|
||||||
|
# factory on a release image, no /etc/fstab line mounts anything
|
||||||
|
# under /factory/ (the dev image mounts the factory slots).
|
||||||
|
|
||||||
root=${1:?rootfs}
|
root=${1:?rootfs}
|
||||||
kind=${2:?release or dev}
|
kind=${2:?release or dev}
|
||||||
@@ -141,4 +153,52 @@ if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'm
|
|||||||
fail mdns "nsswitch.conf resolves hosts through mdns"
|
fail mdns "nsswitch.conf resolves hosts through mdns"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ init-order
|
||||||
|
# link <runlevel dir> <S|K> <name>: the first link of that kind whose name
|
||||||
|
# ends in name, or nothing
|
||||||
|
link() {
|
||||||
|
for l in "$root/etc/$1"/"$2"*"$3"; do
|
||||||
|
[ -L "$l" ] || [ -e "$l" ] || continue
|
||||||
|
echo "${l##*/}"
|
||||||
|
return
|
||||||
|
done
|
||||||
|
}
|
||||||
|
# before <what> <a> <b>: a and b are both there and a sorts first
|
||||||
|
before() {
|
||||||
|
if [ -z "$2" ] || [ -z "$3" ]; then
|
||||||
|
fail init-order "$1: a link is missing (${2:-none}, ${3:-none})"
|
||||||
|
elif [ "$(printf '%s\n%s\n' "$2" "$3" | LC_ALL=C sort | head -n 1)" != "$2" ] || [ "$2" = "$3" ]; then
|
||||||
|
fail init-order "$1: $2 does not sort before $3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
before "rc5.d starts forgectrl before forgeext" "$(link rc5.d S forgectrl)" "$(link rc5.d S forgeext)"
|
||||||
|
before "rc6.d stops grblhal before forgectrl" "$(link rc6.d K grblhal)" "$(link rc6.d K forgectrl)"
|
||||||
|
before "rc6.d stops forgeext before forgectrl" "$(link rc6.d K forgeext)" "$(link rc6.d K forgectrl)"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- logging
|
||||||
|
[ -e "$root/etc/init.d/forgefirm-logging" ] || [ -e "$root/etc/init.d/forgefirm-logrotate" ] \
|
||||||
|
|| fail logging "no ForgeFIRM logging init script (forgefirm-logging, forgefirm-logrotate)"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- watchdog
|
||||||
|
[ -e "$root/etc/init.d/watchdog" ] && fail watchdog "/etc/init.d/watchdog is on the image"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- read-only
|
||||||
|
grep -qx 'ROOTFS_READ_ONLY=yes' "$root/etc/default/rcS" 2>/dev/null \
|
||||||
|
|| fail read-only "/etc/default/rcS does not set ROOTFS_READ_ONLY=yes"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dhcpv6
|
||||||
|
for d in bin sbin usr/bin usr/sbin; do
|
||||||
|
for p in udhcpc6 dhcp6c odhcp6c; do
|
||||||
|
if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then
|
||||||
|
fail dhcpv6 "/$d/$p is on the image"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
[ -e "$root/usr/share/udhcpc/default6.script" ] && fail dhcpv6 "/usr/share/udhcpc/default6.script is on the image"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- factory
|
||||||
|
if [ "$kind" = release ] && active "$root/etc/fstab" | awk '$2 ~ /^\/factory\// { f = 1 } END { exit !f }'; then
|
||||||
|
fail factory "/etc/fstab mounts a factory slot under /factory/ on a release image"
|
||||||
|
fi
|
||||||
|
|
||||||
exit $failed
|
exit $failed
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ reported messages:
|
|||||||
cancels it from the hold - reason reported, the reset banner, no
|
cancels it from the hold - reason reported, the reset banner, no
|
||||||
alarm, the return to the job start - and a later press resumes
|
alarm, the return to the job start - and a later press resumes
|
||||||
nothing
|
nothing
|
||||||
|
17. a jog cancel (0x85) stops a jog short of its target, Idle; a ^X
|
||||||
|
mid-move lands in Alarm with the position kept and no return to the
|
||||||
|
job start; $X recovers to Idle and the next jog ships steps
|
||||||
|
(GFSINK_DUMP)
|
||||||
|
|
||||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||||
@@ -214,9 +218,11 @@ class Session:
|
|||||||
"""One controller process with the lifecycle overrides applied."""
|
"""One controller process with the lifecycle overrides applied."""
|
||||||
|
|
||||||
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="",
|
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="",
|
||||||
armed_ack=True, ack_after_s=0.0):
|
armed_ack=True, ack_after_s=0.0, dump=False):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
|
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
|
||||||
|
# The stream the controller ships, when the case reads it.
|
||||||
|
self.dump = os.path.join(self.workdir, "stream") if dump else None
|
||||||
conf = os.path.join(self.workdir, "forgefirm.conf")
|
conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||||
with open(conf, "w") as f:
|
with open(conf, "w") as f:
|
||||||
# The fan grace at 0 keeps the arm's acknowledgment budget at its
|
# The fan grace at 0 keeps the arm's acknowledgment budget at its
|
||||||
@@ -228,6 +234,9 @@ class Session:
|
|||||||
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||||
env.pop("GFSINK", None)
|
env.pop("GFSINK", None)
|
||||||
env.pop("GF_SWITCH_FILE", None)
|
env.pop("GF_SWITCH_FILE", None)
|
||||||
|
env.pop("GFSINK_DUMP", None)
|
||||||
|
if self.dump:
|
||||||
|
env["GFSINK_DUMP"] = self.dump
|
||||||
self.switch_file = None
|
self.switch_file = None
|
||||||
if switches is not None:
|
if switches is not None:
|
||||||
self.switch_file = os.path.join(self.workdir, "switches")
|
self.switch_file = os.path.join(self.workdir, "switches")
|
||||||
@@ -868,6 +877,64 @@ def test_pause_then_lid_cancel_returns_to_the_job_start():
|
|||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_and_abort():
|
||||||
|
"""Rule 17: a jog cancel (0x85) stops a jog short of its target and
|
||||||
|
leaves the controller Idle. A ^X mid-move is a controlled abort: Alarm,
|
||||||
|
the position kept, and no return to the job start (that move is the lid
|
||||||
|
policy's alone). $X recovers to Idle, the head does not move on its
|
||||||
|
own, and the next jog ships steps."""
|
||||||
|
s = Session("cancel-abort", disarm_s=60, dump=True)
|
||||||
|
try:
|
||||||
|
x0 = s.mpos_x()
|
||||||
|
if x0 is None:
|
||||||
|
fail("[cancel-abort] no position in the status report")
|
||||||
|
s.send_raw("$J=G91X40F2400")
|
||||||
|
if not s.wait_state("Jog", 3):
|
||||||
|
fail("[cancel-abort] the jog never reported Jog (state %s)" % s.state())
|
||||||
|
time.sleep(0.3)
|
||||||
|
s.sock.sendall(b"\x85")
|
||||||
|
if not s.wait_state("Idle", 5):
|
||||||
|
fail("[cancel-abort] not Idle after the jog cancel (state %s)" % s.state())
|
||||||
|
x1 = s.mpos_x()
|
||||||
|
if x1 is None or not 0.5 < x1 - x0 < 39.0:
|
||||||
|
fail("[cancel-abort] the jog cancel did not stop short of the target (X %s from %.3f)"
|
||||||
|
% (x1, x0))
|
||||||
|
|
||||||
|
s.send_raw("M5")
|
||||||
|
s.send_raw("G91")
|
||||||
|
s.send_raw("G1 X30 F600") # 3 s
|
||||||
|
if not s.wait_state("Run", 5):
|
||||||
|
fail("[cancel-abort] the move never reported Run (state %s)" % s.state())
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.sock.sendall(b"\x18")
|
||||||
|
if not s.wait_state("Alarm", 5):
|
||||||
|
fail("[cancel-abort] ^X mid-move did not land in Alarm (state %s)" % s.state())
|
||||||
|
x2 = s.mpos_x()
|
||||||
|
if x2 is None or not 0.5 < x2 - x1 < 29.5:
|
||||||
|
fail("[cancel-abort] the abort did not keep a plausible position (X %s from %.3f)" % (x2, x1))
|
||||||
|
send_line(s.sock, "$X", s.log)
|
||||||
|
if not s.wait_state("Idle", 5):
|
||||||
|
fail("[cancel-abort] $X did not recover to Idle (state %s)" % s.state())
|
||||||
|
read_avail(s.sock, s.log, 1.0)
|
||||||
|
if "returned to the job start" in "".join(s.log):
|
||||||
|
fail("[cancel-abort] a sender abort triggered the return to the job start")
|
||||||
|
x3 = s.mpos_x()
|
||||||
|
if x3 is None or abs(x3 - x2) > 0.05:
|
||||||
|
fail("[cancel-abort] the head moved on its own after the abort (X %s, was %.3f)" % (x3, x2))
|
||||||
|
shipped = os.path.getsize(s.dump) if os.path.exists(s.dump) else 0
|
||||||
|
s.send_raw("$J=G91X-5F2400")
|
||||||
|
if not s.wait_state("Jog", 3) or not s.wait_state("Idle", 5):
|
||||||
|
fail("[cancel-abort] the jog after $X did not run (state %s)" % s.state())
|
||||||
|
grew = (os.path.getsize(s.dump) if os.path.exists(s.dump) else 0) - shipped
|
||||||
|
if grew <= 0:
|
||||||
|
fail("[cancel-abort] the jog after $X shipped no steps")
|
||||||
|
print("PASS [cancel-abort]: 0x85 stopped the jog at %.3f mm of 40, Idle; ^X mid-move landed in "
|
||||||
|
"Alarm at %.3f mm with no return; $X recovered to Idle and the next jog shipped %d bytes"
|
||||||
|
% (x1 - x0, x2 - x1, grew))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def test_lid_cancel_from_hold():
|
def test_lid_cancel_from_hold():
|
||||||
"""Rule 16: a job paused on the button is not resumable past a lid
|
"""Rule 16: a job paused on the button is not resumable past a lid
|
||||||
open. The lid cancels it from the hold as it does from Run - the
|
open. The lid cancels it from the hold as it does from Run - the
|
||||||
@@ -1008,6 +1075,7 @@ def main():
|
|||||||
test_button_pause_resume()
|
test_button_pause_resume()
|
||||||
test_lid_cancels_and_returns()
|
test_lid_cancels_and_returns()
|
||||||
test_pause_then_lid_cancel_returns_to_the_job_start()
|
test_pause_then_lid_cancel_returns_to_the_job_start()
|
||||||
|
test_cancel_and_abort()
|
||||||
test_lid_cancel_from_hold()
|
test_lid_cancel_from_hold()
|
||||||
test_lid_policy_hold()
|
test_lid_policy_hold()
|
||||||
test_sigterm_mid_job()
|
test_sigterm_mid_job()
|
||||||
|
|||||||
+11
-20
@@ -241,18 +241,14 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Root policy gate. The release image ships root WITHOUT a password (the
|
# Root policy gate. The release image ships root WITHOUT a password (the
|
||||||
# serial console is the recovery path) and sshd refuses root and empty
|
# serial console is the recovery path): root's shadow field must be empty.
|
||||||
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be
|
# sshd's refusal of root and of empty passwords is the rootfs lint's
|
||||||
# active (uncommented) in the built sshd_config, and root's shadow field
|
# (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails
|
||||||
# must be empty. Read the built files, not the recipes: this catches a
|
# the build. Read the built file, not the recipes: this catches a drift
|
||||||
# drift however it got in (recipe, local.conf, an inherited class).
|
# however it got in (recipe, local.conf, an inherited class).
|
||||||
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
|
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
|
||||||
[ -n "$SSHD_CONFIG" ] \
|
[ -n "$SSHD_CONFIG" ] \
|
||||||
|| die "release rootfs carries no /etc/ssh/sshd_config"
|
|| die "release rootfs carries no /etc/ssh/sshd_config"
|
||||||
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \
|
|
||||||
|| die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)"
|
|
||||||
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \
|
|
||||||
|| die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)"
|
|
||||||
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
|
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
|
||||||
| awk -F: '$1=="root"{print; exit}')
|
| awk -F: '$1=="root"{print; exit}')
|
||||||
[ -n "$ROOT_SHADOW" ] \
|
[ -n "$ROOT_SHADOW" ] \
|
||||||
@@ -260,27 +256,22 @@ ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
|
|||||||
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
|
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
|
||||||
[ -z "$ROOT_PW" ] \
|
[ -z "$ROOT_PW" ] \
|
||||||
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
|
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
|
||||||
echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)"
|
echo "root policy gate OK (root field empty)"
|
||||||
|
|
||||||
# Read-only rootfs gate. The release rootfs mounts read-only: the root
|
# Read-only rootfs gate. The release rootfs mounts read-only: the root
|
||||||
# line of the built fstab carries ro, the rcS default agrees (the
|
# line of the built fstab carries ro, and sshd keeps its host keys on
|
||||||
# read-only-rootfs image feature), no factory-slot mount is in the
|
# /data, where the read-only rootfs cannot hold them. rcS's
|
||||||
# release fstab (those belong to the dev image), and sshd keeps its host
|
# ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs
|
||||||
# keys on /data, where the read-only rootfs cannot hold them.
|
# lint's.
|
||||||
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
|
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
|
||||||
[ -n "$FSTAB" ] \
|
[ -n "$FSTAB" ] \
|
||||||
|| die "release rootfs carries no /etc/fstab"
|
|| die "release rootfs carries no /etc/fstab"
|
||||||
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
|
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
|
||||||
| grep -Eq '(^|,)ro(,|$)' \
|
| grep -Eq '(^|,)ro(,|$)' \
|
||||||
|| die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)"
|
|| die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)"
|
||||||
printf '%s\n' "$FSTAB" | grep -Eq '^[^#]*[[:space:]]/factory/' \
|
|
||||||
&& die "release fstab mounts a factory slot under /factory (dev image only)"
|
|
||||||
RCS=$(debugfs -R "cat /etc/default/rcS" "$EXT4" 2>/dev/null)
|
|
||||||
printf '%s\n' "$RCS" | grep -q '^ROOTFS_READ_ONLY=yes$' \
|
|
||||||
|| die "release rcS has no ROOTFS_READ_ONLY=yes (read-only-rootfs image feature drift?)"
|
|
||||||
[ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \
|
[ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \
|
||||||
|| die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)"
|
|| die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)"
|
||||||
echo "read-only rootfs gate OK (/ ro, no /factory mounts, host keys on /data)"
|
echo "read-only rootfs gate OK (/ ro, host keys on /data)"
|
||||||
|
|
||||||
# Config-level guard: debug-tweaks must not sit in the shared kas config,
|
# Config-level guard: debug-tweaks must not sit in the shared kas config,
|
||||||
# where it would apply to every target including the release image.
|
# where it would apply to every target including the release image.
|
||||||
|
|||||||
Reference in New Issue
Block a user