mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-19 02:51:42 -07:00
Compare commits
61
Commits
c40dc6c24c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9190ffddc | ||
|
|
5036517e22 | ||
|
|
0de9051820 | ||
|
|
9e7b065343 | ||
|
|
8fc411f50f | ||
|
|
4fa03f7e2b | ||
|
|
9c97f05db9 | ||
|
|
e1d1c1b84e | ||
|
|
b4493f5052 | ||
|
|
a3211ba1ea | ||
|
|
84a231163a | ||
|
|
c978e76b4c | ||
|
|
18cba1daa2 | ||
|
|
9c768619f8 | ||
|
|
79796b305b | ||
|
|
7a71dd80b5 | ||
|
|
614efb23e4 | ||
|
|
3aecf800a5 | ||
|
|
609ae561bf | ||
|
|
c849603942 | ||
|
|
6473e7d9ae | ||
|
|
fc6b7797af | ||
|
|
a10fd7782d | ||
|
|
2af9b0277f | ||
|
|
c717ecbcce | ||
|
|
49f087f376 | ||
|
|
e046c534c2 | ||
|
|
36c1b75bb8 | ||
|
|
ce73dbb052 | ||
|
|
7b0f065a3f | ||
|
|
d57421c71d | ||
|
|
50c56a863d | ||
|
|
2172ae4101 | ||
|
|
9451587728 | ||
|
|
bed46e961e | ||
|
|
41ca7fab82 | ||
|
|
eb684d366e | ||
|
|
9ea9c5d10f | ||
|
|
5d26818946 | ||
|
|
8d16b93e86 | ||
|
|
5bcc536853 | ||
|
|
c0c74d1658 | ||
|
|
b2a011ac08 | ||
|
|
68e9d3fb8e | ||
|
|
f47373a20d | ||
|
|
199282c029 | ||
|
|
6a4ece4b0d | ||
|
|
802cd3af8d | ||
|
|
099399b58f | ||
|
|
03146a1529 | ||
|
|
852943e20c | ||
|
|
a596767a9a | ||
|
|
3d2f2d9ab4 | ||
|
|
850498fb00 | ||
|
|
4d8f0d2e67 | ||
|
|
f165713d05 | ||
|
|
80ddd53bcd | ||
|
|
c3da9fa1bd | ||
|
|
56aa164708 | ||
|
|
df5ae151ab | ||
|
|
10ae7bf39c |
@@ -143,9 +143,18 @@ jobs:
|
||||
|
||||
- name: No undefined names (the live drills never execute on the host)
|
||||
working-directory: forgefirm/forgetest
|
||||
# pyflakes exits 1 for any message, unused imports included, so the
|
||||
# grep is the gate. A run stopped by SIGINT exits 1 too, and its
|
||||
# "... stopped" line fails the grep. Any other status (a usage
|
||||
# error, a signal pyflakes does not handle) fails the step and
|
||||
# prints the capture, and a capture that cannot be written fails it
|
||||
# too. pyflakes ends a SIGPIPE with status 1 and no line, and a
|
||||
# write to the capture file raises none.
|
||||
run: |
|
||||
pip install pyflakes
|
||||
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
|
||||
python -m pip install pyflakes
|
||||
python -B -m pyflakes --version
|
||||
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1 || { cat pyflakes.txt; exit 1; }
|
||||
if grep -E "undefined name|Traceback|^\.\.\. stopped$" pyflakes.txt; then exit 1; fi
|
||||
|
||||
- name: Shared UI files match forgectrl at its pinned revision
|
||||
working-directory: forgefirm
|
||||
@@ -153,7 +162,11 @@ jobs:
|
||||
|
||||
- name: Coverage lint (enforced)
|
||||
working-directory: forgefirm/forgetest
|
||||
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||
# The step runs under bash -e with no pipefail of its own: set here,
|
||||
# the status of the step is the lint's, not tee's.
|
||||
run: |
|
||||
set -o pipefail
|
||||
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Gate self-check on the tree manifest
|
||||
working-directory: forgefirm
|
||||
|
||||
@@ -133,7 +133,7 @@ ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
||||
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
||||
controller for a Grbl sender on TCP port 23. Cloud mode runs the
|
||||
factory experience through the Glowforge web service, and it is a deliberate,
|
||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||
and cloud mode runs end to end.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# The scratch trees forgectrl's cool_flow_test and super_test write in the
|
||||
# directory they run from
|
||||
/cool-flow-test/
|
||||
/super-test/
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
255
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1023
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
65535
|
||||
@@ -1 +0,0 @@
|
||||
26214
|
||||
@@ -1 +0,0 @@
|
||||
43278
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
204
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -69,11 +69,14 @@ class Test:
|
||||
def source_sha(self):
|
||||
"""The hash of the test's own implementation: its function (the
|
||||
decorator included) together with the code its module shares
|
||||
among its tests - everything outside the @test functions. Part
|
||||
of the fingerprint: a change inside one test's body invalidates
|
||||
that test's earlier passes and no other; a change to a helper
|
||||
invalidates the tests of that module. A test the module does not
|
||||
define in the @test form hashes its whole file."""
|
||||
among its tests (everything outside the @test functions), the
|
||||
shared code of every sibling module that either of the two
|
||||
imports, and the shared judges (SHARED_JUDGES). Part of the
|
||||
fingerprint: a change inside one test's body invalidates that
|
||||
test's earlier passes and no other; a change to a helper
|
||||
invalidates the tests of that module and the tests that import
|
||||
it; a change to a shared judge invalidates every test. A test the
|
||||
module does not define in the @test form hashes its whole file."""
|
||||
if self._source_sha is None:
|
||||
# One filler at a time: a second poll that arrives while the
|
||||
# first computes waits for it rather than repeating the work.
|
||||
@@ -144,6 +147,28 @@ def source_file_sha(path):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
# The package modules that judge the machine. Every test runs through
|
||||
# the first three without importing them: the Context a test is handed
|
||||
# and the takeover (runner.py), the sysfs, LED, switch and HTTP readers
|
||||
# behind it (hw.py), and the fresh-boot idle state judged around every
|
||||
# run (baseline.py). puls.py is the dark job the cloud and extension
|
||||
# tests print: suite/cloud.py imports it from the package (`from .. import
|
||||
# puls`), an import the sibling walk does not follow. Their text joins
|
||||
# every implementation hash: a change to one of them moves every test.
|
||||
# The rest of the package (the server, the page, the campaign rules, the
|
||||
# artifact, the manifest, this registry, the bench actuator) judges
|
||||
# nothing about the machine and stays out.
|
||||
SHARED_JUDGES = ("baseline.py", "hw.py", "puls.py", "runner.py")
|
||||
|
||||
|
||||
def shared_judge_paths():
|
||||
"""The SHARED_JUDGES files, next to this module. Every one has to be
|
||||
there: a judge missing from the package is an error, never a hash
|
||||
computed without it."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return [os.path.join(here, name) for name in SHARED_JUDGES]
|
||||
|
||||
|
||||
# The files never change under a run, so each is read and parsed once:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
@@ -178,6 +203,20 @@ def _parsed(path):
|
||||
return hit
|
||||
|
||||
|
||||
def _test_defs(tree):
|
||||
"""{test id: its function} for the @test("...") functions at the top
|
||||
level of a module's tree."""
|
||||
defs = {}
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for d in node.decorator_list:
|
||||
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
||||
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
||||
defs[d.args[0].value] = node
|
||||
return defs
|
||||
|
||||
|
||||
def module_parts(path):
|
||||
"""(shared sha, {test id: own sha}) for a suite module: the test ids
|
||||
are read from the @test("...") decorators on the module's top-level
|
||||
@@ -191,14 +230,9 @@ def module_parts(path):
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for d in node.decorator_list:
|
||||
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
||||
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
||||
for tid, node in _test_defs(tree).items():
|
||||
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||
spans[d.args[0].value] = (start, node.end_lineno)
|
||||
spans[tid] = (start, node.end_lineno)
|
||||
owned = set()
|
||||
for a, b in spans.values():
|
||||
owned.update(range(a, b + 1))
|
||||
@@ -211,10 +245,10 @@ def module_parts(path):
|
||||
|
||||
|
||||
def sibling_imports(path, seen=None):
|
||||
"""The suite modules a module imports with `from .x import ...`, as
|
||||
paths, transitively, in a stable order. A helper a test calls from
|
||||
a sibling module is part of the judge, so its text belongs in the
|
||||
test's fingerprint."""
|
||||
"""The suite modules a module's shared text imports with `from .x
|
||||
import ...` or `from . import x`, as paths, transitively, in a stable
|
||||
order. A helper a test calls from a sibling module is part of the
|
||||
judge, so its text belongs in the test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
for sib in _direct_siblings(path):
|
||||
@@ -224,32 +258,65 @@ def sibling_imports(path, seen=None):
|
||||
return seen
|
||||
|
||||
|
||||
def _imported(path, nodes):
|
||||
"""The suite modules the statements `nodes` import with `from .x
|
||||
import` or `from . import x` (each name a module of the suite), at any
|
||||
depth (inside a function or a block too), in the order they are
|
||||
written."""
|
||||
here = os.path.dirname(path)
|
||||
found = sorted((n for top in nodes for n in ast.walk(top)
|
||||
if isinstance(n, ast.ImportFrom) and n.level == 1),
|
||||
key=lambda n: (n.lineno, n.col_offset))
|
||||
out = []
|
||||
for node in found:
|
||||
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||
sib = os.path.join(here, name.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in out:
|
||||
out.append(sib)
|
||||
return out
|
||||
|
||||
|
||||
def _direct_siblings(path):
|
||||
"""The suite modules one module imports itself, in import order."""
|
||||
"""The suite modules one module's shared text imports itself: at its
|
||||
top level, and inside its helpers and blocks. What a @test function
|
||||
imports is that test's own (_own_siblings)."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
here = os.path.dirname(path)
|
||||
hit = []
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib):
|
||||
hit.append(sib)
|
||||
_SIBLINGS[path] = hit
|
||||
tests = list(_test_defs(tree).values())
|
||||
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
|
||||
hit = _SIBLINGS[path] = _imported(path, shared)
|
||||
return hit
|
||||
|
||||
|
||||
def implementation_sha(path, test_id):
|
||||
def _own_siblings(path, test_id):
|
||||
"""The suite modules one test's own function imports: part of that
|
||||
test's judge, and of no other test's."""
|
||||
_text, tree = _parsed(path)
|
||||
node = _test_defs(tree).get(test_id)
|
||||
return _imported(path, [node]) if node is not None else []
|
||||
|
||||
|
||||
def implementation_sha(path, test_id, judges=None):
|
||||
"""The implementation hash of one test (see Test.source_sha): the
|
||||
test's own text, the shared text of its module, and the shared text
|
||||
of every sibling suite module the module imports."""
|
||||
test's own text, the shared text of its module, the shared text of
|
||||
every sibling suite module the module's shared text or the test's
|
||||
own function imports (transitively), and the text of the shared
|
||||
judges. `judges` names files to take as the judges in place of
|
||||
SHARED_JUDGES (the unit tests edit their own)."""
|
||||
shared, own = module_parts(path)
|
||||
if test_id not in own:
|
||||
return source_file_sha(path)
|
||||
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
|
||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
|
||||
sibs = sibling_imports(path)
|
||||
for sib in _own_siblings(path, test_id):
|
||||
if sib not in sibs and sib != path:
|
||||
sibs.append(sib)
|
||||
sibling_imports(sib, sibs)
|
||||
helpers = [module_parts(p)[0] for p in sorted(sibs) if p != path]
|
||||
paths = shared_judge_paths() if judges is None else list(judges)
|
||||
judged = [module_parts(p)[0] for p in paths]
|
||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers + judged), own[test_id]))
|
||||
|
||||
|
||||
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
|
||||
|
||||
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
|
||||
|
||||
|
||||
class FixtureError(Exception):
|
||||
pass
|
||||
"""A request the fixture did not carry out as asked. `sent` is True
|
||||
when the failure came after the request went out (no reply, or a
|
||||
reply cut off), so the box may have acted on it; a refusal, a failed
|
||||
lookup and a failed connect leave it False."""
|
||||
|
||||
def __init__(self, *args, sent=False):
|
||||
super().__init__(*args)
|
||||
self.sent = sent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mDNS
|
||||
@@ -271,11 +278,20 @@ class Fixture:
|
||||
body = {}
|
||||
return e.code, body
|
||||
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||
# urllib raises a failed connect or send as a URLError; a
|
||||
# timeout or a reset after the request went out comes bare, and
|
||||
# the box may have acted on the request
|
||||
sent = not isinstance(e, urllib.error.URLError)
|
||||
if retry and not self.ip_override:
|
||||
# the address may have moved: one fresh lookup, one retry
|
||||
# the address may have moved: one fresh lookup, one retry,
|
||||
# and a failure of either still says the first one went out
|
||||
try:
|
||||
self.address(refresh=True)
|
||||
return self._request(method, path, body, retry=False)
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
||||
except FixtureError as again:
|
||||
again.sent = again.sent or sent
|
||||
raise
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
|
||||
|
||||
def status(self):
|
||||
st, body = self._request("GET", "/")
|
||||
@@ -314,11 +330,12 @@ class Fixture:
|
||||
|
||||
def _press(self, ms=None):
|
||||
"""One press, spaced from the last one. `ms` is how long the box
|
||||
holds it (the firmware clamps it into 20 to 500, and takes 200 when
|
||||
it is not said): a check that must reach the machine *while* the
|
||||
button is down asks for the longest press the box can make. The controller detects
|
||||
the button's rising edge, so a press must follow a release it has
|
||||
seen: the next press waits for the last pulse to end (the
|
||||
holds it: the box's own 200 ms when it is not said, and the box
|
||||
clamps a request into 20 to 500, so no press is ever held. Every
|
||||
reader of the button catches the default press (an edge, or a poll
|
||||
of 100 ms or less), so no check needs a longer one. The controller
|
||||
detects the button's rising edge, so a press must follow a release
|
||||
it has seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
progress (a press this client did not time) is waited out once
|
||||
against the fixture's state, then retried."""
|
||||
|
||||
+637
-59
@@ -118,12 +118,22 @@ BEHAVIORAL = [
|
||||
# the manifest (its tests/ and .github/ files are listed there), or
|
||||
# "forgefirm" for this repository (the null-sink harnesses under
|
||||
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
|
||||
# must run the host test: name it, or discover it (a glob, unittest
|
||||
# discover, pytest on its directory). A host-tested path counts as covered
|
||||
# in the coverage lint, and like every path no test covers it is in no
|
||||
# fingerprint: a change there makes no acceptance run necessary. A path a
|
||||
# catalog test exercises on the hardware belongs in that test's covers, not
|
||||
# here.
|
||||
# must run the host test: on a push or a pull_request with no branches,
|
||||
# tags or paths filter under it, and no types under the pull_request
|
||||
# (one of the two events is enough), in a step
|
||||
# that runs on every run of the workflow and whose failure fails it (no
|
||||
# `if:` other than `true` and no `continue-on-error:` other than `false`
|
||||
# on the step or its job, every job the job's `needs:` names one that
|
||||
# runs on every run, and no key with one stray quote where the lint
|
||||
# reads the workflow's keys), under bash or sh, on a line of the step's
|
||||
# run text whose failure ends the step (the command alone on its line,
|
||||
# or in a pipeline under pipefail, with errexit set and no line before
|
||||
# it that ends the step or opens a compound command: workflow_runs), and
|
||||
# name it, or discover it (a glob, unittest discover, pytest on its
|
||||
# directory). A host-tested path counts as covered in the coverage lint,
|
||||
# and like every path no test covers it is in no fingerprint: a change
|
||||
# there makes no acceptance run necessary. A path a catalog test
|
||||
# exercises on the hardware belongs in that test's covers, not here.
|
||||
HOST_TESTED = [
|
||||
# An extension package's holds as the engine reads them (forgeext's
|
||||
# holdkeep_test and run_test prove the writer's side).
|
||||
@@ -179,19 +189,23 @@ HOST_TESTED = [
|
||||
# The data and run directories from the environment: the SSH flag in the
|
||||
# run directory, written and removed by POST /system/ssh.
|
||||
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The one gate the test hooks are read through (the sysfs root, the switch
|
||||
# device and the run directory, honored only in a host-test run): the
|
||||
# daemon started with the hooks set and no gate ignores and names them.
|
||||
("forgectrl", "src/testhook.h", "forgectrl:tests/host_start.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# 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
|
||||
# form steps through main.c's hooks (extensions.md stays with
|
||||
# exthost.service, which covers it).
|
||||
# form steps through main.c's hooks; and the on-demand Extensions
|
||||
# advisory, accepted at its hash with the typed phrase.
|
||||
("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:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The setup sheet id from the fuse serial, against golden ids.
|
||||
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
@@ -207,6 +221,34 @@ HOST_TESTED = [
|
||||
# store that holds only hashes, and the extension host's credential
|
||||
# (mode 0600, exactly the host's capabilities).
|
||||
("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
|
||||
@@ -388,33 +430,297 @@ def repo_name(manifest, repo):
|
||||
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
|
||||
"continue-on-error", "timeout-minutes")
|
||||
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
|
||||
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
|
||||
# A mapping key, plain or in a matched pair of quotes ("if": is the same
|
||||
# key as if:).
|
||||
_KEY_RX = re.compile(r"""^(\s*)(?=(?:"[\w-]+"|'[\w-]+'|[\w-]+)\s*:)["']?([\w-]+)["']?\s*:\s*(.*)$""")
|
||||
# A key with one stray quote (if": or "if:), which _KEY_RX does not read:
|
||||
# YAML reads a key with the quote in its name, which no workflow key and
|
||||
# no event has, or refuses the file.
|
||||
_STRAY_RX = re.compile(r"""^\s*["']?[\w-]+["']?\s*:""")
|
||||
_ON_RX = re.compile(r"""^(?:on|"on"|'on')\s*:\s*(.*)$""")
|
||||
# What a job or a step says about whether it runs on every run of the
|
||||
# workflow, and whether its failure fails the run.
|
||||
_CONDITION_KEYS = ("if", "continue-on-error")
|
||||
|
||||
|
||||
def _indent(line):
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def workflow_steps(text):
|
||||
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
||||
command text, a block's lines joined), "working-directory", and "with"
|
||||
(a dict). YAML read by its indentation, as far as these workflows use
|
||||
it; comments, whole lines and from ' #' to the end of a line, are not
|
||||
read. Anything outside a step (a trigger's paths, a job's name) is not
|
||||
a step."""
|
||||
lines = []
|
||||
def _stray_key(line):
|
||||
"""Whether a line is a mapping key with one stray quote."""
|
||||
return _KEY_RX.match(line) is None and _STRAY_RX.match(line) is not None
|
||||
|
||||
|
||||
def _lines(text):
|
||||
"""A workflow's lines with the comments dropped: whole comment lines,
|
||||
and from ' #' to the end of a line."""
|
||||
out = []
|
||||
for ln in text.splitlines():
|
||||
if ln.lstrip().startswith("#"):
|
||||
continue
|
||||
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
||||
out.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
||||
return out
|
||||
|
||||
|
||||
def _scalar(val):
|
||||
"""A key's value, a quoted scalar without its quotes (a quote inside a
|
||||
command stays)."""
|
||||
val = val.strip()
|
||||
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
||||
return val[1:-1]
|
||||
return val
|
||||
|
||||
|
||||
def workflow_triggers(text):
|
||||
"""The events a workflow runs on: the keys of its `on:` mapping, the
|
||||
items of its list (`on: [push, pull_request]`, or one per line), or
|
||||
its one scalar (`on: push`). What is under an event (its branches,
|
||||
its paths, a dispatch's inputs) is not an event (workflow_filters
|
||||
reads the filters). A flow mapping (`on: {push: ...}`) is not read,
|
||||
and names no event. An event key with one stray quote (`push":`) is
|
||||
no event, and the workflow then reads as one that runs on none."""
|
||||
lines = _lines(text)
|
||||
for i, ln in enumerate(lines):
|
||||
m = _ON_RX.match(ln) if not _indent(ln) else None
|
||||
if not m:
|
||||
continue
|
||||
val = m.group(1).strip()
|
||||
if val.startswith("["):
|
||||
return {_scalar(x) for x in val.strip("[]").split(",") if x.strip()}
|
||||
if val:
|
||||
return set() if val.startswith("{") else {_scalar(val)}
|
||||
out, at = set(), None
|
||||
for child in lines[i + 1:]:
|
||||
if not child.strip():
|
||||
continue
|
||||
ind = _indent(child)
|
||||
if ind == 0:
|
||||
break
|
||||
at = ind if at is None else at
|
||||
if ind != at:
|
||||
continue
|
||||
body = child.strip()
|
||||
if body.startswith("- "):
|
||||
out.add(_scalar(body[2:]))
|
||||
elif _stray_key(child):
|
||||
return set()
|
||||
else:
|
||||
km = _KEY_RX.match(child)
|
||||
if km:
|
||||
out.add(km.group(2))
|
||||
return out
|
||||
return set()
|
||||
|
||||
|
||||
# What under a push or a pull_request of `on:` keeps the workflow from a
|
||||
# run: a ref or a path the event does not match, and under a pull_request
|
||||
# alone, an activity type the pull request's event is not.
|
||||
_FILTER_KEYS = ("branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore")
|
||||
_PULL_REQUEST_FILTER_KEYS = _FILTER_KEYS + ("types",)
|
||||
|
||||
|
||||
def workflow_filters(text):
|
||||
"""The events of a workflow's `on:` block mapping that carry a filter,
|
||||
{event: [what]}: each `branches:`, `branches-ignore:`, `tags:`,
|
||||
`tags-ignore:`, `paths:` or `paths-ignore:` key under the event, and
|
||||
under a pull_request a `types:` key too, by name; a value on the
|
||||
event's own line (`push: {branches: [main]}`), which the lint does
|
||||
not read, as "a value the lint does not read"; and a key with one
|
||||
stray quote under the event (`paths":`), as "a key the lint does not
|
||||
read". Every filter counts: a push with only a branches or a tags
|
||||
filter runs on no push of the other kind of ref, and a branch it
|
||||
names is one the lint does not know, as the manifest carries no
|
||||
branch. A pull_request under `types:` runs on the activity types it
|
||||
names and no other, so one under `types: [closed]` runs on no change
|
||||
of a pull request; the lint reads no type, so any `types:` is a
|
||||
filter. A key deeper than the event's own level (a dispatch's
|
||||
inputs) is no filter. An event of a list or a scalar `on:` carries
|
||||
none."""
|
||||
lines = _lines(text)
|
||||
out = {}
|
||||
for i, ln in enumerate(lines):
|
||||
m = _ON_RX.match(ln) if not _indent(ln) else None
|
||||
if not m:
|
||||
continue
|
||||
if m.group(1).strip():
|
||||
return out
|
||||
at, event, sub = None, None, None
|
||||
for child in lines[i + 1:]:
|
||||
if not child.strip():
|
||||
continue
|
||||
ind = _indent(child)
|
||||
if ind == 0:
|
||||
break
|
||||
at = ind if at is None else at
|
||||
km = _KEY_RX.match(child)
|
||||
if ind == at:
|
||||
event, sub = None, None
|
||||
if km:
|
||||
event = km.group(2)
|
||||
if km.group(3).strip():
|
||||
out.setdefault(event, []).append("a value the lint does not read")
|
||||
elif event is not None and ind > at:
|
||||
sub = ind if sub is None else sub
|
||||
if ind != sub:
|
||||
continue
|
||||
if _stray_key(child):
|
||||
out.setdefault(event, []).append("a key the lint does not read")
|
||||
elif km and km.group(2) in (_PULL_REQUEST_FILTER_KEYS if event == "pull_request" else _FILTER_KEYS):
|
||||
out.setdefault(event, []).append(km.group(2))
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _needs(lines, i):
|
||||
"""The job names the `needs:` key at lines[i] names: a flow list
|
||||
(`[build, lint]`, on the key's line or continued on the lines under
|
||||
it up to its `]`), one scalar, or a block list on the lines under it
|
||||
(its items deeper than the key, or at the key's own indentation).
|
||||
None for every other form, which the lint does not read: a name no
|
||||
job has, so the need is never met. That is an empty `needs:` (`[]`,
|
||||
or nothing under the key), a flow list a key or a shallower line
|
||||
interrupts before its `]`, a line under the key that is no list
|
||||
item, and list items at two indentations."""
|
||||
key_at = _indent(lines[i])
|
||||
val = _KEY_RX.match(lines[i]).group(3).strip()
|
||||
if val.startswith("["):
|
||||
for ln in lines[i + 1:]:
|
||||
if "]" in val:
|
||||
break
|
||||
if not ln.strip():
|
||||
continue
|
||||
ind, body = _indent(ln), ln.strip()
|
||||
if ind < key_at or (ind == key_at and not body.startswith("]")):
|
||||
return None
|
||||
val += " " + body
|
||||
if "]" not in val:
|
||||
return None
|
||||
return [_scalar(x) for x in val[1:val.index("]")].split(",") if x.strip()] or None
|
||||
if val:
|
||||
return [_scalar(val)]
|
||||
out, at = [], None
|
||||
for ln in lines[i + 1:]:
|
||||
if not ln.strip():
|
||||
continue
|
||||
ind, body = _indent(ln), ln.strip()
|
||||
if ind < key_at or (ind == key_at and not body.startswith("- ")):
|
||||
break
|
||||
at = ind if at is None else at
|
||||
if not body.startswith("- ") or ind != at:
|
||||
return None
|
||||
out.append(_scalar(body[2:]))
|
||||
return out or None
|
||||
|
||||
|
||||
def _needs_run(jobs):
|
||||
"""Whether each job's `needs:` are met on every run of the workflow,
|
||||
as "needs_run" on the job's dict: True when every job it needs runs
|
||||
on every run (no `if:` other than the literal true, and its own
|
||||
needs met, transitively), False when one does not, names no job of
|
||||
the workflow, or is in a cycle with it, or when the `needs:` is a
|
||||
form the lint does not read. The lint evaluates no condition, so a
|
||||
job under `always()` counts as no job, as its steps do. A needed
|
||||
job's `continue-on-error:` does not bear: its failure fails neither
|
||||
the run nor the jobs after it."""
|
||||
state = {}
|
||||
|
||||
def runs(name, trail):
|
||||
if name in state:
|
||||
return state[name]
|
||||
job = jobs.get(name)
|
||||
if job is None or name in trail:
|
||||
return False
|
||||
needs = job.get("needs", [])
|
||||
met = needs is not None and all(runs(n, trail | {name}) for n in needs)
|
||||
job["needs_run"] = met
|
||||
state[name] = met and job.get("if", "true").lower() == "true"
|
||||
return state[name]
|
||||
for name in jobs:
|
||||
runs(name, frozenset())
|
||||
|
||||
|
||||
def _defaults(lines, i):
|
||||
"""{"shell": its value} for the `defaults:` key at lines[i] when it
|
||||
gives the run steps a shell:, {} when it gives none. A flow mapping
|
||||
(at the key or at its run:) and a key with one stray quote under it
|
||||
give None: a shell the lint does not read."""
|
||||
if _KEY_RX.match(lines[i]).group(3).strip():
|
||||
return {"shell": None}
|
||||
out = {}
|
||||
for ln in lines[i + 1:]:
|
||||
if not ln.strip():
|
||||
continue
|
||||
if _indent(ln) <= _indent(lines[i]):
|
||||
break
|
||||
km = _KEY_RX.match(ln)
|
||||
if _stray_key(ln) or (km and km.group(2) == "run" and km.group(3).strip()):
|
||||
return {"shell": None}
|
||||
if km and km.group(2) == "shell":
|
||||
out["shell"] = _scalar(km.group(3))
|
||||
return out
|
||||
|
||||
|
||||
def workflow_steps(text):
|
||||
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
||||
command text: a literal block's lines one per line; a folded block,
|
||||
or a scalar continued on the lines under its key, as one line),
|
||||
"working-directory", "with" (a dict), "shell", "if" and
|
||||
"continue-on-error" as written; "job": the "if" and
|
||||
"continue-on-error" of the job the step is in, when it is in one,
|
||||
its "needs" (_needs) and whether they are met on every run
|
||||
("needs_run", _needs_run), and the "shell" its `defaults:` give; and
|
||||
"workflow": the "shell" the
|
||||
workflow's own `defaults:` give, when they give one (_defaults). YAML
|
||||
read by its indentation, as far as these workflows use it; comments,
|
||||
whole lines and from ' #' to the end of a line, are not read.
|
||||
Anything outside a step (a trigger's paths, a job's name) is not a
|
||||
step. A key with one stray quote (`if":`, `"run:`) at the top level,
|
||||
at a job's name or a job's key, at a step's key, or as the first key of
|
||||
a list item outside a step (a step's first key is on its dash line,
|
||||
`- run": make`; a matrix entry is such an item; a list inside a step's
|
||||
body is not read) is no key the lint reads: every step of the workflow
|
||||
then carries "unread" and counts as no step."""
|
||||
lines = _lines(text)
|
||||
steps, i = [], 0
|
||||
in_jobs, job_at, key_at, job, jobs = False, None, None, None, {}
|
||||
stray, workflow = False, {}
|
||||
while i < len(lines):
|
||||
km = _KEY_RX.match(lines[i])
|
||||
if km: # a mapping key: which job this is, and what the job itself says
|
||||
ind, key = _indent(lines[i]), km.group(2)
|
||||
if ind == 0:
|
||||
in_jobs, job_at, key_at, job = key == "jobs", None, None, None
|
||||
if key == "defaults":
|
||||
workflow.update(_defaults(lines, i))
|
||||
elif in_jobs and (job_at is None or ind == job_at):
|
||||
job_at, key_at, job = ind, None, {}
|
||||
jobs[key] = job
|
||||
elif in_jobs and job_at is not None and ind > job_at and (key_at is None or ind == key_at):
|
||||
key_at = ind
|
||||
if key in _CONDITION_KEYS:
|
||||
job[key] = _scalar(km.group(3))
|
||||
elif key == "needs":
|
||||
job["needs"] = _needs(lines, i)
|
||||
elif key == "defaults":
|
||||
job.update(_defaults(lines, i))
|
||||
elif _stray_key(lines[i]):
|
||||
ind = _indent(lines[i])
|
||||
stray = stray or ind == 0 or (in_jobs and (job_at is None or ind == job_at or (
|
||||
ind > job_at and (key_at is None or ind == key_at))))
|
||||
# A list item's first key is on its dash line, where _STEP_RX reads no
|
||||
# key with a stray quote: the dash and the spaces after it become spaces.
|
||||
item = re.match(r"^\s*-\s+", lines[i])
|
||||
if item and _stray_key(" " * item.end() + lines[i][item.end():]):
|
||||
stray = True
|
||||
m = _STEP_RX.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
dash = len(m.group(1))
|
||||
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
|
||||
# The dash becomes a space, nothing realigned: the first key stays at
|
||||
# the column the step's other keys use, however many spaces follow it.
|
||||
body = [" " * (dash + 1) + lines[i][dash + 1:]]
|
||||
i += 1
|
||||
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
|
||||
body.append(lines[i])
|
||||
@@ -424,11 +730,10 @@ def workflow_steps(text):
|
||||
while k < len(body):
|
||||
km = _KEY_RX.match(body[k])
|
||||
if not km or _indent(body[k]) != key_col:
|
||||
stray = stray or (_indent(body[k]) == key_col and _stray_key(body[k]))
|
||||
k += 1
|
||||
continue
|
||||
key, val = km.group(2), km.group(3).strip()
|
||||
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
||||
val = val[1:-1] # a quoted scalar, not a quote inside a command
|
||||
key, val = km.group(2), _scalar(km.group(3))
|
||||
k += 1
|
||||
child = []
|
||||
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
|
||||
@@ -438,20 +743,59 @@ def workflow_steps(text):
|
||||
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
|
||||
for c in (_KEY_RX.match(x) for x in child) if c}
|
||||
elif key == "run":
|
||||
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
|
||||
# YAML folds a folded block, and a plain or quoted scalar continued on the
|
||||
# lines under its key, into one line where no blank or more indented line
|
||||
# breaks it. Read as one line: where YAML keeps a break, the joined line
|
||||
# counts no command that the shell's own lines would not.
|
||||
text = [x.strip() for x in child if x.strip()]
|
||||
if val[:1] == "|":
|
||||
step["run"] = "\n".join(x.strip() for x in child)
|
||||
elif val[:1] == ">":
|
||||
step["run"] = " ".join(text)
|
||||
else:
|
||||
step["run"] = _scalar(" ".join([km.group(3).strip()] + text))
|
||||
else:
|
||||
step[key] = val
|
||||
if job is not None:
|
||||
step["job"] = job # the job's own dict: a job key written after its steps counts too
|
||||
steps.append(step)
|
||||
_needs_run(jobs)
|
||||
for s in steps:
|
||||
if stray:
|
||||
s["unread"] = True
|
||||
if workflow:
|
||||
s["workflow"] = workflow
|
||||
return steps
|
||||
|
||||
|
||||
def _on_every_run(step):
|
||||
"""Whether the lint counts a step as one that runs on every run of
|
||||
its workflow and fails the run when it fails: neither the step nor
|
||||
its job carries an `if:` other than the literal `true` or a
|
||||
`continue-on-error:` other than the literal `false`, the job's
|
||||
`needs:` are met on every run (_needs_run: GitHub skips a job whose
|
||||
needed job did not run, unless the job says `always()`, which the
|
||||
lint does not evaluate), and the workflow has no key the lint
|
||||
cannot read ("unread"). The lint evaluates no condition, so a step
|
||||
under `always()` or `success()` counts as no step too."""
|
||||
if step.get("unread"):
|
||||
return False
|
||||
job = step.get("job") or {}
|
||||
if not job.get("needs_run", True):
|
||||
return False
|
||||
for d in (step, job):
|
||||
if d.get("if", "true").lower() != "true" or d.get("continue-on-error", "false").lower() != "false":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def checkout_path(steps, name, own):
|
||||
"""Where a workflow checks the repository `name` out (its path, "" for
|
||||
the workspace), or None when it does not: its own repository is the
|
||||
checkout that names no repository, another one the checkout whose
|
||||
repository is <owner>/<name>."""
|
||||
for s in steps:
|
||||
if not s.get("uses", "").startswith("actions/checkout"):
|
||||
if not _on_every_run(s) or not s.get("uses", "").startswith("actions/checkout"):
|
||||
continue
|
||||
w = s.get("with", {})
|
||||
repo = w.get("repository")
|
||||
@@ -460,33 +804,224 @@ def checkout_path(steps, name, own):
|
||||
return None
|
||||
|
||||
|
||||
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
|
||||
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
|
||||
# What only starts the real command, and the flags with which each one
|
||||
# runs it. Any other flag (`command -v` and `sudo -l` print the command)
|
||||
# leaves the prefix as the command.
|
||||
_PREFIXES = {"sudo": ("-E", "-H", "-n"), "env": ("-i", "-"), "time": ("-p",), "command": ("-p",), "nice": ()}
|
||||
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
|
||||
# The shell's operators, the longest first. A redirection keeps a command
|
||||
# one command, and a pipe joins commands into a pipeline; any other
|
||||
# operator makes a list of commands, puts one in the background, or opens
|
||||
# a subshell or a here-document.
|
||||
_OPERATORS = ("&>>", "&&", "||", ";;", ";&", "|&", ">>", "<<", ">&", "<&", "<>", ">|", "&>",
|
||||
"|", "&", ";", "(", ")", "<", ">")
|
||||
_REDIRECTIONS = frozenset((">", ">>", "<", ">&", "<&", "<>", ">|", "&>", "&>>"))
|
||||
# A command word that opens or closes a compound command (an if, a case,
|
||||
# a loop, a group, a function) or negates a status.
|
||||
_RESERVED = frozenset(("if", "then", "elif", "else", "fi", "case", "esac", "for", "select", "while",
|
||||
"until", "do", "done", "function", "coproc", "{", "}", "[[", "]]", "!"))
|
||||
# A command after which the lint cannot tell what a later line runs, or
|
||||
# whether a failure still ends the step.
|
||||
_STOPS = frozenset(("exit", "return", "exec", "trap", "eval", "source", ".", "alias", "shopt",
|
||||
"enable", "builtin", "hash", "break", "continue", "logout"))
|
||||
# The `set` options the lint follows (errexit, pipefail) or knows to
|
||||
# change neither; a `set` of any other option ends what it reads.
|
||||
_SET_FLAGS = {"e": "errexit", "u": "nounset", "x": "xtrace", "v": "verbose"}
|
||||
_SET_NAMES = frozenset(("errexit", "pipefail", "nounset", "xtrace", "verbose"))
|
||||
|
||||
|
||||
def _commands(run):
|
||||
"""The simple commands of a step's run text, each as its words, with
|
||||
what only starts the real command taken off: sudo and its flags, env,
|
||||
time, exec, and variable assignments."""
|
||||
import shlex
|
||||
out = []
|
||||
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
|
||||
try:
|
||||
words = shlex.split(part)
|
||||
except ValueError:
|
||||
words = part.split()
|
||||
def _part_end(line, i):
|
||||
"""Where the part of a shell word that starts at line[i] ends: a
|
||||
quoted string, an escaped character, an expansion ($(...), ${...},
|
||||
`...`), or one character; -1 when it does not close on the line."""
|
||||
n, c = len(line), line[i]
|
||||
if c == "\\":
|
||||
return i + 2 if i + 1 < n else -1
|
||||
if c == "'":
|
||||
j = line.find("'", i + 1)
|
||||
return j + 1 if j > 0 else -1
|
||||
if c in "\"`":
|
||||
j = i + 1
|
||||
while j < n and line[j] != c:
|
||||
j = _part_end(line, j) if line[j] in "\\$" or (c == '"' and line[j] == "`") else j + 1
|
||||
if j < 0:
|
||||
return -1
|
||||
return j + 1 if j < n else -1
|
||||
if c == "$" and line[i + 1:i + 2] in ("(", "{"):
|
||||
opener, depth, j = line[i + 1], 0, i + 1
|
||||
while j < n:
|
||||
if line[j] == opener:
|
||||
depth, j = depth + 1, j + 1
|
||||
elif line[j] == (")" if opener == "(" else "}"):
|
||||
depth, j = depth - 1, j + 1
|
||||
if not depth:
|
||||
return j
|
||||
elif line[j] in "\\'\"`$":
|
||||
j = _part_end(line, j)
|
||||
if j < 0:
|
||||
return -1
|
||||
else:
|
||||
j += 1
|
||||
return -1
|
||||
return i + 1
|
||||
|
||||
|
||||
def _shell_words(line):
|
||||
"""One line of shell as the lint reads it: [(True, word) or (False,
|
||||
operator)], a word with its quotes taken off and its expansions kept
|
||||
as written; None when a quote or an expansion does not close on the
|
||||
line."""
|
||||
out, word, i = [], None, 0
|
||||
while i < len(line):
|
||||
c = line[i]
|
||||
if c in " \t|&;()<>":
|
||||
if word is not None:
|
||||
out.append((True, word))
|
||||
word = None
|
||||
if c in " \t":
|
||||
i += 1
|
||||
else:
|
||||
op = next(o for o in _OPERATORS if line.startswith(o, i))
|
||||
out.append((False, op))
|
||||
i += len(op)
|
||||
continue
|
||||
j = _part_end(line, i)
|
||||
if j < 0:
|
||||
return None
|
||||
part = line[i:j]
|
||||
if part[0] in "'\"":
|
||||
part = part[1:-1]
|
||||
elif part[0] == "\\":
|
||||
part = part[1:]
|
||||
word = (word or "") + part
|
||||
i = j
|
||||
if word is not None:
|
||||
out.append((True, word))
|
||||
return out
|
||||
|
||||
|
||||
def _command(words):
|
||||
"""A simple command's words with what only starts the real command
|
||||
taken off: sudo, env, time, command and nice with the flags with which
|
||||
they run it (_PREFIXES), and variable assignments."""
|
||||
while words:
|
||||
if words[0] in _PREFIXES:
|
||||
words = words[1:]
|
||||
while words and words[0].startswith("-"):
|
||||
words = words[1:]
|
||||
rest = words[1:]
|
||||
while rest and rest[0] in _PREFIXES[words[0]]:
|
||||
rest = rest[1:]
|
||||
if rest[:1] and rest[0].startswith("-"):
|
||||
break
|
||||
words = rest
|
||||
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
|
||||
words = words[1:]
|
||||
else:
|
||||
break
|
||||
if words:
|
||||
out.append(words)
|
||||
return words
|
||||
|
||||
|
||||
def _line_commands(tokens):
|
||||
"""The commands of a line of shell (_shell_words) that is one simple
|
||||
command, or a pipeline of them, and nothing else, each as its words
|
||||
after _command, with its redirections taken off. None for any other
|
||||
line: one that does not close, holds another operator (a list, a
|
||||
background job, a subshell, a here-document) or a pipe at its end, or
|
||||
has a command that is a reserved word or one of _STOPS."""
|
||||
if tokens is None:
|
||||
return None
|
||||
cmds, words, k = [], [], 0
|
||||
while k < len(tokens):
|
||||
is_word, text = tokens[k]
|
||||
if is_word:
|
||||
words.append(text)
|
||||
elif text in _REDIRECTIONS and k + 1 < len(tokens) and tokens[k + 1][0]:
|
||||
k += 1 # the redirection's target
|
||||
elif text == "|" and words:
|
||||
cmds.append(words)
|
||||
words = []
|
||||
else:
|
||||
return None
|
||||
k += 1
|
||||
if cmds and not words:
|
||||
return None
|
||||
cmds = [_command(c) for c in cmds + [words]]
|
||||
return None if any(c[:1] and (c[0] in _RESERVED or c[0] in _STOPS) for c in cmds) else cmds
|
||||
|
||||
|
||||
def _for_header(tokens):
|
||||
"""The words of a line `for <name> in <words>; do` without its `; do`,
|
||||
None for any other line."""
|
||||
if (tokens and len(tokens) >= 5 and tokens[0] == (True, "for") and tokens[2] == (True, "in")
|
||||
and tokens[-2:] == [(False, ";"), (True, "do")] and all(w for w, _t in tokens[:-2])):
|
||||
return [t for _w, t in tokens[:-2]]
|
||||
return None
|
||||
|
||||
|
||||
def _set(args, opts):
|
||||
"""Follow in opts the errexit and pipefail that a `set` command's
|
||||
arguments set or clear. False when they name an option the lint does
|
||||
not follow (noexec, onecmd, the listing of `set -o`)."""
|
||||
k = 0
|
||||
while k < len(args):
|
||||
a, k = args[k], k + 1
|
||||
if a in ("-", "--") or len(a) < 2 or a[0] not in "-+":
|
||||
return True # the positional parameters follow
|
||||
for ch in a[1:]:
|
||||
if ch == "o":
|
||||
name, k = (args[k] if k < len(args) else ""), k + 1
|
||||
else:
|
||||
name = _SET_FLAGS.get(ch)
|
||||
if name not in _SET_NAMES:
|
||||
return False
|
||||
if name in opts:
|
||||
opts[name] = a[0] == "-"
|
||||
return True
|
||||
|
||||
|
||||
def _shell(step):
|
||||
"""The shell a step's run text runs under: the step's own shell:, else
|
||||
its job's `defaults: run: shell:`, else the workflow's; "" for none,
|
||||
which is bash -e (sh -e with no bash) on a Linux or macOS runner (the
|
||||
lint does not read runs-on). None when any one of them is other than
|
||||
bash or sh: the lint reads the text of no other shell."""
|
||||
given = [d["shell"] for d in (step, step.get("job") or {}, step.get("workflow") or {}) if "shell" in d]
|
||||
if any(g not in ("bash", "sh") for g in given):
|
||||
return None
|
||||
return given[0] if given else ""
|
||||
|
||||
|
||||
def _run_commands(run, shell):
|
||||
"""The commands of a step's run text that the lint counts, each as its
|
||||
words (_line_commands): see workflow_runs."""
|
||||
opts = {"errexit": True, "pipefail": shell == "bash"}
|
||||
lines = [ln.strip() for ln in run.replace("\\\n", " ").split("\n") if ln.strip()]
|
||||
out, k = [], 0
|
||||
while k < len(lines):
|
||||
tokens = _shell_words(lines[k])
|
||||
k += 1
|
||||
header = _for_header(tokens)
|
||||
if header is not None: # the loop is read whole: its header, its body, its done
|
||||
groups = [[header]]
|
||||
while k < len(lines) and lines[k] != "done":
|
||||
cmds = _line_commands(_shell_words(lines[k]))
|
||||
if (cmds is None or any(c[:1] == ["set"] for c in cmds)
|
||||
or (len(cmds) > 1 and not opts["pipefail"])):
|
||||
return out
|
||||
groups.append(cmds)
|
||||
k += 1
|
||||
if k == len(lines):
|
||||
return out
|
||||
k += 1
|
||||
else:
|
||||
cmds = _line_commands(tokens)
|
||||
if cmds is None:
|
||||
return out
|
||||
if any(c[:1] == ["set"] for c in cmds):
|
||||
if len(cmds) > 1 or not _set(cmds[0][1:], opts):
|
||||
return out
|
||||
continue
|
||||
groups = [cmds]
|
||||
if opts["errexit"]:
|
||||
out.extend(c for cmds in groups if len(cmds) == 1 or opts["pipefail"] for c in cmds if c)
|
||||
return out
|
||||
|
||||
|
||||
@@ -553,22 +1088,46 @@ def _pytest_runs(args, full, at):
|
||||
def workflow_runs(steps, test_path):
|
||||
"""Whether a step of the workflow runs the test at test_path (its path
|
||||
in the workspace: the checkout's path joined to the test's path in its
|
||||
repository). Only what a step's run commands execute counts, each path
|
||||
resolved against the step's working-directory: the command itself
|
||||
(a script run directly, or a C test's built binary, <dir>/<stem>);
|
||||
the script an interpreter runs (python, bash, sh), a glob matching the
|
||||
whole path; a for loop's list over the test; python -m unittest given
|
||||
the test's file or a dotted name whose module is that file
|
||||
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
|
||||
test's own directory with a pattern its name matches; pytest on the
|
||||
test or its own directory. An argument to anything else (echo, a build,
|
||||
an install) does not run the test."""
|
||||
repository) so that a failure of the test fails the step.
|
||||
|
||||
Only a step that _on_every_run accepts counts: the lint evaluates no
|
||||
condition, so a step under an `if:` other than the literal true, or
|
||||
with a `continue-on-error:` other than the literal false, on the step
|
||||
or on its job, counts as no step. So does a step whose shell is other
|
||||
than bash or sh (_shell: its own shell:, or a `defaults: run: shell:`
|
||||
of its job or of the workflow).
|
||||
|
||||
The lint reads a step's run text line by line as bash -e or sh -e
|
||||
runs it, a backslash at the end of a line joining the next one
|
||||
(_run_commands). A command counts on a line that holds it alone, with
|
||||
its redirections, or in a pipeline under pipefail (shell: bash, or a
|
||||
`set -o pipefail` on an earlier line), and only while errexit holds
|
||||
(`set +e` clears it). A pipeline with no pipefail does not count. The
|
||||
lint stops at a line that holds any other operator (`||`, `&&`, `;`,
|
||||
`&`, a subshell, a here-document), whose command opens a compound
|
||||
command (if, case, while, until, a group, a function) or negates a
|
||||
status (!), whose command is one of _STOPS (exit, exec, trap, eval,
|
||||
source and the like), or that sets an option it does not follow: no
|
||||
command on that line or after it counts. A for loop counts in one
|
||||
form: `for <name> in <words>; do` on its own line, body lines that
|
||||
count, and `done` on its own line.
|
||||
|
||||
Each path is resolved against the step's working-directory. A command
|
||||
runs the test when it is the test (a script run directly, or a C
|
||||
test's built binary, <dir>/<stem>); the script an interpreter runs
|
||||
(python, bash, sh), a glob matching the whole path; a for loop's list
|
||||
over the test; python -m unittest given the test's file or a dotted
|
||||
name whose module is that file (tests.test_x,
|
||||
tests.test_x.Case.test_one); unittest discover from the test's own
|
||||
directory with a pattern its name matches; pytest on the test or its
|
||||
own directory. An argument to anything else (echo, a build, an
|
||||
install) does not run the test."""
|
||||
full = posixpath.normpath(test_path)
|
||||
stem, ext = posixpath.splitext(posixpath.basename(full))
|
||||
compiled = ext in (".c", ".cc", ".cpp")
|
||||
for s in steps:
|
||||
run = s.get("run")
|
||||
if not run:
|
||||
run, shell = s.get("run"), _shell(s)
|
||||
if not run or shell is None or not _on_every_run(s):
|
||||
continue
|
||||
wd = s.get("working-directory") or "."
|
||||
|
||||
@@ -577,7 +1136,7 @@ def workflow_runs(steps, test_path):
|
||||
|
||||
def names(p):
|
||||
return "$" not in p and _segments_match(full, at(p))
|
||||
for words in _commands(run):
|
||||
for words in _run_commands(run, shell):
|
||||
cmd, args = words[0], words[1:]
|
||||
if cmd == "for" and "in" in args:
|
||||
if any(names(p) for p in args[args.index("in") + 1:]):
|
||||
@@ -608,8 +1167,18 @@ def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCE
|
||||
"""HOST_TESTED rows that prove nothing: a glob that selects no
|
||||
behavioral file of its component (the same defect as an empty covers
|
||||
entry), a host test or a workflow that does not exist, a workflow that
|
||||
cannot be read where it should be, one that does not check out the
|
||||
test's repository, or one with no step that runs the test. Whether a
|
||||
cannot be read where it should be, one whose own `on:` names neither
|
||||
push nor pull_request as the lint reads it (workflow_triggers: a
|
||||
dispatch, a call or a schedule alone, a call not followed to the
|
||||
workflow that calls it, and a flow mapping, which names no event),
|
||||
one whose every push and pull_request carries a filter
|
||||
(workflow_filters: a branches, tags or paths filter, or a types
|
||||
filter on the pull_request, which the lint does not read, so the
|
||||
workflow runs on not every change of the covered files, or on not
|
||||
every branch; one push or pull_request with no filter makes the
|
||||
workflow run on every change),
|
||||
one that does not check out the test's repository, or one with no
|
||||
step that runs the test on every run (workflow_runs). Whether a
|
||||
workflow runs its test is not checked where it cannot be read (see
|
||||
read_source). Returns [(component, glob, host test, why)]."""
|
||||
out = []
|
||||
@@ -627,6 +1196,15 @@ def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCE
|
||||
continue
|
||||
if text is None:
|
||||
continue
|
||||
events = workflow_triggers(text) & {"push", "pull_request"}
|
||||
if not events:
|
||||
out.append((comp, pat, ref, "%s: the lint reads no push or pull_request in its own on:" % ci))
|
||||
continue
|
||||
filters = workflow_filters(text)
|
||||
if all(e in filters for e in events):
|
||||
out.append((comp, pat, ref, "%s: every push or pull_request in its own on: carries a filter (%s)" % (
|
||||
ci, "; ".join("%s: %s" % (e, ", ".join(filters[e])) for e in sorted(events)))))
|
||||
continue
|
||||
steps = workflow_steps(text)
|
||||
test_repo, _sep, test_path = ref.partition(":")
|
||||
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
|
||||
|
||||
@@ -464,8 +464,9 @@ class Context:
|
||||
|
||||
The fixture presses where the bench took this test's presses (or
|
||||
opted in standing); otherwise the notice goes up for a person,
|
||||
who is looking at the same prompt. Returns True when the fixture
|
||||
pressed."""
|
||||
who is looking at the same prompt. A press the fixture reports
|
||||
failed while the button went out was taken all the same, and no
|
||||
notice goes up for it. Returns True when the fixture pressed."""
|
||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||
self.evidence.setdefault("actions", []).append(rec)
|
||||
@@ -483,38 +484,73 @@ class Context:
|
||||
fixture.act("button", "press")
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
if self._press_taken(rec, e):
|
||||
return rec["by"] == "fixture"
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return False
|
||||
rec["by"] = "fixture"
|
||||
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||
return True
|
||||
|
||||
def _press_taken(self, rec, err):
|
||||
"""After the box reported a failed press of a lit button: True when
|
||||
the button is out, so the controller took a press. No notice goes
|
||||
up then, because it would ask for a second press, and a press during
|
||||
the job pauses it. The press is recorded as the box's only when its
|
||||
request went out and the failure came after it (err.sent: the box
|
||||
pressed and its reply was lost). After a refusal, a failed lookup
|
||||
or a failed connect the box made no press, and the record stays as
|
||||
it was: the press was a person's. A button still lit, or
|
||||
unreadable, is False: the operator is asked."""
|
||||
if hw.button_lit() is not False:
|
||||
return False
|
||||
if getattr(err, "sent", False):
|
||||
rec["by"] = "fixture"
|
||||
self.log("ARM press: the fixture reported a failure (%s), and the button is out: the press "
|
||||
"was taken; no notice", err)
|
||||
else:
|
||||
self.log("ARM press: the fixture made no press (%s), and the button is out: a press was "
|
||||
"taken all the same; no notice", err)
|
||||
return True
|
||||
|
||||
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
|
||||
"""Take the arm notice down the moment the press is taken: the
|
||||
controller lights the button white for its arm wait and puts it out
|
||||
at the press. Without this the notice stood until the test's next
|
||||
step, and an operator who still saw it pressed again, which pauses
|
||||
the job. A notice replaced meanwhile is left alone."""
|
||||
with self.run._lock:
|
||||
nid = (self.run.notice or {}).get("id")
|
||||
the job. A notice replaced meanwhile is left alone. The watch lives
|
||||
only as long as the notice it guards and the run: a notice cleared
|
||||
or replaced, a run that finished (passed, failed, or errored before
|
||||
the press) or an abort ends it, so no thread is left polling the
|
||||
LEDs behind a test."""
|
||||
run = self.run
|
||||
with run._lock:
|
||||
nid = (run.notice or {}).get("id")
|
||||
if nid is None:
|
||||
return
|
||||
|
||||
def standing():
|
||||
with run._lock:
|
||||
return run.finished is None and (run.notice or {}).get("id") == nid
|
||||
|
||||
def watch():
|
||||
t0 = time.time()
|
||||
while hw.button_lit() is not True:
|
||||
if time.time() - t0 > lit_timeout:
|
||||
if not standing() or time.time() - t0 > lit_timeout:
|
||||
return
|
||||
if run.aborted.wait(0.05):
|
||||
return
|
||||
time.sleep(0.05)
|
||||
while hw.button_lit() is not False:
|
||||
if time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
|
||||
if not standing() or time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
with self.run._lock:
|
||||
same = (self.run.notice or {}).get("id") == nid
|
||||
if run.aborted.wait(0.05):
|
||||
return
|
||||
with run._lock:
|
||||
same = (run.notice or {}).get("id") == nid
|
||||
if same:
|
||||
self.run.notice = None
|
||||
run.notice = None
|
||||
if same:
|
||||
self.log("ARM: the button went out at the press; the notice is down")
|
||||
|
||||
@@ -529,8 +565,9 @@ class Context:
|
||||
seconds for the button to light (the job may still be on its way
|
||||
to the arm wait; a card that settles the coolant first takes
|
||||
minutes) and presses once, recorded as the fixture's; if the
|
||||
button never lights or the press fails, the notice goes up for a
|
||||
person. Returns True when the fixture has been asked."""
|
||||
button never lights, or the press fails with the button still lit,
|
||||
the notice goes up for a person. Returns True when the fixture has
|
||||
been asked."""
|
||||
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||
self.evidence.setdefault("actions", []).append(rec)
|
||||
@@ -555,13 +592,18 @@ class Context:
|
||||
if lit is None:
|
||||
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return
|
||||
try:
|
||||
fixture.act("button", "press")
|
||||
except _fixture.FixtureError as e:
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||
rec["fixture_error"] = str(e)
|
||||
if self._press_taken(rec, e):
|
||||
rec["took_s"] = round(lit, 2)
|
||||
return
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
return
|
||||
rec["by"] = "fixture"
|
||||
rec["took_s"] = round(lit, 2)
|
||||
|
||||
@@ -8,7 +8,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -488,8 +487,6 @@ FWUP = "/usr/bin/fwup"
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||
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_KEY = "forgetest-reference"
|
||||
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")
|
||||
|
||||
|
||||
@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
|
||||
|
||||
def _frozen(id_):
|
||||
@@ -1170,7 +850,7 @@ def _frozen(id_):
|
||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
requires=["exthost.service"], actions=["button"],
|
||||
requires=["exthost.platform"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights."],
|
||||
@@ -1186,8 +866,8 @@ def _frozen(id_):
|
||||
"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 "
|
||||
"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 "
|
||||
"back.")
|
||||
"thread priority does not cover. The package, the key, the setting and the setup "
|
||||
"record are put back as found, the record under a forgectrl restart.")
|
||||
def armed_freeze(ctx):
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
|
||||
# The write guard reads the account and the session store (a machine with
|
||||
# an account needs a session from the LAN; this host and the dev image
|
||||
# write with the token), so both are the guard's domain.
|
||||
# write with the token), so both are the guard's domain. The fuse view
|
||||
# is two-factor: the token and the physical button, read by button.c.
|
||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
|
||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||
|
||||
|
||||
def lan_ip():
|
||||
@@ -210,104 +209,3 @@ def auth(ctx):
|
||||
if st == 302:
|
||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||
"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
|
||||
|
||||
"""image.* - the post-flash health of the running image (always-required)."""
|
||||
import glob
|
||||
import gzip
|
||||
import mmap
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import re
|
||||
import stat
|
||||
@@ -123,15 +121,15 @@ def fds_of(pid):
|
||||
"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 "
|
||||
"unthrottled), "
|
||||
"the init ordering, the extension host as one process that starts after forgectrl and "
|
||||
"stops before it on a machine with no package installed and nothing running under a "
|
||||
"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 extension host as one process on a machine with no package installed and "
|
||||
"nothing running under a pool account, no userspace watchdog running, "
|
||||
"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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"address up (a global address is logged, not checked).")
|
||||
def image_health(ctx):
|
||||
@@ -284,17 +282,8 @@ def image_health(ctx):
|
||||
ctx.check(temps.get("soc_throttle") == 0,
|
||||
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||
|
||||
# 5. init ordering: controllers stop before the daemon
|
||||
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
|
||||
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
|
||||
# 5. the extension host: one process, and a campaign's machine is
|
||||
# extension-free (its init order is the rootfs lint's)
|
||||
host = []
|
||||
for pid in hw.pidof("forgeext"):
|
||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||
@@ -302,13 +291,6 @@ def image_health(ctx):
|
||||
host.append(pid)
|
||||
ev["extension_host_pids"] = 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["pool_processes"] = len(hw.pool_pids())
|
||||
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"])
|
||||
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
|
||||
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
|
||||
ev["logging_init"] = logging
|
||||
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
|
||||
# 6. no userspace watchdog daemon running (the logging init and the
|
||||
# absent watchdog init script are the rootfs lint's); 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, "")]
|
||||
ev["userspace_watchdog_pids"] = 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,
|
||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||
# sshd host keys). The
|
||||
# dev image alone mounts the factory slots under /factory.
|
||||
# sshd host keys). The dev image's factory slots under /factory are
|
||||
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||
# factory slot are the rootfs lint's.
|
||||
mounts = {}
|
||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||
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("/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/"))
|
||||
ev["factory_mounts"] = factory
|
||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||
@@ -382,8 +360,6 @@ def image_health(ctx):
|
||||
if os.path.exists("/dev/mmcblk2p%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))
|
||||
else:
|
||||
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
|
||||
if os.path.isfile("/data/forgefirm/users"):
|
||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||
if l.strip() and not l.startswith("#")]
|
||||
@@ -428,14 +404,10 @@ def image_health(ctx):
|
||||
for pid, ppid, _comm, cmd in v4:
|
||||
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)
|
||||
# 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]
|
||||
ev["dhcp6_running"] = 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
|
||||
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||
|
||||
@@ -291,6 +291,77 @@ def dwell_gap(samples):
|
||||
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
|
||||
|
||||
|
||||
def read_gaps(samples, key, t_end=None):
|
||||
"""Over a sample trail: how many samples carry a reading for `key`,
|
||||
how many do not, and the longest stretch of seconds between two
|
||||
readings, the trail's ends counted as edges (an unread lead or tail
|
||||
is a gap too; a trail with no reading is one gap, its whole span).
|
||||
The trail ends when its sampling stopped (`t_end`), not at the last
|
||||
sample served: a tail the daemon did not serve at all is a gap too.
|
||||
A reading the daemon could not serve is a gap, never a dark sample:
|
||||
/status leaves the laser block out when the kernel's count did not
|
||||
read, and the count latches once per ~1 s window, so a window with
|
||||
no read in it is a window in which a fire is not witnessed."""
|
||||
if not samples:
|
||||
return {"read": 0, "unread": 0, "gap_max_s": None}
|
||||
ts = [s["t"] for s in samples if s.get(key) is not None]
|
||||
last = samples[-1]["t"] if t_end is None else max(t_end, samples[-1]["t"])
|
||||
edges = [samples[0]["t"]] + ts + [last]
|
||||
gap = max(b - a for a, b in zip(edges, edges[1:]))
|
||||
return {"read": len(ts), "unread": len(samples) - len(ts), "gap_max_s": round(gap, 3)}
|
||||
|
||||
|
||||
def rapid_place(stretch, x0):
|
||||
"""Where the head's reports place the rapid after the M5, judged over
|
||||
the armed part of the dark stretch alone: its samples before the first
|
||||
disarmed one. Returns (i_out, i_back, i_closed): the first sample with
|
||||
the head at least 19.5 mm out, the first after it with the head back
|
||||
at x0 (within 0.05 mm), and the first disarmed sample; each None when
|
||||
there is none. A window that closes before the head is back leaves
|
||||
i_back None: only a sample that read the window open places the head
|
||||
back, and sample_to_disarm reads its X before its armed flag, so the
|
||||
head was back with the window open. The first disarmed sample does
|
||||
not tell whether the head came back before the close or after it."""
|
||||
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
|
||||
part = stretch if i_closed is None else stretch[:i_closed]
|
||||
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||
i_back = (next((i for i in range(i_out + 1, len(part))
|
||||
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
|
||||
if i_out is not None else None)
|
||||
return i_out, i_back, i_closed
|
||||
|
||||
|
||||
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
|
||||
"""Sample until the armed window closes, or the timeout, with the
|
||||
controller's state and machine X beside each sample ("x"), read just
|
||||
before the sample: a sample that reads the window open read its X
|
||||
with the window open (rapid_place). A G4 dwell reports Idle, so Idle
|
||||
cannot end this; the window closing at the program end does, after
|
||||
two more kernel windows and the read gap allowed: the LASER_ON count
|
||||
for the window in flight at the close latches up to one window later
|
||||
and is held through the next, so the sampling runs on until that
|
||||
count has been read. Returns the samples served and the time the
|
||||
sampling stopped, the trail's end for the read rule."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
t0 = time.time()
|
||||
end = t0 + timeout
|
||||
t_closed = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
rep = g.status_report()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
smp["gstate"] = rep.get("state")
|
||||
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||
samples.append(smp)
|
||||
if t_closed is None and not smp["armed"]:
|
||||
t_closed = time.time()
|
||||
end = t_closed + 2 * EMISSION_WINDOW_S + EMISSION_READ_GAP_S
|
||||
time.sleep(period)
|
||||
return samples, time.time()
|
||||
|
||||
|
||||
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
|
||||
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
|
||||
Completes on: emission seen then Idle > 3 s; or armed then disarmed
|
||||
@@ -394,37 +465,22 @@ def wait_disarm(ctx, timeout):
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"first side and back, then the program ends. From the dark point until the "
|
||||
"window closes, every sample reads no emission and the HV current at its idle "
|
||||
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
|
||||
"stays off with FIRE clear while the chain can fire. The M2 program end "
|
||||
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
|
||||
"window closes, and on until the count of the window in flight at the close is "
|
||||
"read, every sample reads no emission and the HV current at its idle reading, "
|
||||
"the kernel's LASER_ON count read in every ~1 s window of that stretch (a "
|
||||
"reading the daemon could not serve is a gap, never a dark sample, and so is a "
|
||||
"tail it served none of), and the head's reports place the rapid inside the "
|
||||
"window, between the dark point and the close: LASER_ON stays off with FIRE "
|
||||
"clear while the chain can fire. The M2 program end disarms promptly after the "
|
||||
"head is back (job-based, not the 60 s idle grace) and the button goes "
|
||||
"dark. The operator confirms all four sides.")
|
||||
def emission_witness(ctx):
|
||||
ev = ctx.evidence
|
||||
# The rapid after the M5 (G91 is modal from the square): out over the
|
||||
# first side and back, then the program end.
|
||||
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
|
||||
|
||||
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
|
||||
"""Sample until the armed window closes, or the timeout, with the
|
||||
controller's state and machine X beside each sample ("x"). A G4
|
||||
dwell reports Idle, so Idle cannot end this; the window closing at
|
||||
the program end does."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
rep = g.status_report()
|
||||
if smp:
|
||||
smp["gstate"] = rep.get("state")
|
||||
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||
samples.append(smp)
|
||||
if not smp["armed"]:
|
||||
break
|
||||
time.sleep(period)
|
||||
return samples
|
||||
# first side and back, then the program end. The head stands back for
|
||||
# 1 s before the M2, so that a sample of a slow sampling loop still
|
||||
# reads it back with the window open (rapid_place).
|
||||
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
|
||||
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
@@ -464,7 +520,7 @@ def emission_witness(ctx):
|
||||
dark["x"] = (rep.get("MPos") or (None,))[0]
|
||||
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
|
||||
stream(g, rapid_tail)
|
||||
tail = sample_to_disarm(g)
|
||||
tail, t_end = sample_to_disarm(ctx, g)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
@@ -519,31 +575,40 @@ def emission_witness(ctx):
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# The rapid after the M5, with the window open and FIRE clear: every
|
||||
# sample from the dark point until the window closes reads no emission
|
||||
# and the HV current at its idle reading. The head's reports place the
|
||||
# rapid inside that stretch: out to +20 mm, then back at the start.
|
||||
# sample from the dark point until the sampling stops (two windows and
|
||||
# the read gap allowed past the close) reads no emission and the HV
|
||||
# current at its idle reading, and the kernel's LASER_ON count was read
|
||||
# in every ~1 s window of that stretch, the one that latches after the
|
||||
# close included: the end of the sampling is the trail's last edge. A
|
||||
# reading the daemon could not serve is a gap, never a dark sample.
|
||||
# The head's reports place the rapid inside the window, from the dark
|
||||
# point to the close: out to +20 mm, then back at the start.
|
||||
stretch = [dark] + tail
|
||||
x0 = dark["x"]
|
||||
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||
i_back = (next((i for i in range(i_out + 1, len(stretch))
|
||||
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
|
||||
if i_out is not None else None)
|
||||
closed = stretch[-1] if not stretch[-1]["armed"] else None
|
||||
i_out, i_back, i_closed = rapid_place(stretch, x0)
|
||||
closed = stretch[i_closed] if i_closed is not None else None
|
||||
armed_part = stretch if i_closed is None else stretch[:i_closed + 1]
|
||||
relit = [s for s in stretch if s["emission"]]
|
||||
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
|
||||
reads = read_gaps(stretch, "emission", t_end)
|
||||
hv_reads = read_gaps(stretch, "hv", t_end)
|
||||
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
|
||||
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
|
||||
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
|
||||
ev["rapid"] = {"samples": len(armed_part), "span_s": round((closed or stretch[-1])["t"] - dark["t"], 2),
|
||||
"x_start": x0, "x_max": max((s["x"] for s in armed_part if s.get("x") is not None), default=None),
|
||||
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
|
||||
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
|
||||
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
|
||||
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
|
||||
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
|
||||
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None,
|
||||
"sampled_past_close_s": round(t_end - closed["t"], 2) if closed else None,
|
||||
"emission_reads": reads, "hv_reads": hv_reads}
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
|
||||
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
|
||||
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point to the close, out at +%s s, "
|
||||
"back at +%s s; sampled %s s past the close, %d of %d samples with emission, HV max %s, %d of %d "
|
||||
"carried the LASER_ON count, longest unread stretch %s s; the window closed %s s after the head "
|
||||
"was back", len(armed_part), ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
ev["rapid"]["sampled_past_close_s"], len(relit), len(stretch), hv_dark, reads["read"],
|
||||
len(stretch), reads["gap_max_s"], ev["disarm_after_idle_s"])
|
||||
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
||||
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
|
||||
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
||||
@@ -558,16 +623,25 @@ def emission_witness(ctx):
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
ev["hv_min"], ev["hv_max"])
|
||||
ctx.check(i_out is not None and i_back is not None,
|
||||
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
|
||||
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
|
||||
"the rapid did not run inside the dark stretch with the window open: out at %s, back at %s "
|
||||
"(X from %s, max %s), the window closed at %s", ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
x0, ev["rapid"]["x_max"], round(closed["t"] - dark["t"], 2) if closed else None)
|
||||
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
|
||||
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
|
||||
ctx.check(reads["read"] >= RAPID_MIN_READS and reads["gap_max_s"] is not None
|
||||
and reads["gap_max_s"] <= EMISSION_READ_GAP_S,
|
||||
"the LASER_ON count was not read through the rapid: %d of %d samples carried it, the longest "
|
||||
"unread stretch %s s (the kernel latches the count once per ~1 s window, so a fire in an "
|
||||
"unread window is not witnessed)", reads["read"], len(stretch), reads["gap_max_s"])
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
|
||||
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
|
||||
relit[0]["emission"] if relit else None)
|
||||
ctx.check(hv_reads["read"] >= RAPID_MIN_READS,
|
||||
"the HV current was readable on only %d of %d samples through the rapid (%d needed)",
|
||||
hv_reads["read"], len(stretch), RAPID_MIN_READS)
|
||||
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
|
||||
"through the rapid", hv_dark)
|
||||
ctx.check(dt is not None and dt < 10.0,
|
||||
ctx.check(dt is not None and 0 <= dt < 10.0,
|
||||
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
||||
@@ -586,6 +660,13 @@ def emission_witness(ctx):
|
||||
|
||||
|
||||
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||
# The kernel latches cnc/laser_on_sampled once per ~1 s window (cnc.c
|
||||
# LASER_SAMPLE_WINDOW) and holds the count through the next window, so a
|
||||
# trail whose valid reads are never farther apart than EMISSION_READ_GAP_S
|
||||
# has read every window's count at least once.
|
||||
EMISSION_WINDOW_S = 1.0
|
||||
EMISSION_READ_GAP_S = 0.9
|
||||
RAPID_MIN_READS = 4 # the fewest samples with the count (and with the HV current) over the rapid
|
||||
|
||||
|
||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||
|
||||
@@ -191,18 +191,239 @@ def pacing(ctx):
|
||||
ctx.log("PASS: idle %.1f%%, moving %.1f%%, parked %.1f%%, hold/resume exact", idle, moving, parked)
|
||||
|
||||
|
||||
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
|
||||
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume, "
|
||||
"a soft reset during a move",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c")],
|
||||
requires=["kernel.latch-locked-idle", "kernel.k1-k2"],
|
||||
steps=["Setup: the head parked with at least 60 mm of free +X and 40 mm of free +Y travel; "
|
||||
"bed clear, lid closed."],
|
||||
description="Sanity jogs (X, Y 40 mm out/back at F2400), max-rate X out/back (60 mm at "
|
||||
"F12000), a diagonal out/back, then a G1 with a feed-hold/resume in the middle. "
|
||||
"Then a soft reset (^X) during a 60 mm G1 at F3000: the kernel ramps the motion "
|
||||
"down (its tick rate falls before the run ends) and leaves running with no "
|
||||
"underrun and no fault, the ring is empty after the reset, MPos matches the "
|
||||
"kernel's step counters within half a step after $X, the homed mask is kept, the sender "
|
||||
"is not told the position is lost, and a jog back by the MPos distance returns "
|
||||
"the kernel's X counter to its start within a step. "
|
||||
"No jog refused, every move returns to Idle, position drift within 0.05 mm, and "
|
||||
"the head accelerometer - the supervisor's own motion witness - saw the head move "
|
||||
"on every jog it could sample; the counters alone are not proof of motion.")
|
||||
def jog_roundtrip(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
def reset_during_move(g):
|
||||
"""A soft reset during a move, from rest with the head at the start:
|
||||
the kernel's ramp, the position read back from its step counters,
|
||||
the reference kept, and the head jogged back. The readings go to the
|
||||
evidence; the failures are returned, for the caller to judge once
|
||||
the head is back."""
|
||||
fc = ctx.forgectrl
|
||||
machine_idle(ctx)
|
||||
g.drain()
|
||||
spm = [grbl_setting(g, k) for k in ("$100", "$101", "$102")]
|
||||
ctx.check(all(spm), "the step scales did not read back ($100 %s, $101 %s, $102 %s)", *spm)
|
||||
f0 = hw.sysfs_int("cnc/step_freq")
|
||||
ramp = hw.sysfs_int("cnc/ramp_rate")
|
||||
ctx.check(f0 and ramp, "cnc/step_freq %s, cnc/ramp_rate %s: the kernel's tick is unreadable",
|
||||
f0, ramp)
|
||||
under0, faults0 = hw.sysfs_int("cnc/underruns"), hw.sysfs_read("cnc/faults")
|
||||
p0 = _kernel_position()
|
||||
m0 = g.status_report().get("MPos")
|
||||
h0 = _port_state(fc).get("homed")
|
||||
ctx.check(m0, "no MPos in the status report before the reset")
|
||||
# The mask comes from the controller port's state record: $# prints
|
||||
# its [HOME:...:mask] line only with $22 on, which the driver
|
||||
# leaves off. The driver always holds the Z bit (the lens hold);
|
||||
# without it the mask could not tell a kept reference from a
|
||||
# dropped one.
|
||||
ctx.check(isinstance(h0, int) and h0 & 4,
|
||||
"the homed mask reads %r before the reset: the lens hold (Z, 4) is not in it, so "
|
||||
"the mask cannot witness the reset", h0)
|
||||
log0 = _log_offset(GRBLHAL_LOG)
|
||||
rec = {"step_freq": f0, "ramp_rate": ramp, "spm": spm, "kernel_before": list(p0),
|
||||
"mpos_before": list(m0), "homed_before": h0}
|
||||
r = g.command("G91G1X60F3000", timeout=0.5)
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the 60 mm G1 was refused: %s", r)
|
||||
ctx.check(wait_state(ctx, g, "Run", 2.0), "the 60 mm G1 did not start (no Run within 2 s)")
|
||||
# The kernel's run starts a queue depth behind the core's; the ^X
|
||||
# lands 0.3 s into it, with the head at its cruise (the move takes
|
||||
# 1.2 s, its acceleration 71 ms).
|
||||
ok = ctx.wait_for(lambda: hw.sysfs_read("cnc/state") == "running", 1.0, poll=0.01)
|
||||
ctx.check(ok is not None, "the kernel did not start the 60 mm G1 within 1 s of Run (cnc/state %s)",
|
||||
hw.sysfs_read("cnc/state"))
|
||||
time.sleep(0.3)
|
||||
kx = _kernel_position()
|
||||
kstate = hw.sysfs_read("cnc/state")
|
||||
ctx.check(kstate == "running", "the kernel was %s at the ^X: the move had ended before it", kstate)
|
||||
g.realtime(0x18)
|
||||
t_x = time.time()
|
||||
# The stop: the kernel leaves running, polled at 5 ms. Each sample
|
||||
# is the time after the ^X, the state, the bytes played and the
|
||||
# bytes handed (the position record).
|
||||
samples = []
|
||||
while True:
|
||||
st = hw.sysfs_read("cnc/state")
|
||||
pos = _kernel_position()
|
||||
t = time.time() - t_x
|
||||
samples.append((t, st, pos[3], pos[4]))
|
||||
if st != "running" or t > 1.0:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
state = None
|
||||
end = t_x + 2.0
|
||||
while time.time() < end:
|
||||
state = g.status_report()["state"]
|
||||
if state.startswith("Alarm"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
rec.update({"t_x": round(t_x, 3), "queue_at_x": kx[4] - kx[3], "kernel_end_state": samples[-1][1],
|
||||
"left_running_s": round(samples[-1][0], 4), "samples": len(samples),
|
||||
"state_after_x": state})
|
||||
ev["reset"] = rec
|
||||
if not (state or "").startswith("Alarm"):
|
||||
if (state or "").startswith("Idle"):
|
||||
ctx.fail("the move had ended before the ^X: the controller is Idle with no alarm")
|
||||
ctx.fail("the controller was not in Alarm within 2 s of the ^X (state %s)",
|
||||
state or "unread: the 5 ms sampling loop ran past the 2 s")
|
||||
|
||||
# The read-back ends inside a second of the reset, and the core's
|
||||
# re-init flushes what it has read: the unlock goes after both.
|
||||
ctx.sleep(max(0.0, t_x + 2.0 - time.time()))
|
||||
text = g.drain()
|
||||
r = g.command("$X")
|
||||
text += "\n".join(r) + "\n"
|
||||
rec["unlock"] = r
|
||||
ctx.check(r and r[-1] == "ok", "$X after the reset answered %s", r)
|
||||
ctx.check(wait_state(ctx, g, "Idle", 5.0), "the controller is not Idle after $X")
|
||||
machine_idle(ctx)
|
||||
text += g.drain()
|
||||
m1 = g.status_report().get("MPos")
|
||||
p1 = _kernel_position()
|
||||
h1 = _port_state(fc).get("homed")
|
||||
ctx.check(m1, "no MPos in the status report after $X")
|
||||
lost = [ln.strip() for ln in text.splitlines()
|
||||
if ln.strip().startswith("[MSG:") and "read back" in ln]
|
||||
kd = [p1[a] - p0[a] for a in range(3)]
|
||||
md = [(m1[a] - m0[a]) * spm[a] for a in range(3)]
|
||||
rec.update({"kernel_after": list(p1), "mpos_after": list(m1), "homed_after": h1,
|
||||
"kernel_delta_steps": kd, "mpos_delta_steps": [round(v, 2) for v in md],
|
||||
"ring_after": p1[4] - p1[3], "lost_messages": lost,
|
||||
"readback_log": _log_lines(GRBLHAL_LOG, log0,
|
||||
"soft reset: the kernel ramped the motion down")[-1:]})
|
||||
|
||||
# Hand back: a jog by the MPos distance (a jog carries its own G91:
|
||||
# the reset restored the parser's defaults).
|
||||
dx = m1[0] - m0[0]
|
||||
if abs(dx) >= 0.001:
|
||||
r = g.command("$J=G91X%.3fF3000" % -dx)
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the jog back was refused: %s", r)
|
||||
_, states, _ = wait_idle(ctx, g)
|
||||
ctx.check("TIMEOUT" not in states, "the jog back did not return to Idle")
|
||||
machine_idle(ctx)
|
||||
p2 = _kernel_position()
|
||||
under1, faults1 = hw.sysfs_int("cnc/underruns"), hw.sysfs_read("cnc/faults")
|
||||
rec.update({"kernel_handed_back": list(p2), "underruns": [under0, under1], "faults": [faults0, faults1]})
|
||||
|
||||
# The ramp: the kernel decelerated. Nothing but its ramp lowers the
|
||||
# tick rate once a run is up to speed, so the bytes it played over
|
||||
# the run's last 25 to 50 ms, against the tick, say whether the run
|
||||
# ended at speed or decelerated. The queue at cnc/stop is about
|
||||
# 110 ms of ticks: the ring holds 200 ms and the producer leads by
|
||||
# 10 ms, and the core's alarm hold plays 100 ms of it at the tick
|
||||
# before the driver's stop. The kernel's ramp is stepped (cnc.c,
|
||||
# ramp_update_tasklet_fn): the tick holds for the first 10 ms,
|
||||
# then every 10 ms it drops by ramp_rate / 100 until it reaches
|
||||
# the 900 Hz floor, which plays 10 ms before the stop. At the x32
|
||||
# tick of 112640 Hz and its 500000 Hz/s ramp that is 5000 Hz off
|
||||
# every 10 ms: 24 intervals, 240 ms, about 13270 ticks, 118 ms of
|
||||
# the tick (the ramp scales with the tick, so every mode gives the
|
||||
# same fractions). So the run ends at the floor, or out of data
|
||||
# part way down the ramp, on the interval its queue runs out in:
|
||||
# 24% of the tick with 110 ms left, 38% with 100 ms, 51% with
|
||||
# 90 ms. The tail's mean over the 25 to 30 ms the poll samples is
|
||||
# higher, since it spans the intervals before: 32% with 110 ms
|
||||
# left, 45% with 100 ms, 56% with 90 ms (61% over the 50 ms window
|
||||
# bound), and a few percent when the run reaches the floor. A run
|
||||
# that ends at the tick (a run-out with no stop) reads 100%,
|
||||
# within the sampling's few percent over 25 ms. The bound is 70%:
|
||||
# 14 points above the slowest ramped tail and 30 below a run-out.
|
||||
# The other readings are recorded, not judged: with the ramp about
|
||||
# the size of the queue, the steps that never played can be none,
|
||||
# the bytes played match a run-out within a few percent, and the
|
||||
# time to leave running is the 100 ms hold plus the run's end on
|
||||
# the ramp: about 240 ms with 100 ms left, 270 ms with 110 ms,
|
||||
# 340 ms when the run reaches the floor. The evidence's ramp_ticks
|
||||
# and ramp_s are the continuous figures, (f0^2 - 900^2) / (2 a)
|
||||
# and (f0 - 900) / a: 12687 ticks and 223 ms at x32, about 4% and
|
||||
# 7% under the stepped ramp's.
|
||||
running = [s for s in samples if s[1] == "running"]
|
||||
tail = None
|
||||
if running:
|
||||
last = running[-1]
|
||||
rec["last_running_s"] = round(last[0], 4)
|
||||
before = [s for s in running if last[0] - s[0] >= 0.025]
|
||||
if before:
|
||||
first = before[-1]
|
||||
w = last[0] - first[0]
|
||||
rate = ((last[2] - first[2]) & 0xffffffff) / w
|
||||
tail = {"window_s": round(w, 4), "rate_hz": round(rate), "fraction": round(rate / f0, 3)}
|
||||
rec.update({"tail": tail, "ramp_s": round((f0 - 900.0) / ramp, 4),
|
||||
"ramp_ticks": int((f0 * f0 - 900.0 * 900.0) / (2.0 * ramp)),
|
||||
"ring_at_idle": samples[-1][3] - samples[-1][2]})
|
||||
ctx.log("soft reset during the move: kernel %s %.0f ms after the ^X (queue %d ticks at the ^X, "
|
||||
"ramp %d ticks over %.0f ms), tail %s; kernel delta %s steps, MPos delta %s steps, "
|
||||
"homed %s -> %s, ring %d after the reset, read-back %s",
|
||||
rec["kernel_end_state"], 1e3 * rec["left_running_s"], rec["queue_at_x"], rec["ramp_ticks"],
|
||||
1e3 * rec["ramp_s"], tail, kd, rec["mpos_delta_steps"], h0, h1, rec["ring_after"],
|
||||
rec["readback_log"] or "(no line)")
|
||||
|
||||
fails = []
|
||||
if samples[-1][1] == "running":
|
||||
fails.append("the kernel was still running 1.0 s after the ^X")
|
||||
elif samples[-1][1] != "idle":
|
||||
fails.append("the kernel left running into %s, not idle" % samples[-1][1])
|
||||
if under1 != under0 or faults1 != faults0:
|
||||
fails.append("cnc/underruns %s -> %s, cnc/faults %s -> %s across the reset"
|
||||
% (under0, under1, faults0, faults1))
|
||||
if tail is None:
|
||||
fails.append("the kernel left running %.0f ms after the ^X with no 25 ms of its run sampled "
|
||||
"after it: a stop with no ramp" % (1e3 * rec["left_running_s"]))
|
||||
elif tail["window_s"] > 0.050:
|
||||
fails.append("the poll did not sample the run's tail (a %.0f ms gap)" % (1e3 * tail["window_s"]))
|
||||
elif tail["fraction"] > 0.70:
|
||||
fails.append("the kernel's tick ran at %.0f%% of %d Hz over the run's last %.0f ms: the run "
|
||||
"ended at speed, not on the ramp" % (100 * tail["fraction"], f0, 1e3 * tail["window_s"]))
|
||||
if rec["ring_after"]:
|
||||
fails.append("the ring still holds %d bytes after the reset: the clear did not run" % rec["ring_after"])
|
||||
# The read-back is exact, and the judge allows half a step. The
|
||||
# smallest miss the read-back can make is one step: the core's
|
||||
# kill step for the pulse start that never came, which a position
|
||||
# kept as the core counted it carries. MPos is reported to
|
||||
# 0.001 mm, so the difference of two readings carries up to 0.21
|
||||
# step at x32 (0.05 at x8): a correct read-back lands within
|
||||
# that, a one-step miss at 0.79 step or more, and a one-step
|
||||
# tolerance would let the miss land on either side of it. The
|
||||
# kernel counts X_DIR set as -X, so the sign of its X count is
|
||||
# the one this +X move gave it.
|
||||
if abs(kd[0]) < spm[0] or md[0] <= 0:
|
||||
fails.append("the move did not reach the kernel or MPos: kernel X %+d steps, MPos X %+.1f steps"
|
||||
% (kd[0], md[0]))
|
||||
elif abs(abs(kd[0]) - md[0]) > 0.5:
|
||||
fails.append("MPos X moved %.1f steps and the kernel played %d: the position was not read back "
|
||||
"from the kernel's counters" % (md[0], abs(kd[0])))
|
||||
for a, name in ((1, "Y"), (2, "Z")):
|
||||
if kd[a] or abs(md[a]) > 0.5:
|
||||
fails.append("%s moved under an X move: kernel %+d steps, MPos %+.1f steps" % (name, kd[a], md[a]))
|
||||
# The reference is kept.
|
||||
if h1 != h0:
|
||||
fails.append("the homed mask went %s -> %s across the reset: the reference was dropped" % (h0, h1))
|
||||
if lost:
|
||||
fails.append("the sender was told the position is lost: %s" % lost[-1])
|
||||
if abs(p2[0] - p0[0]) > 1:
|
||||
fails.append("the jog back left the kernel's X counter %+d steps from its start" % (p2[0] - p0[0]))
|
||||
return fails
|
||||
|
||||
accel = hw.AccelSampler()
|
||||
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
|
||||
hw.HEAD_ACCEL_I2C)
|
||||
@@ -269,6 +490,10 @@ def jog_roundtrip(ctx):
|
||||
ctx.log("after ~: states %s", states)
|
||||
g.command("G1X-30F2400", timeout=0.5)
|
||||
wait_idle(ctx, g)
|
||||
fails = reset_during_move(g)
|
||||
for why in fails:
|
||||
ctx.log("soft reset: %s", why)
|
||||
ctx.check(not fails, "soft reset during the move: %s", "; ".join(fails))
|
||||
g.command("G90")
|
||||
final = g.status_report().get("MPos")
|
||||
ev["final"] = final
|
||||
@@ -559,78 +784,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
|
||||
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
|
||||
|
||||
def _kernel_x_mm(ctx):
|
||||
@@ -656,7 +809,7 @@ def _return_x(ctx, delta_mm):
|
||||
"a kill during the web-service homing",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||
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. "
|
||||
"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."],
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.* - the first-run setup: the record and the
|
||||
controller gate, the advisories, the account and its login, the HTTPS
|
||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||
return.
|
||||
"""setup.* - what the setup tests share: the setup record and its
|
||||
builders, the files of the data directory, a takeover that installs a
|
||||
record, and the HTTPS client. The tests are setup_dark's and
|
||||
setup_burns'; this module registers none.
|
||||
|
||||
The daemon reads the setup record (setup.json in the
|
||||
data directory) and the account record (users) once, at its start, and
|
||||
@@ -27,19 +27,15 @@ import posixpath
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
RECORD = "setup.json"
|
||||
USERS = "users"
|
||||
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).
|
||||
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.
|
||||
INTAKE_KEY = "cool_intake_fans"
|
||||
SAFETY_PHRASE = "I UNDERSTAND"
|
||||
LOGIN_FAILS = 5
|
||||
LOGIN_LOCK_S = 30
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- files
|
||||
@@ -284,19 +278,6 @@ def decode(content):
|
||||
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):
|
||||
"""The ffsid session id out of a Set-Cookie value, or None."""
|
||||
for part in (value or "").split(";"):
|
||||
@@ -318,218 +299,6 @@ def wiz(fc):
|
||||
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):
|
||||
"""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)}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -22,6 +22,7 @@ the real numbers go in.
|
||||
import re
|
||||
import time
|
||||
|
||||
from .. import baseline
|
||||
from .. import hw
|
||||
from ..catalog import test
|
||||
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
|
||||
@@ -43,11 +44,135 @@ BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
|
||||
# Every burn asks first that the gantry stops are installed and the head
|
||||
# is at its home against them; the bench's head stands there, and every
|
||||
# burn's tail brings it back.
|
||||
# burn's tail brings it back. head_at_home reads the head's place from
|
||||
# /status before the wizard starts and judges it, within HOME_TOL_MM,
|
||||
# against the stops as the manual home that references X and Y declared
|
||||
# them (its home anchor): X0 Y0 for a home with no manual home offset,
|
||||
# minus manual_home_x and manual_home_y for a home made with them.
|
||||
HOME = "The head is home"
|
||||
HOME_TOL_MM = 0.05
|
||||
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
|
||||
EDGE_KEY = "lens_hall_edge_z_mm"
|
||||
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
|
||||
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
|
||||
# pic/hv_current above this: the discharge is present (the daemon's own
|
||||
# rule for a lit burn, jobstream.h JOBSTREAM_HV_ON).
|
||||
HV_ON = 30
|
||||
|
||||
|
||||
class Witnesses:
|
||||
"""The two emission witnesses read from sysfs by the test itself at
|
||||
every poll of the burn: the kernel's LASER_ON sample count
|
||||
(cnc/laser_on_sampled) and the tube current (pic/hv_current), each
|
||||
at its peak, and how many polls read both. The burn's result reports
|
||||
the daemon's own reading of the same two; this one is taken past the
|
||||
daemon."""
|
||||
|
||||
def __init__(self):
|
||||
self.laser_on_max = None
|
||||
self.hv_max = None
|
||||
self.reads = 0
|
||||
|
||||
def __call__(self, _doc):
|
||||
lon = hw.sysfs_int("cnc/laser_on_sampled")
|
||||
hv = hw.sysfs_int("pic/hv_current")
|
||||
if lon is not None:
|
||||
self.laser_on_max = lon if self.laser_on_max is None else max(self.laser_on_max, lon)
|
||||
if hv is not None:
|
||||
self.hv_max = hv if self.hv_max is None else max(self.hv_max, hv)
|
||||
if lon is not None and hv is not None:
|
||||
self.reads += 1
|
||||
|
||||
def record(self):
|
||||
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
|
||||
|
||||
|
||||
def anchor_stops():
|
||||
"""Where the manual home that references X and Y declared the gantry
|
||||
stops, in /status coordinates: the X and Y of the controller's home
|
||||
anchor (baseline.ANCHOR_PATH), to which /status adds the step
|
||||
counters. None when the anchor does not read as a manual home of X
|
||||
and Y. The settings do not place the stops: a manual home reads
|
||||
manual_home_x and manual_home_y once, and a change to them after the
|
||||
home leaves its reference as it is."""
|
||||
try:
|
||||
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
|
||||
fields = f.read().split()
|
||||
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
if (axes & 3) != 3 or source != "manual":
|
||||
return None
|
||||
return x, y
|
||||
|
||||
|
||||
def home_offset_remedy(ctx):
|
||||
"""What brings the head to its home against the stops, from what the
|
||||
next manual home reads: with no manual home offset, a home there;
|
||||
with one, Set home here moves the head off the stops to X0 Y0, so the
|
||||
head is pushed there with the motors released, and the release ends
|
||||
with Energize motors."""
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
|
||||
|
||||
def no_offset(v):
|
||||
try:
|
||||
return v == "" or float(v) == 0
|
||||
except ValueError:
|
||||
return False
|
||||
if no_offset(raw[0]) and no_offset(raw[1]):
|
||||
return "home it there before the burn"
|
||||
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
|
||||
"release the motors, push the head against the stops, and end the release with Energize motors "
|
||||
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
|
||||
|
||||
|
||||
def head_at_home(ctx):
|
||||
"""The head's place on record before the wizard starts, read from
|
||||
/status under the campaign's own controller: the wizard restarts the
|
||||
controller before its home prompt, and a fresh controller drops the
|
||||
X/Y reference, so a read at that prompt places nothing. When X and Y
|
||||
are referenced by a manual home, the head is judged within
|
||||
HOME_TOL_MM against the gantry stops, where the wizard draws from,
|
||||
as that home declared them (anchor_stops): X0 Y0 for a home with no
|
||||
offset, and minus manual_home_x and manual_home_y for a home made
|
||||
with them, which moves the head to X0 Y0, that far in front of the
|
||||
stops. Unreferenced, the head's place rests on the operator's word
|
||||
at the ready gate, and the record says so."""
|
||||
fc = ctx.forgectrl
|
||||
read = {}
|
||||
|
||||
def served():
|
||||
st = fc.status()
|
||||
if not isinstance(st.get("pos"), dict):
|
||||
return False
|
||||
read.update(st)
|
||||
return True
|
||||
took = ctx.wait_for(served, 10)
|
||||
ctx.check(took is not None, "/status served no head position within 10 s: the head's place cannot "
|
||||
"go on record before the burn")
|
||||
pos = read.get("pos") or {}
|
||||
axes = read.get("homed_axes")
|
||||
source = read.get("home_source")
|
||||
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
|
||||
rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
|
||||
"judged": referenced}
|
||||
if not referenced:
|
||||
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
|
||||
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
|
||||
return
|
||||
found = anchor_stops()
|
||||
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
|
||||
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
|
||||
stops = rec["stops"] = {"x": found[0], "y": found[1]}
|
||||
x, y = pos.get("x"), pos.get("y")
|
||||
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
|
||||
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
|
||||
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
|
||||
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
|
||||
stops["y"], "" if at_home else home_offset_remedy(ctx))
|
||||
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
|
||||
pos, stops["x"], stops["y"])
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
@@ -91,17 +216,26 @@ def preview_ok(ctx, wid):
|
||||
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
"""The two witnesses of the burn, from the wizard's result. The head's
|
||||
beam sensor is recorded, never required: the result reports its rise
|
||||
and whether it read as the beam seen."""
|
||||
def emission_ok(ctx, last, seen):
|
||||
"""The two witnesses of the burn, from the wizard's result and from
|
||||
the test's own reads of them at every poll (`seen`, a Witnesses).
|
||||
The head's beam sensor is recorded, never required: the result
|
||||
reports its rise and whether it read as the beam seen."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("hv_max", 0) > HV_ON, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
|
||||
"the result does not record the beam sensor: %s", e)
|
||||
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
|
||||
w = seen.record()
|
||||
ctx.check(w["reads"] > 0, "the test could not read the witnesses from sysfs during the burn")
|
||||
ctx.check((w["laser_on_max"] or 0) > 0,
|
||||
"the kernel's LASER_ON count never read nonzero from here during the burn (%d reads); the "
|
||||
"burn reports %s", w["reads"], e.get("laser_on_samples"))
|
||||
ctx.check((w["hv_max"] or 0) > HV_ON,
|
||||
"the tube current never read over %d from here during the burn (peak %s over %d reads); the "
|
||||
"burn reports %s", HV_ON, w["hv_max"], w["reads"], e.get("hv_max"))
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
@@ -116,7 +250,8 @@ def burn(ctx, wid, arm, pick, want):
|
||||
"""One burn: the preview, then the burn after the press with every
|
||||
prompt answered (the gantry stops installed, the head at home, the
|
||||
scrap in the corner, its thickness in the machine's units, the arm,
|
||||
and the pick in `pick`), the witnesses, and the keys of the result in
|
||||
and the pick in `pick`), the witnesses (the burn's own, and the two
|
||||
read from sysfs here at every poll), and the keys of the result in
|
||||
`want`. run_check makes the arm press when the burn asks for it. A
|
||||
prompt the test does not answer, or one asked again because its
|
||||
answer was refused, ends the burn. Returns the result."""
|
||||
@@ -127,6 +262,7 @@ def burn(ctx, wid, arm, pick, want):
|
||||
answers.update(pick)
|
||||
preview_ok(ctx, wid)
|
||||
asked = set()
|
||||
seen = Witnesses()
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id")
|
||||
@@ -142,10 +278,16 @@ def burn(ctx, wid, arm, pick, want):
|
||||
return answers[pid]
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900)
|
||||
last = run_check(ctx, wid, on_prompt, 900, on_poll=seen)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emission_ok(ctx, last)
|
||||
# What the test read itself goes on record however the burn ends:
|
||||
# reported dark, failed, timed out, or aborted.
|
||||
w = seen.record()
|
||||
ctx.evidence["emission_seen"] = w
|
||||
ctx.log("seen from here: LASER_ON count peak %s, tube current peak %s over %d polls",
|
||||
w["laser_on_max"], w["hv_max"], w["reads"])
|
||||
emission_ok(ctx, last, seen)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
@@ -223,9 +365,14 @@ def focus_window_ok(ctx, r, before):
|
||||
"the button again at the arm, as the page asks.",
|
||||
"Nothing else: the test answers every prompt itself and puts back the setting the "
|
||||
"burn writes."],
|
||||
description="The focus burn as a fresh head runs it: the edge setting is cleared first, so "
|
||||
"the burn starts from the bench reference's edge, while the lens window stays "
|
||||
"the one the Lens travel check wrote (or the fallback). POST "
|
||||
description="The focus burn as a fresh head runs it. Before anything starts, the head's "
|
||||
"place is read from /status and recorded: judged against the gantry stops when "
|
||||
"a manual home references X and Y (where that home declared them: X0 Y0, or "
|
||||
"minus the manual_home_x and manual_home_y it read), else left to the "
|
||||
"operator's word at the ready gate. The "
|
||||
"edge setting is cleared, so the burn starts from the bench reference's edge, "
|
||||
"while the lens window stays the one the Lens travel check wrote (or the "
|
||||
"fallback). POST "
|
||||
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
|
||||
"references the lens, takes the controller in loopback posture, is answered that "
|
||||
"the head is home and that the scrap is in the corner, and takes the thickness "
|
||||
@@ -236,7 +383,8 @@ def focus_window_ok(ctx, r, before):
|
||||
"inside the reach /status reports, and the program ending in that tail at the "
|
||||
"edge /status reports, it burns twelve lines after the press, from the top of "
|
||||
"the window to the bottom, seen by the two witnesses (the tube current and the "
|
||||
"kernel's LASER_ON samples), with the beam sensor's rise recorded and not "
|
||||
"kernel's LASER_ON samples) as the burn reports them and as the test reads them "
|
||||
"from sysfs itself at every poll, with the beam sensor's rise recorded and not "
|
||||
"required. The pick is line 11. The "
|
||||
"result carries its keys; the ladder ran in the window the machine held, which "
|
||||
"the burn leaves as found (it writes the edge alone, the one its result "
|
||||
@@ -283,6 +431,7 @@ def focus(ctx):
|
||||
ctx.log("the controller could not be restarted on the settings as found: %s", e)
|
||||
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
|
||||
"button lights.")
|
||||
head_at_home(ctx)
|
||||
try:
|
||||
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
|
||||
# A fresh head's focus: the edge unknown, so the burn runs from the
|
||||
|
||||
@@ -26,9 +26,12 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
|
||||
# check switches it back on. The bench reference draws its off current
|
||||
# (74) for the first seconds and settles near 630.
|
||||
PURGE_SPINUP_S = 30
|
||||
# The checks' press prompts read the button (button.c) and light it
|
||||
# (led.c) on the machine; the switches check reads both back.
|
||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*")]
|
||||
# The lens window: the free half-steps below and above the hall edge the
|
||||
# Lens travel check writes, and the fallback the lens frame holds while
|
||||
# they are unset (lens.h).
|
||||
@@ -79,11 +82,13 @@ def answer(fc, wid, prompt, value):
|
||||
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
||||
|
||||
|
||||
def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
def run_check(ctx, wid, on_prompt, timeout_s, on_poll=None):
|
||||
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
||||
once per prompt (by sequence number) and returns the value to answer
|
||||
with, or None when it handled the prompt another way (a fixture
|
||||
action). Returns the final status document."""
|
||||
action). on_poll(doc), when given, is called once per poll of
|
||||
/wiz/dark with the status document: a caller's own witness read
|
||||
beside the check's. Returns the final status document."""
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
start(ctx, fc, wid)
|
||||
@@ -97,6 +102,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
d = dark(fc)
|
||||
if d.get("id") != wid:
|
||||
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
||||
if on_poll is not None:
|
||||
on_poll(d)
|
||||
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
||||
phases.append(d["phase"])
|
||||
ctx.log("%s: %s", wid, d["phase"])
|
||||
@@ -125,10 +132,11 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
raise
|
||||
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||
ev["phases"] = phases[-12:]
|
||||
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||
if last is None:
|
||||
# A check this test stops following is aborted before the test
|
||||
# fails: no check runs on behind the FAIL.
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
return {}
|
||||
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||
ev["log"] = last.get("log", [])[-12:]
|
||||
ev["error"] = last.get("error")
|
||||
ev["result"] = last.get("result")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -40,6 +40,8 @@ class FakeFixture:
|
||||
self.button_enabled = button_enabled
|
||||
self.calls = []
|
||||
self.presses = 0 # presses the device performed
|
||||
self.reply_delay = 0.0 # seconds between a press and its reply
|
||||
self.replied = 0 # delayed replies the device is done with (sent or lost)
|
||||
srv = self
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
@@ -95,6 +97,14 @@ class FakeFixture:
|
||||
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||
s = self._state()
|
||||
s["pulse_ms"] = 200
|
||||
if srv.reply_delay:
|
||||
time.sleep(srv.reply_delay) # the reply comes after the client gave up
|
||||
try:
|
||||
self._json(200, s)
|
||||
except OSError:
|
||||
pass
|
||||
srv.replied += 1
|
||||
return None
|
||||
return self._json(200, s)
|
||||
if self.path == "/release":
|
||||
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||
@@ -109,6 +119,11 @@ class FakeFixture:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
|
||||
def wait_replied(self, n, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while self.replied < n and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -155,11 +170,11 @@ class ClientTests(unittest.TestCase):
|
||||
self.resolved.append(hostname)
|
||||
return "127.0.0.1"
|
||||
|
||||
def client(self, **over):
|
||||
def client(self, timeout=3.0, resolver=None, **over):
|
||||
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
||||
"port": self.dev.port}
|
||||
cfg.update(over)
|
||||
return fx.Fixture(cfg, resolver=self.resolver)
|
||||
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
|
||||
|
||||
def test_status_covers_act_release(self):
|
||||
f = self.client()
|
||||
@@ -173,7 +188,7 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("button", "press")
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||
time.sleep(0.15)
|
||||
# a check that must reach the machine while the button is down asks for the longest press
|
||||
# a pulse length asked for goes to the box as asked; the clamp is the box's
|
||||
f.act("button", "press", ms=500)
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
||||
time.sleep(0.15) # the fake's pulse ends
|
||||
@@ -196,6 +211,35 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("button", "press")
|
||||
self.assertIn("jumper", str(cm.exception))
|
||||
|
||||
def test_a_failure_after_the_request_went_out_says_so(self):
|
||||
# The press made and its reply lost (it comes after the client's
|
||||
# timeout): sent, one press. A refusal, a failed lookup and a
|
||||
# refused connect: not sent, no press. The one retry keeps it.
|
||||
self.dev.reply_delay = 0.6
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(1)
|
||||
self.assertEqual(self.dev.presses, 1)
|
||||
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(2)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
self.dev.reply_delay = 0.0
|
||||
self.dev.button_enabled = False
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(ip="127.0.0.1").act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(resolver=lambda h: None).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
|
||||
def test_a_wrong_key_is_refused(self):
|
||||
f = self.client(key="wrong")
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
@@ -576,6 +620,94 @@ class RoutingTests(unittest.TestCase):
|
||||
lit["v"] = False
|
||||
time.sleep(0.3)
|
||||
self.assertEqual((run.notice or {}).get("text"), "Something else.")
|
||||
# The watch ends with the run: a test that ends before the press
|
||||
# (a FAIL on the way to the arm, say) leaves no thread polling the
|
||||
# LEDs for up to the arm's whole timeout.
|
||||
def watching():
|
||||
return [t for t in threading.enumerate() if t.name == "forgetest-arm-notice"]
|
||||
deadline = time.time() + 2
|
||||
while watching() and time.time() < deadline:
|
||||
time.sleep(0.02) # the earlier watches are gone
|
||||
self.assertFalse(watching())
|
||||
lit["v"] = False
|
||||
self.assertFalse(ctx.arm_press())
|
||||
self.assertTrue(watching()) # up while the arm is still dark
|
||||
run.finished = {"result": "FAIL", "message": "", "duration_s": 0}
|
||||
deadline = time.time() + 2
|
||||
while watching() and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertFalse(watching())
|
||||
self.assertIsNotNone(run.notice) # the finished run's notice is left as it was
|
||||
# A press the fixture could not make is asked of the operator, and
|
||||
# that notice comes down at the press like any other: press_now and
|
||||
# arm_press with the box refusing, and arm_press with the button
|
||||
# never lit for the box.
|
||||
self.stub.arm_press = True
|
||||
self.stub.fail = True
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
|
||||
def settles(up):
|
||||
deadline = time.time() + 2
|
||||
while (run.notice is not None) != up and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
return (run.notice is not None) == up
|
||||
lit["v"] = True # the machine waits for the press
|
||||
self.assertFalse(ctx.press_now()) # the box is off: the operator is asked
|
||||
self.assertIsNotNone(run.notice)
|
||||
time.sleep(0.2) # lit: the arm waits for the press
|
||||
lit["v"] = False # the operator's press is taken
|
||||
self.assertTrue(settles(False))
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press()) # the fixture is asked, and refuses
|
||||
self.assertTrue(settles(True))
|
||||
time.sleep(0.2)
|
||||
lit["v"] = False
|
||||
self.assertTrue(settles(False))
|
||||
self.assertTrue(ctx.arm_press(lit_timeout=0)) # the button never lit for the fixture
|
||||
self.assertTrue(settles(True))
|
||||
lit["v"] = True
|
||||
time.sleep(0.2)
|
||||
lit["v"] = False
|
||||
self.assertTrue(settles(False))
|
||||
# A press the box made and reported failed (its reply lost after
|
||||
# the press): the button is out when the error arrives, so the
|
||||
# press was taken, and no notice asks for a second one.
|
||||
def pressed_then_failed(channel, state, ms=None):
|
||||
lit["v"] = False # the controller takes the press
|
||||
raise fx.FixtureError("timed out", sent=True)
|
||||
self.stub.act = pressed_then_failed
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.press_now())
|
||||
self.assertIsNone(run.notice)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 2
|
||||
while ctx.evidence["actions"][-1]["by"] != "fixture" and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||
self.assertIsNone(run.notice)
|
||||
# A refusal (or a request that never reached the box) while the
|
||||
# button went out: a press at the machine was taken, so no notice
|
||||
# goes up either, and the record does not give it to the box.
|
||||
def refused_while_pressed(channel, state, ms=None):
|
||||
lit["v"] = False # a person's press is taken
|
||||
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
|
||||
self.stub.act = refused_while_pressed
|
||||
lit["v"] = True
|
||||
self.assertFalse(ctx.press_now())
|
||||
self.assertIsNone(run.notice)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 2
|
||||
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
self.assertIsNone(run.notice)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -120,6 +121,89 @@ class MachineNameTests(unittest.TestCase):
|
||||
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):
|
||||
STANZA = ("auto wlan0\n"
|
||||
"iface wlan0 inet dhcp\n"
|
||||
@@ -232,6 +316,22 @@ class PackLicensesTests(unittest.TestCase):
|
||||
self.assertIn("common-licenses/license.manifest", t.getnames())
|
||||
self.assertFalse(os.path.exists(self.tree))
|
||||
|
||||
def test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left(self):
|
||||
"""A member tar cannot read fails the pack, bundle removed and tree
|
||||
left: tar skips that member, and the manifest check alone passes
|
||||
the bundle without it."""
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("root reads every file")
|
||||
self.make_tree(self.RECIPES)
|
||||
d = os.path.join(self.tree, "zlib")
|
||||
os.makedirs(d)
|
||||
locked = os.path.join(d, "generic_Zlib")
|
||||
with open(locked, "w") as f:
|
||||
f.write("zlib license\n")
|
||||
os.chmod(locked, 0) # rmtree still unlinks it
|
||||
self.assertRefused(self.pack())
|
||||
self.assertTrue(os.path.isfile(self.manifest))
|
||||
|
||||
|
||||
class RootfsLintTests(unittest.TestCase):
|
||||
"""rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a
|
||||
@@ -255,6 +355,14 @@ class RootfsLintTests(unittest.TestCase):
|
||||
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/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):
|
||||
return os.path.join(self.root, rel)
|
||||
@@ -313,6 +421,33 @@ class RootfsLintTests(unittest.TestCase):
|
||||
os.makedirs(self.path("etc/avahi"))
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
the button latch judged from the first emission through every sample
|
||||
whose readback word shows the laser latch unlocked, both bits from that
|
||||
word, so the relocked tail (where the relock itself sets the latch and
|
||||
the emission counter still reads nonzero) is never judged."""
|
||||
the emission counter still reads nonzero) is never judged. And its
|
||||
read-gap verdict over the dark stretch after the M5: a sample whose
|
||||
/status carried no laser block is a gap, never a dark sample, a tail the
|
||||
daemon did not serve before the sampling stopped is a gap too, and a
|
||||
stretch with a kernel window unread fails the rule; the sampling runs on
|
||||
past the close; and the head's reports place the rapid inside the armed
|
||||
window only."""
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import laser
|
||||
@@ -47,5 +53,157 @@ class DwellGapTests(unittest.TestCase):
|
||||
self.assertFalse(g["hv_enable_dipped"])
|
||||
|
||||
|
||||
def rapid_trail(n, dt=0.2, holes=()):
|
||||
"""A dark stretch of n samples dt seconds apart, the emission count
|
||||
and the HV current read on every sample but the ones in `holes`: a
|
||||
/status served without its laser block, which sample() records as
|
||||
None."""
|
||||
out = []
|
||||
for i in range(n):
|
||||
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
|
||||
if i in holes:
|
||||
s["emission"] = None
|
||||
s["hv"] = None
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def covered(r):
|
||||
"""The rule emission_witness applies to read_gaps' report."""
|
||||
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
|
||||
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
|
||||
|
||||
|
||||
class RapidReadTests(unittest.TestCase):
|
||||
def test_every_sample_read_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (25, 0))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.2)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_one_transient_miss_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (24, 1))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.4)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.8)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_a_hole_longer_than_a_window_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 1.2)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_the_block_gone_after_the_dark_point_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (1, 24))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 4.8)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
|
||||
# the tail is an edge: the window that latches after the close
|
||||
# goes unread
|
||||
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 1.6)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_a_tail_the_daemon_never_served_fails(self):
|
||||
# every sample served was read, and the served samples end at the
|
||||
# close: the sampling ran on past it with nothing served, so the
|
||||
# stretch from the last reading to where the sampling stopped is a gap
|
||||
trail = rapid_trail(25)
|
||||
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
|
||||
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
|
||||
self.assertAlmostEqual(r["gap_max_s"], tail)
|
||||
self.assertFalse(covered(r))
|
||||
# served through: the sampling stops one loop after the last sample
|
||||
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
|
||||
|
||||
def test_a_stretch_of_one_sample_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(1), "emission")
|
||||
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
|
||||
class Clock:
|
||||
"""time.time and time.sleep on a clock the test moves."""
|
||||
|
||||
def __init__(self, now=1000.0):
|
||||
self.now = now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, s):
|
||||
self.now += s
|
||||
|
||||
|
||||
class RapidTailTests(unittest.TestCase):
|
||||
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
|
||||
# The window closes 3 s in, and the daemon serves the closed
|
||||
# sample and nothing after it: the sampling still runs two kernel
|
||||
# windows and the read gap allowed past the close, and the time it
|
||||
# stopped is the trail's end, so the tail it never served fails the
|
||||
# read rule.
|
||||
clock = Clock()
|
||||
close = clock.now + 3.0
|
||||
served = []
|
||||
|
||||
def sample(_ctx):
|
||||
clock.now += 0.06 # the two GETs
|
||||
if served and not served[-1]["armed"]:
|
||||
return None # nothing served after the close
|
||||
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
|
||||
served.append(s)
|
||||
return s
|
||||
|
||||
class Ctx:
|
||||
def checkpoint(self):
|
||||
pass
|
||||
|
||||
class Grbl:
|
||||
def status_report(self):
|
||||
# the time of the read, as the head's X
|
||||
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
|
||||
saved = laser.time, laser.sample
|
||||
self.addCleanup(setattr, laser, "time", saved[0])
|
||||
self.addCleanup(setattr, laser, "sample", saved[1])
|
||||
laser.time, laser.sample = clock, sample
|
||||
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
|
||||
closed = tail[-1]
|
||||
self.assertFalse(closed["armed"])
|
||||
self.assertTrue(all(s["armed"] for s in tail[:-1]))
|
||||
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
|
||||
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
|
||||
# each sample's X is read before its armed flag: an X that places
|
||||
# the head was read with the window open
|
||||
self.assertTrue(all(s["x"] < s["t"] for s in tail))
|
||||
|
||||
|
||||
def place_trail(xs, closes_at):
|
||||
"""A dark stretch with the head at machine X `xs` (one sample each,
|
||||
0.2 s apart), the window closing at sample `closes_at`."""
|
||||
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
|
||||
|
||||
|
||||
class RapidPlaceTests(unittest.TestCase):
|
||||
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
|
||||
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
|
||||
|
||||
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
|
||||
# the window closes after the head is back: out at 2, back at 6, closed at 8
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
|
||||
# the window closes in the dwell at +20 mm and the head comes back
|
||||
# after the close: the samples past the close do not place it
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
|
||||
# the window reads closed at the first sample with the head back:
|
||||
# that sample does not tell whether the head was back before the
|
||||
# close, so it does not place it
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -197,9 +197,10 @@ class CoverageReportTests(unittest.TestCase):
|
||||
f.write(text)
|
||||
return root
|
||||
|
||||
# A workflow of this repository as the scratch checkouts carry it: it
|
||||
# A workflow of this repository as the scratch checkouts carry it: on
|
||||
# every push, and on the pull requests that touch one bench tool, it
|
||||
# checks itself out at the workspace root and runs the one harness.
|
||||
CI_YML = ("on:\n push:\n paths:\n - 'scripts/bench/drill.py'\n"
|
||||
CI_YML = ("on:\n push:\n pull_request:\n paths:\n - 'scripts/bench/drill.py'\n"
|
||||
"jobs:\n test:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n"
|
||||
" # drill.py is a bench tool\n"
|
||||
@@ -242,10 +243,36 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertEqual(m.host_row_problems(man, [runs], root=root, sources=None), [])
|
||||
self.assertEqual(m.host_row_problems(man, [idle], root=root, sources=None),
|
||||
[(idle[0], idle[1], idle[2], ci + " does not run it")])
|
||||
# a workflow whose own on: names neither push nor pull_request is listed, and a step whose
|
||||
# failure the run tolerates does not run it
|
||||
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/manual.yml": "on:\n workflow_dispatch:\n" + job,
|
||||
".github/workflows/soft.yml": "on: [push]\n" + job.replace(
|
||||
" - run:", " - continue-on-error: true\n run:")})
|
||||
manual = runs[:3] + ("forgefirm:.github/workflows/manual.yml",)
|
||||
soft = runs[:3] + ("forgefirm:.github/workflows/soft.yml",)
|
||||
self.assertEqual(m.host_row_problems(man, [manual, soft], root=root, sources=None),
|
||||
[manual[:3] + (manual[3] + ": the lint reads no push or pull_request in its own on:",),
|
||||
soft[:3] + (soft[3] + " does not run it",)])
|
||||
# a step in a job that needs a conditional job does not run it (the checkout in a job of
|
||||
# its own, as the lint reads a checkout for the whole workflow)
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||
" - run: make\n test:\n needs: build\n steps:\n"
|
||||
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||
needs = runs[:3] + ("forgefirm:.github/workflows/needs.yml",)
|
||||
self.assertEqual(m.host_row_problems(man, [needs], root=root, sources=None),
|
||||
[needs[:3] + (needs[3] + " does not run it",)])
|
||||
|
||||
def _runs(self, yml, path):
|
||||
return m.workflow_runs(m.workflow_steps(yml), path)
|
||||
|
||||
def _block(self, *lines, keys=""):
|
||||
"""A one-step workflow whose run text is these lines, the step's other keys before it."""
|
||||
return "steps:\n - name: x\n%s run: |\n%s" % (keys, "".join(" %s\n" % ln for ln in lines))
|
||||
|
||||
def test_what_running_a_host_test_looks_like(self):
|
||||
step = "steps:\n - name: x\n run: %s\n"
|
||||
self.assertTrue(self._runs(step % "./build/tray_test", "tests/tray_test.c"))
|
||||
@@ -254,9 +281,11 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertTrue(self._runs(step % "python3 forgefirm/scripts/bench/ctlport_test.py build/x",
|
||||
"forgefirm/scripts/bench/ctlport_test.py"))
|
||||
self.assertTrue(self._runs(step % "python -m pytest -q tests", "tests/test_frame.py"))
|
||||
# a block, with the step's working-directory: the words are its paths
|
||||
# a block, with the step's working-directory: the words are its paths (the loop as
|
||||
# python3-gfhardware's CI writes it, an expansion in an assignment)
|
||||
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
|
||||
" set -e\n for t in tests/test_*.py; do\n python -m unittest \"tests.$t\"\n"
|
||||
" set -e\n for t in tests/test_*.py; do\n m=$(basename \"$t\" .py)\n"
|
||||
" python -m unittest -v \"tests.$m\"\n"
|
||||
" done\n - working-directory: forgefirm/forgetest\n"
|
||||
" run: python -B -m unittest discover -s tests -v\n")
|
||||
self.assertTrue(self._runs(block % "gfh", "gfh/tests/test_feeder.py"))
|
||||
@@ -267,8 +296,8 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertFalse(self._runs("on:\n push:\n paths:\n - 'kas/source-bundle.yml'\n"
|
||||
+ step % "python3 x.py", "kas/source-bundle.yml"))
|
||||
self.assertFalse(self._runs(step % "./build/lens_test # tray_test is manual", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "for t in tests/test_*.py; do python $t; done",
|
||||
"tools/tests/test_feeder.py"))
|
||||
self.assertFalse(self._runs("steps:\n - run: |\n for t in tests/test_*.py; do\n python $t\n"
|
||||
" done\n", "tools/tests/test_feeder.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest discover -s tests",
|
||||
"scripts/bench/tests/foo_test.py"))
|
||||
# a longer name, another file, a step's name, a comment line
|
||||
@@ -281,6 +310,7 @@ class CoverageReportTests(unittest.TestCase):
|
||||
# discover or pytest runs its own directory's matching files only
|
||||
self.assertFalse(self._runs(step % "echo tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "echo ./build/tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "command -v ./build/tray_test", "tests/tray_test.c")) # prints it
|
||||
self.assertFalse(self._runs(step % "cmake --build build --target tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "cat tests/run_test.py", "tests/run_test.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest discover -s forgefirm",
|
||||
@@ -305,6 +335,191 @@ class CoverageReportTests(unittest.TestCase):
|
||||
self.assertFalse(self._runs(step % "python -m unittest -k test_feeder tests.test_lens",
|
||||
"tests/test_feeder.py"))
|
||||
|
||||
def test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run(self):
|
||||
step = "steps:\n - name: x\n%s run: ./build/tray_test\n"
|
||||
test = "tests/tray_test.c"
|
||||
# the lint evaluates no condition: an if: other than the literal true counts as no step,
|
||||
# always() included; literally true is no condition
|
||||
self.assertFalse(self._runs(step % " if: false\n", test))
|
||||
self.assertFalse(self._runs(step % " if: github.event_name == 'push'\n", test))
|
||||
self.assertFalse(self._runs(step % " if: ${{ always() }}\n", test))
|
||||
self.assertTrue(self._runs(step % " if: true\n", test))
|
||||
self.assertTrue(self._runs(step % " if: 'true'\n", test))
|
||||
# however the step is spelled: more than one space after its dash, or a quoted key
|
||||
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n if: false\n", test))
|
||||
self.assertFalse(self._runs(step % " \"if\": false\n", test))
|
||||
# a key with one stray quote is no key the lint reads: at a step's key (its first one on
|
||||
# its dash line too), a job's key or the top level, no step of the workflow counts
|
||||
self.assertFalse(self._runs(step % " if\": false\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - name: x\n run\": ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - run\": make\n - run: ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n if\": false\n steps:\n - run: ./build/tray_test\n",
|
||||
test))
|
||||
self.assertFalse(self._runs("jobs\":\n t:\n if: false\n steps:\n - run: ./build/tray_test\n",
|
||||
test))
|
||||
# a step whose failure the run tolerates proves nothing; literally false is no tolerance
|
||||
self.assertFalse(self._runs(step % " continue-on-error: true\n", test))
|
||||
self.assertFalse(self._runs(step % " continue-on-error: ${{ inputs.soft }}\n", test))
|
||||
self.assertTrue(self._runs(step % " continue-on-error: false\n", test))
|
||||
# the job's own if: or continue-on-error: holds for each of its steps, wherever it is
|
||||
# written, over a step's own, and not for another job's steps
|
||||
job = "jobs:\n t:\n%s steps:\n - run: ./build/tray_test\n%s"
|
||||
self.assertFalse(self._runs(job % (" if: github.ref == 'refs/heads/main'\n", ""), test))
|
||||
self.assertFalse(self._runs(job % ("", " if: false\n"), test))
|
||||
self.assertFalse(self._runs(job % (" continue-on-error: true\n", ""), test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n continue-on-error: true\n steps:\n"
|
||||
" - continue-on-error: false\n run: ./build/tray_test\n", test))
|
||||
self.assertTrue(self._runs(job % (" if: true\n runs-on: ubuntu-latest\n", ""), test))
|
||||
self.assertTrue(self._runs(job % (" permissions:\n contents: read\n", ""), test))
|
||||
two = "jobs:\n a:\n if: false\n steps:\n - run: %s\n b:\n steps:\n - run: %s\n"
|
||||
self.assertTrue(self._runs(two % ("make", "./build/tray_test"), test))
|
||||
self.assertFalse(self._runs(two % ("./build/tray_test", "make"), test))
|
||||
# the run text is read as bash -e or sh -e runs it: the test counts on a line that holds it
|
||||
# alone, or in a pipeline under pipefail; any other operator on the line, or a pipe with no
|
||||
# pipefail, keeps it from counting
|
||||
for line in ("./build/tray_test || true", "true || ./build/tray_test", "./build/tray_test; true",
|
||||
"./build/tray_test &", "./build/tray_test | tee log"):
|
||||
self.assertFalse(self._runs(self._block(line), test), line)
|
||||
self.assertTrue(self._runs(self._block("set -o pipefail", "./build/tray_test | tee log"), test))
|
||||
self.assertTrue(self._runs(self._block("./build/tray_test | tee log", keys=" shell: bash\n"), test))
|
||||
# no line counts after one that ends the step, turns errexit off, sets an option the lint
|
||||
# does not follow (noexec), or opens a here-document or a compound command
|
||||
for before in ("exit 0", "set +e", "set -n", "python3 - <<'EOF'"):
|
||||
self.assertFalse(self._runs(self._block(before, "./build/tray_test"), test), before)
|
||||
self.assertFalse(self._runs(self._block("{", "./build/tray_test", "} || true"), test))
|
||||
# a loop counts only with a body whose lines count
|
||||
for body in ("python $t || true", "python $t | tee log"):
|
||||
self.assertFalse(self._runs(self._block("for t in tests/test_*.py; do", body, "done"), "tests/test_x.py"),
|
||||
body)
|
||||
# a folded block, and a scalar continued under its key, are one line to the shell
|
||||
self.assertFalse(self._runs("steps:\n - run: >\n ./build/tray_test\n || true\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n || true\n", test))
|
||||
# the lint reads the text of bash and sh alone: the step's shell, or its job's or its
|
||||
# workflow's default
|
||||
self.assertFalse(self._runs(self._block("./build/tray_test", keys=" shell: bash {0}\n"), test))
|
||||
self.assertTrue(self._runs(self._block("./build/tray_test", keys=" shell: sh\n"), test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n defaults:\n run:\n shell: pwsh\n steps:\n"
|
||||
" - run: ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("defaults:\n run:\n shell: pwsh\njobs:\n t:\n steps:\n"
|
||||
" - run: ./build/tray_test\n", test))
|
||||
# a checkout under a condition is no checkout
|
||||
yml = "steps:\n - uses: actions/checkout@v4\n%s with:\n path: own\n"
|
||||
self.assertEqual(m.checkout_path(m.workflow_steps(yml % ""), "x", True), "own")
|
||||
self.assertIsNone(m.checkout_path(m.workflow_steps(yml % " if: false\n"), "x", True))
|
||||
|
||||
def test_a_job_runs_only_when_every_job_it_needs_runs(self):
|
||||
test = "tests/tray_test.c"
|
||||
# GitHub skips a job whose needed job did not run: a job that needs a conditional job runs
|
||||
# on not every run, however the needs: is written
|
||||
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
|
||||
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
|
||||
" needs:\n - build\n", " needs:\n - build\n",
|
||||
" needs: [\n build\n ]\n"):
|
||||
self.assertTrue(self._runs(yml % ("", needs), test), needs)
|
||||
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
|
||||
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
|
||||
# the needed job's continue-on-error: does not bear: its failure fails neither the run nor
|
||||
# the jobs after it
|
||||
self.assertTrue(self._runs(yml % (" continue-on-error: true\n", " needs: build\n"), test))
|
||||
# transitively; a job the workflow does not have, a cycle, and a needs: the lint does not
|
||||
# read each fail closed
|
||||
chain = "jobs:\n a:\n%s steps:\n - run: make\n b:\n needs: a\n steps:\n - run: make\n" \
|
||||
" c:\n needs: [b]\n steps:\n - run: ./build/tray_test\n"
|
||||
self.assertTrue(self._runs(chain % "", test))
|
||||
self.assertFalse(self._runs(chain % " if: false\n", test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs: lint\n"), test))
|
||||
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
|
||||
# a flow list split after a comma keeps the name on its second line: a conditional job
|
||||
# there holds the dependent job back
|
||||
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
|
||||
self.assertTrue(self._runs(split, test))
|
||||
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
|
||||
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
|
||||
# list items at two indentations each fail closed
|
||||
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
|
||||
" needs:\n - build\n - build\n"):
|
||||
self.assertFalse(self._runs(yml % ("", needs), test), needs)
|
||||
# through a row: the test job needs, in a block list at the key's own indentation, a
|
||||
# build job under if: false (the checkout in a job of its own, as the lint reads a
|
||||
# checkout for the whole workflow)
|
||||
man = helpers.make_manifest()
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||
" - run: make\n test:\n needs:\n - build\n steps:\n"
|
||||
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||
"forgefirm:.github/workflows/needs.yml")
|
||||
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
|
||||
[row[:3] + (row[3] + " does not run it",)])
|
||||
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
|
||||
# no job for its own steps and for the jobs after it
|
||||
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))
|
||||
self.assertFalse(self._runs((chain % " if: false\n").replace(
|
||||
" b:\n needs: a\n", " b:\n needs: a\n if: ${{ always() }}\n"), test))
|
||||
|
||||
def test_the_events_a_workflow_runs_on(self):
|
||||
self.assertEqual(m.workflow_triggers("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"),
|
||||
{"push", "pull_request"})
|
||||
self.assertEqual(m.workflow_triggers("on: [push, pull_request]\n"), {"push", "pull_request"})
|
||||
self.assertEqual(m.workflow_triggers("on: push\n"), {"push"})
|
||||
self.assertEqual(m.workflow_triggers("on:\n - push\n - workflow_dispatch\n"), {"push", "workflow_dispatch"})
|
||||
self.assertEqual(m.workflow_triggers("\"on\":\n workflow_dispatch:\n inputs:\n push:\n"
|
||||
" type: string\n"), {"workflow_dispatch"})
|
||||
self.assertEqual(m.workflow_triggers("on:\n workflow_call:\n"), {"workflow_call"})
|
||||
self.assertEqual(m.workflow_triggers("name: x\njobs:\n t:\n steps: []\n"), set())
|
||||
# an event key with one stray quote is no event, and the workflow then names none
|
||||
self.assertEqual(m.workflow_triggers("on:\n push\":\n"), set())
|
||||
self.assertEqual(m.workflow_triggers("on:\n push:\n 'pull_request:\n"), set())
|
||||
|
||||
def test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change(self):
|
||||
f = m.workflow_filters
|
||||
self.assertEqual(f("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"), {"push": ["branches"]})
|
||||
self.assertEqual(f("on:\n push:\n branches-ignore:\n - wip/*\n paths-ignore:\n - 'docs/**'\n"
|
||||
" pull_request:\n paths:\n - 'src/**'\n"),
|
||||
{"push": ["branches-ignore", "paths-ignore"], "pull_request": ["paths"]})
|
||||
self.assertEqual(f("on:\n push:\n tags: ['v*']\n"), {"push": ["tags"]})
|
||||
self.assertEqual(f("on:\n push:\n tags-ignore: ['v*']\n"), {"push": ["tags-ignore"]})
|
||||
# a pull_request under types: runs on the activity types it names and no other; the lint
|
||||
# reads no type, so any types: is a filter, on the pull_request alone
|
||||
self.assertEqual(f("on:\n push:\n pull_request:\n types: [closed]\n"), {"pull_request": ["types"]})
|
||||
self.assertEqual(f("on:\n pull_request:\n types:\n - opened\n - synchronize\n paths-ignore:\n"
|
||||
" - '**.md'\n"), {"pull_request": ["types", "paths-ignore"]})
|
||||
self.assertEqual(f("on:\n push:\n types: [x]\n"), {})
|
||||
# a value on the event's line, and a key with one stray quote under it, are filters the
|
||||
# lint does not read
|
||||
self.assertEqual(f("on:\n push: {branches: [main]}\n"), {"push": ["a value the lint does not read"]})
|
||||
self.assertEqual(f("on:\n push:\n paths\":\n - x\n"), {"push": ["a key the lint does not read"]})
|
||||
# a filter is read at the event's own level: a dispatch's inputs are none, and an on: list
|
||||
# or scalar carries none
|
||||
self.assertEqual(f("on:\n workflow_dispatch:\n inputs:\n paths:\n type: string\n push:\n"),
|
||||
{})
|
||||
self.assertEqual(f("on: [push, pull_request]\n"), {})
|
||||
self.assertEqual(f("on: push\n"), {})
|
||||
self.assertEqual(f("on:\n - push\n"), {})
|
||||
# a row's workflow counts when its push or its pull_request carries no filter: a push to
|
||||
# the default branch alone with every pull request does, a push with paths-ignore alone
|
||||
# does not, nor do a push and a pull request each under a filter; every push with a pull
|
||||
# request under types: does, a pull request under types: alone does not
|
||||
man = helpers.make_manifest()
|
||||
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/main.yml": "on:\n push:\n branches: [main]\n pull_request:\n" + job,
|
||||
".github/workflows/docs.yml": "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + job,
|
||||
".github/workflows/both.yml": "on:\n push:\n branches: [main]\n pull_request:\n"
|
||||
" paths:\n - 'src/**'\n" + job,
|
||||
".github/workflows/push.yml": "on:\n push:\n pull_request:\n types: [closed]\n" + job,
|
||||
".github/workflows/closed.yml": "on:\n pull_request:\n types: [closed]\n" + job})
|
||||
rows = [("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||
"forgefirm:.github/workflows/%s.yml" % w) for w in ("main", "docs", "both", "push", "closed")]
|
||||
filtered = ": every push or pull_request in its own on: carries a filter (%s)"
|
||||
self.assertEqual(m.host_row_problems(man, rows, root=root, sources=None),
|
||||
[rows[1][:3] + (rows[1][3] + filtered % "push: paths-ignore",),
|
||||
rows[2][:3] + (rows[2][3] + filtered % "pull_request: paths; push: branches",),
|
||||
rows[4][:3] + (rows[4][3] + filtered % "pull_request: types",)])
|
||||
|
||||
def test_the_lint_says_when_it_could_not_read_a_workflow(self):
|
||||
import contextlib
|
||||
import io
|
||||
@@ -366,7 +581,7 @@ class CoverageReportTests(unittest.TestCase):
|
||||
def blob(text):
|
||||
return subprocess.run(["git", "hash-object", "-w", "--stdin"], cwd=cache, input=text.encode(),
|
||||
capture_output=True, check=True).stdout.decode().strip()
|
||||
yml = "steps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
|
||||
yml = "on: [push, pull_request]\nsteps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
|
||||
comps = {"forgectrl": {"srcrev": "aaa", "source": url, "files": [
|
||||
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], ["tests/lens_test.c", "3" * 40],
|
||||
[".github/workflows/build.yml", blob(yml)], [".github/workflows/lost.yml", "4" * 40]]}}
|
||||
@@ -425,6 +640,37 @@ class CatalogTests(unittest.TestCase):
|
||||
for t in catalog.all_tests(reg):
|
||||
self.assertEqual(len(t.source_sha), 64)
|
||||
|
||||
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
|
||||
# The list is kept by hand: the Context reaches runner.py, hw.py
|
||||
# and baseline.py with no import to find. So every package module
|
||||
# a suite module does import, at its top level or inside a function
|
||||
# or a block, has to be on it (or be the registry, which judges
|
||||
# nothing), or that judge would be in no fingerprint. The sibling
|
||||
# walk follows the relative spellings alone (`from .x import` and
|
||||
# `from . import x`), so a suite module imports the package by no
|
||||
# absolute name.
|
||||
import ast
|
||||
import glob
|
||||
paths = catalog.shared_judge_paths()
|
||||
self.assertEqual([os.path.basename(p) for p in paths], list(catalog.SHARED_JUDGES))
|
||||
for p in paths:
|
||||
self.assertTrue(os.path.isfile(p), p)
|
||||
allowed = set(catalog.SHARED_JUDGES) | {"catalog.py"}
|
||||
for mod in sorted(glob.glob(os.path.join(catalog.suite_dir(), "*.py"))):
|
||||
with open(mod, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 2:
|
||||
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||
self.assertIn(name + ".py", allowed,
|
||||
"%s imports %s, which no fingerprint carries" % (os.path.basename(mod), name))
|
||||
absolute = ([node.module or ""] if isinstance(node, ast.ImportFrom) and node.level == 0
|
||||
else [a.name for a in node.names] if isinstance(node, ast.Import) else [])
|
||||
for name in absolute:
|
||||
self.assertFalse(name == "forgetest" or name.startswith("forgetest."),
|
||||
"%s imports %s by its absolute name, which no hash follows: import it "
|
||||
"relatively (from .. or from .)" % (os.path.basename(mod), name))
|
||||
|
||||
|
||||
class ImplementationHashTests(unittest.TestCase):
|
||||
"""A test's implementation hash is its own function plus the code its
|
||||
@@ -511,6 +757,94 @@ def two(ctx):
|
||||
f.write("def o():\n return 1\n")
|
||||
self.assertEqual((a2, b2), self.shas())
|
||||
|
||||
def test_a_shared_judge_edit_moves_every_test(self):
|
||||
# runner.py, hw.py and baseline.py are in every test's path with no
|
||||
# import to follow (the Context, the baseline around each run), and
|
||||
# puls.py comes in by a package import the walk does not follow, so
|
||||
# their text joins every implementation hash.
|
||||
judge = os.path.join(self.tmp, "hwlike.py")
|
||||
|
||||
def write_judge(text):
|
||||
with open(judge, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
catalog.forget(judge)
|
||||
|
||||
def shas():
|
||||
return tuple(catalog.implementation_sha(self.path, t, judges=[judge]) for t in ("m.one", "m.two"))
|
||||
|
||||
write_judge("def sysfs_int(attr, default=None):\n return default\n")
|
||||
a1, b1 = shas()
|
||||
write_judge("def sysfs_int(attr, default=None):\n return 8\n")
|
||||
a2, b2 = shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
# a package file outside the list changes nothing
|
||||
other = os.path.join(self.tmp, "server.py")
|
||||
with open(other, "w", newline="\n") as f:
|
||||
f.write("def serve():\n return 1\n")
|
||||
self.assertEqual((a2, b2), shas())
|
||||
# Test.source_sha, which the fingerprint, the campaign and the gate
|
||||
# read, takes the shared judges itself: a suite test's hash is the
|
||||
# one with SHARED_JUDGES folded in, not the one without them.
|
||||
import inspect
|
||||
t = catalog.load_suite()["image.health"]
|
||||
src = inspect.getsourcefile(t.fn)
|
||||
self.assertEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=catalog.shared_judge_paths()))
|
||||
self.assertNotEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=[]))
|
||||
|
||||
def test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it(self):
|
||||
# A test that imports a judge inside its own function carries the
|
||||
# judge in its hash, and what the judge imports in turn, at any
|
||||
# depth, and no other test of its module does; a helper of the
|
||||
# module that imports one inside its body carries it into every
|
||||
# test of the module.
|
||||
def put(name, text):
|
||||
path = os.path.join(self.tmp, name)
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
catalog.forget(path)
|
||||
|
||||
def write_judge(text):
|
||||
put("judge.py", text)
|
||||
write_judge("def judge(x):\n return x > 1\n")
|
||||
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||
"def two(ctx):\n from .judge import judge\n judge(2)\n"))
|
||||
a1, b1 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 2\n")
|
||||
a2, b2 = self.shas()
|
||||
self.assertEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
# judge.py imports limit.py, which imports base.py: an edit to
|
||||
# either moves m.two, and not m.one
|
||||
put("base.py", "BASE = 1\n")
|
||||
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE\n")
|
||||
write_judge("from .limit import LIMIT\n\n\ndef judge(x):\n return x > LIMIT\n")
|
||||
a5, b5 = self.shas()
|
||||
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE + 1\n")
|
||||
a6, b6 = self.shas()
|
||||
self.assertEqual(a5, a6)
|
||||
self.assertNotEqual(b5, b6)
|
||||
put("base.py", "BASE = 2\n")
|
||||
a7, b7 = self.shas()
|
||||
self.assertEqual(a6, a7)
|
||||
self.assertNotEqual(b6, b7)
|
||||
self.write(self.MODULE.replace(" return x + 1\n",
|
||||
" from .judge import judge\n return judge(x)\n"))
|
||||
a3, b3 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 3\n")
|
||||
a4, b4 = self.shas()
|
||||
self.assertNotEqual(a3, a4)
|
||||
self.assertNotEqual(b3, b4)
|
||||
# the spelling with no module (`from . import x`, as homeoff.py imports cloud.py) is
|
||||
# walked the same
|
||||
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||
"def two(ctx):\n from . import judge as j\n j.judge(2)\n"))
|
||||
a8, b8 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 4\n")
|
||||
a9, b9 = self.shas()
|
||||
self.assertEqual(a8, a9)
|
||||
self.assertNotEqual(b8, b9)
|
||||
|
||||
def test_line_endings_do_not_count(self):
|
||||
a1, b1 = self.shas()
|
||||
catalog.forget(self.path)
|
||||
|
||||
@@ -9,16 +9,19 @@ sender, the page, and the driver's laser module; and the burn against a
|
||||
scripted daemon: the preview and the program read, the start, the
|
||||
prompts answered in order (the gantry stops, the head at home, the scrap,
|
||||
its thickness in the machine's units, the arm, the machine's own press
|
||||
prompt, the pick), the witnesses judged, the record read at version 1,
|
||||
the setting the burn wrote put back as found, and the controller started
|
||||
prompt, the pick), the witnesses judged (the burn's own, and the two the
|
||||
test reads from sysfs at every poll), the record read at version 1, the
|
||||
setting the burn wrote put back as found, and the controller started
|
||||
again on the edge and the window put back."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import catalog # noqa: E402
|
||||
from forgetest import baseline, catalog # noqa: E402
|
||||
from forgetest.runner import Aborted, Context, Failed, Run # noqa: E402
|
||||
from forgetest.suite import setup_burns, setup_dark # noqa: E402
|
||||
|
||||
@@ -279,18 +282,50 @@ class ScriptedDaemon:
|
||||
|
||||
class BurnRunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# the two witnesses as sysfs serves them through a lit burn (the
|
||||
# shape of a bench record: the LASER_ON count and the tube current)
|
||||
self.sysfs = tempfile.mkdtemp(prefix="forgetest-burn-")
|
||||
self.plant("cnc/laser_on_sampled", "118")
|
||||
self.plant("pic/hv_current", "431")
|
||||
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
# the head at its home against the gantry stops, X and Y referenced
|
||||
# by a manual home with no offset (the fake's pos is X0 Y0 Z0), its
|
||||
# anchor declaring the stops at X0 Y0
|
||||
self.fake.state["status"].update({"homed": True, "homed_axes": 7, "home_source": "manual"})
|
||||
self.saved_anchor = baseline.ANCHOR_PATH
|
||||
baseline.ANCHOR_PATH = os.path.join(self.sysfs, "grblhal.homed")
|
||||
self.anchor("0.000 0.000 0.000 7 manual")
|
||||
self.daemon = ScriptedDaemon(self.fake)
|
||||
self.poll = setup_dark.POLL_S
|
||||
setup_dark.POLL_S = 0.01
|
||||
|
||||
def tearDown(self):
|
||||
setup_dark.POLL_S = self.poll
|
||||
baseline.ANCHOR_PATH = self.saved_anchor
|
||||
self.fake.stop()
|
||||
if self.saved_root is None:
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
else:
|
||||
os.environ["GF_SYSFS_ROOT"] = self.saved_root
|
||||
shutil.rmtree(self.sysfs, ignore_errors=True)
|
||||
|
||||
def plant(self, attr, value):
|
||||
path = os.path.join(self.sysfs, *attr.split("/"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(value + "\n")
|
||||
|
||||
def anchor(self, line):
|
||||
"""The controller's home anchor as a home writes it: X Y Z, the
|
||||
axes it references, and what set it."""
|
||||
with open(baseline.ANCHOR_PATH, "w") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
def run_test(self, tid):
|
||||
t = catalog.load_suite()[tid]
|
||||
run = Run("test", t.id, t.title)
|
||||
run = self.run = Run("test", t.id, t.title) # kept for a run that fails
|
||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
@@ -312,6 +347,14 @@ class BurnRunTests(unittest.TestCase):
|
||||
self.assertNotIn("lens_hall_edge_z_mm", start)
|
||||
self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20"))
|
||||
self.assertEqual(run.evidence["result"]["emission"]["hv_max"], 610)
|
||||
# the head's place on record before the start, judged at the stops: X0 Y0 with no offset
|
||||
self.assertEqual(run.evidence["head_at_start"],
|
||||
{"pos": {"x": 0.0, "y": 0.0, "z": 0.0}, "homed_axes": 7, "home_source": "manual",
|
||||
"judged": True, "stops": {"x": 0.0, "y": 0.0}})
|
||||
# the witnesses read from here at every poll: each at its peak
|
||||
seen = run.evidence["emission_seen"]
|
||||
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
|
||||
self.assertGreater(seen["reads"], 0)
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual(fw["window"]["below"], 14)
|
||||
self.assertEqual(fw["window"]["above"], 20)
|
||||
@@ -402,6 +445,100 @@ class BurnRunTests(unittest.TestCase):
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("the tube current never rose", str(cm.exception))
|
||||
# what the test read itself is on record beside the daemon's word
|
||||
seen = self.run.evidence["emission_seen"]
|
||||
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_head_off_its_home_fails_before_any_start(self):
|
||||
# X and Y referenced by a manual home, and the head 12.5 mm from
|
||||
# the stops: nothing starts, no controller lever is touched.
|
||||
self.fake.state["status"]["pos"] = {"x": 12.5, "y": 0.0, "z": 0.0}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assertEqual(self.daemon.lever_posts(), [])
|
||||
self.assert_found()
|
||||
|
||||
def test_a_manual_home_offset_puts_the_stops_off_x0_y0(self):
|
||||
# A manual home made with manual_home_x 5 and manual_home_y 3
|
||||
# declares the stops X-5 Y-3 (its anchor) and leaves the head at
|
||||
# X0 Y0, in front of them, where the wizard does not draw from. X0
|
||||
# Y0 fails before any start, with no controller lever touched; the
|
||||
# head at the stops is home.
|
||||
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
|
||||
self.anchor("-5.000 -3.000 0.000 7 manual")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.run.evidence["head_at_start"]["stops"], {"x": -5.0, "y": -3.0})
|
||||
self.assertEqual(self.daemon.order, [])
|
||||
self.assertEqual(self.daemon.lever_posts(), [])
|
||||
self.assert_found()
|
||||
self.fake.state["status"]["pos"] = {"x": -5.0, "y": -3.0, "z": 0.0}
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertTrue(run.evidence["head_at_start"]["judged"])
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
# The offsets cleared after that home leave its reference as it is:
|
||||
# the stops are still X-5 Y-3, and the head at X0 Y0 still fails
|
||||
# before any start.
|
||||
for k in ("manual_home_x", "manual_home_y"):
|
||||
self.fake.state["settings"].pop(k)
|
||||
self.fake.state["status"]["pos"] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("not at its home", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
# The offsets set after a home made with none: the head at X0 Y0 is
|
||||
# at the stops that home declared, and the burn runs.
|
||||
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
|
||||
self.anchor("0.000 0.000 0.000 7 manual")
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertEqual(run.evidence["head_at_start"]["stops"], {"x": 0.0, "y": 0.0})
|
||||
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
|
||||
# An anchor that is gone, or that is not the manual home /status
|
||||
# reports, places no stops: nothing starts.
|
||||
os.unlink(baseline.ANCHOR_PATH)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("home anchor", str(cm.exception))
|
||||
self.anchor("0.000 0.000 0.000 4 startup")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("home anchor", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
|
||||
|
||||
def test_an_unreferenced_head_runs_on_the_operators_word(self):
|
||||
# A fresh controller's counters-only position places nothing: the
|
||||
# read is recorded, not judged, and the burn runs.
|
||||
self.fake.state["status"].update({"homed": False, "homed_axes": 4, "home_source": "startup",
|
||||
"pos": {"x": 12.5, "y": 0.0, "z": 0.0}})
|
||||
run = self.run_test("setup.focus")
|
||||
self.assertEqual(run.evidence["head_at_start"]["judged"], False)
|
||||
self.assertEqual(run.evidence["head_at_start"]["home_source"], "startup")
|
||||
self.assertEqual(self.daemon.order, ["laser.focus"])
|
||||
|
||||
def test_a_burn_the_test_itself_never_saw_fails_and_still_restores(self):
|
||||
# The burn's result reports a lit burn, and the test's own reads
|
||||
# of the two witnesses never leave zero: the burn is judged on
|
||||
# what was read from here, not on the daemon's word alone.
|
||||
self.plant("cnc/laser_on_sampled", "0")
|
||||
self.plant("pic/hv_current", "0")
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("LASER_ON count never read nonzero", str(cm.exception))
|
||||
self.assert_found()
|
||||
self.assert_restarted_on_found()
|
||||
|
||||
def test_a_burn_whose_tube_current_the_test_never_saw_rise_fails(self):
|
||||
# The LASER_ON count read nonzero from here, and the tube current
|
||||
# never read over HV_ON: the second witness is judged on its own
|
||||
# reads, against the daemon's own threshold for a lit burn.
|
||||
self.plant("pic/hv_current", str(setup_burns.HV_ON))
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test("setup.focus")
|
||||
self.assertIn("tube current never read over %d from here" % setup_burns.HV_ON, str(cm.exception))
|
||||
self.assert_found()
|
||||
|
||||
def test_a_burn_with_a_silent_beam_sensor_passes(self):
|
||||
@@ -446,6 +583,7 @@ class BurnRunTests(unittest.TestCase):
|
||||
with self.assertRaises(Aborted):
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.aborted, ["laser.focus"])
|
||||
self.assertIn("emission_seen", run.evidence) # the test's own reads, on record all the same
|
||||
self.assert_found()
|
||||
self.assert_restarted_on_found()
|
||||
|
||||
|
||||
@@ -408,6 +408,26 @@ class LensCheckTests(ScriptedCheck):
|
||||
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
|
||||
self.assert_put_back()
|
||||
|
||||
def test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails(self):
|
||||
# The run never ends: the test stops following it at its timeout,
|
||||
# aborts it, and fails, so no check runs on behind the FAIL.
|
||||
from forgetest.runner import Failed
|
||||
from forgetest.suite import setup_dark
|
||||
real = self.fake.on_get
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark) # running until aborted
|
||||
return real(path, q)
|
||||
self.fake.on_get = on_get
|
||||
t = catalog.load_suite()["setup.check-lens"]
|
||||
ctx = Context(Run("test", t.id, t.title), None, t)
|
||||
with self.assertRaises(Failed) as cm:
|
||||
setup_dark.run_check(ctx, self.WID, lambda p: None, 0.2)
|
||||
self.assertIn("did not end within", str(cm.exception))
|
||||
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
|
||||
self.assertFalse(self.dark["running"])
|
||||
|
||||
def test_a_controller_that_does_not_restart_fails_the_hand_back(self):
|
||||
# The start refused: the controller stays stopped on the stale
|
||||
# window's heels, and the run says so rather than pass.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||
takeover tests, the operator tests' hands), the record builders, the
|
||||
cookie parsing, and the settle rule for a gated supervisor."""
|
||||
"""The setup.* suite on the host: the registration (the operator tests'
|
||||
hands, the intake tests' precheck), the record builders, the cookie
|
||||
parsing, and the settle rule for a gated supervisor."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -18,19 +18,11 @@ import helpers # noqa: E402
|
||||
from forgetest import baseline, catalog # noqa: E402
|
||||
from forgetest.suite import setup # noqa: E402
|
||||
|
||||
IDS = ("setup.account-login",)
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
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):
|
||||
w = {"recommended": [{"id": "laser.floor", "reason": "the tube 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")))
|
||||
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):
|
||||
STATUS = {"sheet_id": "ABCDE-FGHIJ",
|
||||
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: b7626be45c7708a8e71bd03af61ca968796fc8f7
|
||||
commit: c542ce6ec5799df9cf0f689eb883c790c71a414b
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "22a2cfc04242ddc50ed0a54e967a59634f3f4d6d"
|
||||
PV = "0.1.32"
|
||||
SRCREV = "b235be3e8ac5fcd88080b00c9d441dbda9c0014c"
|
||||
PV = "0.1.34"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "31cdb4005668286c9faa5b4b834f0cbc3fdbbe9b"
|
||||
PV = "0.1.2"
|
||||
SRCREV = "ebd959256d3da9f648d6975ba740bee86ad69c7b"
|
||||
PV = "0.1.5"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
# because the component entry already identifies the pinned source
|
||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||
# meta-glowforge-bsp pins the same repository; move both together.
|
||||
SRCREV = "0f59c547b6e8418712ab9e000c58eb1975c29d79"
|
||||
SRCREV = "6fb146a13c51765eec492bf758946e573ae25297"
|
||||
|
||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||
# backwards.
|
||||
PV = "0.1.32+git"
|
||||
PV = "0.1.33+git"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||
# *-pin.inc out of the layer content hash because the component entry
|
||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "badc8323463c059e418bdf0e4a161ed73823bfc1"
|
||||
PV = "0.1.21"
|
||||
SRCREV = "e15972005c6e3e3b5bcb4d7408df0eaf83b5cc43"
|
||||
PV = "0.1.22"
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||
# and the acceptance test image.health compares the stamp on the running
|
||||
# machine with the manifest's.
|
||||
FORGEFIRM_RELEASE ?= "0.0.8"
|
||||
FORGEFIRM_RELEASE ?= "0.0.9"
|
||||
|
||||
@@ -31,8 +31,11 @@ done
|
||||
|
||||
base=$(basename "$dir")
|
||||
mkdir -p "$(dirname "$out")" || fail "cannot make the directory of $out"
|
||||
# tar runs the compressor itself, so its exit status covers both: a
|
||||
# member it cannot read fails the pack, and a failed pack leaves no bundle.
|
||||
tar -C "$(dirname "$dir")" --sort=name --mtime=@0 --owner=0 --group=0 \
|
||||
--numeric-owner -cf - "$base" | gzip -9 -n > "$out" || fail "cannot write $out"
|
||||
--numeric-owner -I 'gzip -9 -n' -cf "$out" "$base" \
|
||||
|| { rm -f "$out"; fail "cannot pack $dir into $out"; }
|
||||
# the tree goes only once the bundle holds its manifest
|
||||
tar -tzf "$out" "$base/license.manifest" > /dev/null 2>&1 \
|
||||
|| fail "$out holds no $base/license.manifest"
|
||||
|
||||
@@ -29,6 +29,18 @@
|
||||
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
|
||||
# libnss_mdns, and the hosts line of nsswitch.conf names no
|
||||
# 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}
|
||||
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"
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
# ForgeFIRM acceptance - 20261018222431 (dev)
|
||||
|
||||
- Image: `20261018222431 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `70ecd7867c2698d17f2f3c38db772868eda3364f541242c93fb5db4e1d593bcd`
|
||||
- Catalog: `b38332a30994e16a499a9ded530062291bacabf873138461dd54421e2174c29b`
|
||||
- Campaign: `c-20261018224638-4ca0` opened 2026-10-18T22:46:38Z
|
||||
- Exported: 2026-10-18T23:33:36Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 43 total, 43 satisfied (0 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-18T22:47:02Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-18T22:47:12Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-18T22:47:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-18T22:48:14Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-10-18T22:48:37Z | `c-20261018224638-4ca0` | no |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-10-18T22:48:41Z | `c-20261018224638-4ca0` | no |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-10-18T22:48:42Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-switches` | operator | PASS | 2026-10-18T22:48:47Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-18T22:49:27Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-motion` | auto, takeover | PASS | 2026-10-18T22:50:03Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.check-lens` | auto, takeover | PASS | 2026-10-18T22:50:24Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-18T23:08:40Z | `c-20261018224638-4ca0` | no |
|
||||
| `setup.focus` | live, takeover | PASS | 2026-10-18T23:11:20Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.pacing` | auto | PASS | 2026-10-18T22:49:43Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-10-18T22:50:42Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-10-18T22:51:07Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-10-18T22:51:16Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.deadman` | auto | PASS | 2026-10-18T22:52:02Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-10-18T22:52:33Z | `c-20261018224638-4ca0` | no |
|
||||
| `motion.release` | auto, takeover | PASS | 2026-10-18T22:52:59Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-18T22:54:32Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-10-18T22:57:10Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-10-18T23:12:56Z | `c-20261018224638-4ca0` | no |
|
||||
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-18T22:57:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-10-18T23:09:51Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-10-18T23:13:40Z | `c-20261018224638-4ca0` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-18T23:14:18Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-10-18T22:57:47Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-10-18T22:58:00Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-10-18T22:58:14Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.frame-health` | auto | PASS | 2026-10-18T22:58:32Z | `c-20261018224638-4ca0` | no |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-10-18T22:58:48Z | `c-20261018224638-4ca0` | no |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-10-18T22:58:50Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-10-18T23:00:59Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-10-18T23:15:30Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-18T23:17:46Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-10-18T23:21:27Z | `c-20261018224638-4ca0` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-10-18T23:24:58Z | `c-20261018224638-4ca0` | no |
|
||||
| `exthost.platform` | auto | PASS | 2026-10-18T23:01:21Z | `c-20261018224638-4ca0` | no |
|
||||
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-18T23:03:09Z | `c-20261018224638-4ca0` | no |
|
||||
| `forgectrl.tls-records` | auto | PASS | 2026-10-18T23:03:15Z | `c-20261018224638-4ca0` | no |
|
||||
|
||||
Artifact sha256: `97018f73354c1b768b893f1538c43d8c506e06e8bd788ab4e194968910c4e5f1`
|
||||
@@ -0,0 +1,25 @@
|
||||
ForgeFIRM v0.0.9 is a beta release.
|
||||
|
||||
The setup is simpler, and it is safe to run again. On some machines, running First fire a second time, or after a power cycle, drove the head into the side of the machine. The setup sheet and its steps (Place the sheet, First fire, Dose curve, Corner rolloff, Flow under load) are gone. Focus and the optional Laser floor now each burn on their own small scrap in the back-left corner, starting from home against the gantry stops. A new Lens travel check measures the lens with the laser off. Only the first steps, through Intake fans and Coolant flow, are required. The rest are recommended, and Finish later takes you to the control panel.
|
||||
|
||||
Also fixed:
|
||||
|
||||
- Stopping mid-move (a reset from your Grbl sender, Abort in the control panel, or a cooling fault) now slows the head to a stop and keeps its position, so the machine stays homed.
|
||||
- A rare stream fault that could replay leftover motion, with the laser off, at the start of the next job now stops with an alarm.
|
||||
- The setup's burns no longer need the head's beam sensor, which reads nothing on some heads.
|
||||
- The setup no longer shows the head as "not detected".
|
||||
- The TEC check is gone. It could never pass, and it turned the TEC off on Pro machines. Your answer in the Your machine step now sets whether a TEC is fitted.
|
||||
- A setup question or wait that times out now ends its step with the reason and a way to run it again.
|
||||
- The coolant flow calibration no longer leaves its trial settings behind after an abort.
|
||||
- The logs now record which firmware runs at every start and every update, so a log export shows how a machine was updated.
|
||||
- Extensions: a rollback or an interrupted install no longer leaves an extension broken.
|
||||
|
||||
After you update:
|
||||
|
||||
- If your machine has not done the Intake fans or Coolant flow step, the setup asks for it, and the machine waits for it before it runs a job.
|
||||
- Pro owners who ran the setup on an earlier release: make sure Cooling protection > Coolant loop > TEC (Pro chiller) is on. The old TEC check turned it off.
|
||||
- If you ran the setup sheet on an earlier release and your jobs now land offset from where you placed them, send G92.1 once from your Grbl sender.
|
||||
|
||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||
|
||||
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||
@@ -31,7 +31,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
|
||||
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
||||
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side lifecycle harness of the operator-armed window (null-sink controller) ([Test](https://docs.forgefirm.org/developers/testing/)). Runs in the grblHAL repo's CI. |
|
||||
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. |
|
||||
| `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py <grblHAL_glowforge>`. |
|
||||
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
|
||||
|
||||
@@ -379,8 +379,8 @@ def test_sender_wins_queued(s):
|
||||
|
||||
|
||||
class NewlinePoller(threading.Thread):
|
||||
"""A sender that polls the way LightBurn does: '?' with an end of line
|
||||
behind it, on a timer. The '?' is a realtime character; the end of line
|
||||
"""A sender that polls '?' with an end of line behind it, on a timer,
|
||||
as some Grbl senders do. The '?' is a realtime character; the end of line
|
||||
is an empty line, which the core answers ok."""
|
||||
|
||||
def __init__(self, sender, period=0.25):
|
||||
@@ -402,10 +402,9 @@ class NewlinePoller(threading.Thread):
|
||||
|
||||
def poll_case(tag, eol):
|
||||
"""An empty line is not the sender speaking. With a sender polling
|
||||
'?'<eol> four times a second (LightBurn polls about twice a second): a
|
||||
port jog is never refused for it, a long port jog runs whole under it,
|
||||
and every poll still draws its one ok, during the jog too. A real line
|
||||
still goes first."""
|
||||
'?'<eol> four times a second: a port jog is never refused for it, a
|
||||
long port jog runs whole under it, and every poll still draws its one
|
||||
ok, during the jog too. A real line still goes first."""
|
||||
s = Session(eol=eol)
|
||||
try:
|
||||
s.sender.wait_state("Idle")
|
||||
|
||||
@@ -16,7 +16,10 @@ reported messages:
|
||||
2. program end (M2) closing the window, and the next job arming
|
||||
afresh: laser_stream_test session K
|
||||
3. a sender change closes the window: a reconnected client must
|
||||
re-arm - the displaced session's consent does not carry over
|
||||
re-arm - the displaced session's consent does not carry over; the
|
||||
disarm and the relock at the change are laser_arm_test.c case H,
|
||||
and a new sender's soft reset of the held job is the core's reset
|
||||
from a completed hold, session hold-reset-restart under rule 10
|
||||
4. the spindle-off grace counts down in HOLD, not only in Idle - a
|
||||
job abandoned in feed hold disarms after laser_disarm_s
|
||||
5. arming refused while the cooling verdict blocks fire, or before
|
||||
@@ -64,6 +67,17 @@ reported messages:
|
||||
cancels it from the hold - reason reported, the reset banner, no
|
||||
alarm, the return to the job start - and a later press resumes
|
||||
nothing
|
||||
17. a jog cancel (0x85) stops a jog short of its target, Idle; a ^X
|
||||
mid-move asks the kernel for its ramp (cnc/stop in GFSINK_ATTR_LOG)
|
||||
and lands in Alarm with the position kept: MPos is the X that the
|
||||
shipped stream (GFSINK_DUMP) moved, an unreferenced lens is still
|
||||
held after $X, and the head does not return to the job start; $X
|
||||
recovers to Idle and the next jog ships steps
|
||||
18. a ^X during the web-service homing session keeps the lens hold: the
|
||||
session's command is ended, the stream owes no stop, the reset lands
|
||||
in Alarm, and after $X a Z jog is still refused (error 15) with MPos
|
||||
Z unmoved, while a program Z move hits the pinned lens's soft limit
|
||||
(ALARM:2)
|
||||
|
||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||
@@ -214,9 +228,13 @@ class Session:
|
||||
"""One controller process with the lifecycle overrides applied."""
|
||||
|
||||
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, attr_log=False):
|
||||
self.name = name
|
||||
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
|
||||
# The sysfs writes the hardware would get, when the case reads them.
|
||||
self.attr_log = os.path.join(self.workdir, "attr.log") if attr_log else None
|
||||
conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf, "w") as f:
|
||||
# The fan grace at 0 keeps the arm's acknowledgment budget at its
|
||||
@@ -228,6 +246,12 @@ class Session:
|
||||
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
env.pop("GFSINK_DUMP", None)
|
||||
env.pop("GFSINK_ATTR_LOG", None)
|
||||
if self.dump:
|
||||
env["GFSINK_DUMP"] = self.dump
|
||||
if self.attr_log:
|
||||
env["GFSINK_ATTR_LOG"] = self.attr_log
|
||||
self.switch_file = None
|
||||
if switches is not None:
|
||||
self.switch_file = os.path.join(self.workdir, "switches")
|
||||
@@ -318,6 +342,39 @@ class Session:
|
||||
m = re.findall(r"MPos:(-?[\d.]+)", "".join(self.log[-4:]))
|
||||
return float(m[-1]) if m else None
|
||||
|
||||
def mpos_z(self):
|
||||
self.sock.sendall(b"?")
|
||||
read_avail(self.sock, self.log, 0.3)
|
||||
m = re.findall(r"MPos:-?[\d.]+,-?[\d.]+,(-?[\d.]+)", "".join(self.log[-4:]))
|
||||
return float(m[-1]) if m else None
|
||||
|
||||
def setting(self, n):
|
||||
"""A $-setting's value, from a $$ dump."""
|
||||
mark = len(self.log)
|
||||
send_line(self.sock, "$$", self.log)
|
||||
m = re.search(r"\$%d=(-?[\d.]+)" % n, "".join(self.log[mark:]))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
def attr_count(self, write):
|
||||
"""How many times the hardware would have been given `write`
|
||||
("<attr> <value>") so far."""
|
||||
try:
|
||||
with open(self.attr_log) as f:
|
||||
return sum(1 for line in f if line.strip() == write)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
def shipped_x_steps(self):
|
||||
"""The X steps the shipped stream moved, net: a tick byte (bit 7
|
||||
clear) with the X step bit (0x01), the X direction bit (0x02) set
|
||||
for -X."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum((-1 if b & 0x02 else 1) for b in data if not b & 0x80 and b & 0x01)
|
||||
|
||||
def armed_count(self):
|
||||
return "".join(self.log).count(ARMED)
|
||||
|
||||
@@ -868,6 +925,179 @@ def test_pause_then_lid_cancel_returns_to_the_job_start():
|
||||
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: the
|
||||
kernel is told to stop with its ramp, Alarm, the position kept, and
|
||||
no return to the job start (that move is the lid policy's alone). The
|
||||
position kept is the X that the shipped stream moved, and the lens,
|
||||
which has no reference here, is still held where it stands. $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, attr_log=True)
|
||||
try:
|
||||
spm = s.setting(100)
|
||||
if not spm:
|
||||
fail("[cancel-abort] no $100 in the settings dump")
|
||||
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 X120 F3000") # 2.4 s
|
||||
if not s.wait_state("Run", 5):
|
||||
fail("[cancel-abort] the move never reported Run (state %s)" % s.state())
|
||||
time.sleep(0.5)
|
||||
stops = s.attr_count("cnc/stop 1")
|
||||
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())
|
||||
if s.attr_count("cnc/stop 1") <= stops:
|
||||
fail("[cancel-abort] ^X mid-move did not ask the kernel to stop with its ramp "
|
||||
"(no cnc/stop write)")
|
||||
x2 = s.mpos_x()
|
||||
if x2 is None or not 0.5 < x2 - x1 < 119.5:
|
||||
fail("[cancel-abort] the abort did not keep a plausible position (X %s from %.3f)" % (x2, x1))
|
||||
moved = s.shipped_x_steps()
|
||||
if abs(x2 * spm - moved) > 0.5:
|
||||
fail("[cancel-abort] the position after the abort is not the X the stream moved "
|
||||
"(X %.3f is %.1f steps, the stream moved %d)" % (x2, x2 * spm, moved))
|
||||
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))
|
||||
# The lens has no reference in this session, so it is held where it
|
||||
# stands, and the abort keeps it held.
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[cancel-abort] a Z jog after the abort was not refused: the lens is no longer held")
|
||||
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 asked "
|
||||
"for the kernel's stop and landed in Alarm at %.3f mm, the X the stream moved, with no "
|
||||
"return and the lens still held; $X recovered to Idle and the next jog shipped %d bytes"
|
||||
% (x1 - x0, x2 - x1, grew))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
HOME_SESSION_CMD = "sleep 47.25"
|
||||
|
||||
|
||||
def home_sessions():
|
||||
"""How many processes of the web-service homing session's command are
|
||||
running (the command is one no other process on the host uses)."""
|
||||
r = subprocess.run(["pgrep", "-f", HOME_SESSION_CMD], capture_output=True, text=True)
|
||||
return len(r.stdout.split())
|
||||
|
||||
|
||||
def test_homing_abort_keeps_z_hold():
|
||||
"""Rule 18: a ^X during the web-service homing session keeps the lens
|
||||
hold. The session owns the device while it runs, so the reset reads
|
||||
nothing back and the position stays lost; the lens, unreferenced in
|
||||
this session, must still be held where it stands once $X has cleared
|
||||
the alarm. The session's command is ended by the reset, and the stream
|
||||
owes the kernel no stop. A program Z move then hits the pinned lens's
|
||||
soft limit."""
|
||||
s = Session("home-reset", disarm_s=60, attr_log=True,
|
||||
conf_extra="homing_mode = gfcloud\ngfcloud_home_cmd = %s\n" % HOME_SESSION_CMD)
|
||||
try:
|
||||
if home_sessions():
|
||||
fail("[home-reset] a homing session's command is already running on the host")
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] the lens is not held before the session: a Z jog was not refused")
|
||||
send_line(s.sock, "", s.log)
|
||||
z0 = s.mpos_z()
|
||||
if z0 is None:
|
||||
fail("[home-reset] no position in the status report")
|
||||
stops = s.attr_count("cnc/stop 1")
|
||||
s.send_raw("$H")
|
||||
if not s.wait_state("Home", 5):
|
||||
fail("[home-reset] $H never reported Home (state %s)" % s.state())
|
||||
time.sleep(0.5)
|
||||
if not home_sessions():
|
||||
fail("[home-reset] the homing session's command is not running")
|
||||
mark = len(s.log)
|
||||
s.sock.sendall(b"\x18")
|
||||
if not s.wait_state("Alarm", 8):
|
||||
fail("[home-reset] ^X during the homing session did not land in Alarm (state %s)"
|
||||
% s.state())
|
||||
read_avail(s.sock, s.log, 0.5)
|
||||
if "ALARM:6" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] the reset during homing did not raise ALARM:6")
|
||||
end = time.time() + 3
|
||||
while home_sessions() and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
if home_sessions():
|
||||
fail("[home-reset] the homing session's command outlived the reset")
|
||||
if s.attr_count("cnc/stop 1") != stops:
|
||||
fail("[home-reset] the reset during the session wrote cnc/stop: the stream owed no stop")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[home-reset] $X did not recover to Idle (state %s)" % s.state())
|
||||
mark = len(s.log)
|
||||
if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \
|
||||
"error:15" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] a Z jog after the reset was not refused: the lens hold was dropped")
|
||||
time.sleep(0.6)
|
||||
z1 = s.mpos_z()
|
||||
if z1 is None or abs(z1 - z0) > 0.001:
|
||||
fail("[home-reset] the lens moved after the reset (Z %s, was %.3f)" % (z1, z0))
|
||||
send_line(s.sock, "", s.log)
|
||||
mark = len(s.log)
|
||||
s.send_raw("G91 G1 Z1 F100")
|
||||
if read_avail(s.sock, s.log, 5.0, until=("ALARM:", "ok")) != "ALARM:" or \
|
||||
"ALARM:2" not in "".join(s.log[mark:]):
|
||||
fail("[home-reset] a program Z move did not hit the pinned lens's soft limit (ALARM:2)")
|
||||
# The soft-limit alarm asks for a reset before $X unlocks it: one
|
||||
# sent once a status report has come from the alarm's wait. A ^X
|
||||
# on the heels of the ALARM line lands while the soft limit's own
|
||||
# reset is still pending and is folded into it. The '?' polls keep
|
||||
# the controller's output flowing while it waits for the banner.
|
||||
if not s.wait_state("Alarm", 5):
|
||||
fail("[home-reset] no status report from the soft-limit alarm (state %s)" % s.state())
|
||||
banners = s.count("GrblHAL")
|
||||
s.sock.sendall(b"\x18")
|
||||
end = time.time() + 5
|
||||
while s.count("GrblHAL") <= banners and time.time() < end:
|
||||
s.state()
|
||||
if s.count("GrblHAL") <= banners:
|
||||
fail("[home-reset] no reset banner after the ^X that clears the soft limit")
|
||||
send_line(s.sock, "$X", s.log)
|
||||
if not s.wait_state("Idle", 5):
|
||||
fail("[home-reset] $X after the soft limit did not recover to Idle (state %s)" % s.state())
|
||||
print("PASS [home-reset]: ^X during the homing session ended its command, wrote no "
|
||||
"cnc/stop, landed in ALARM:6; after $X the Z jog was refused with Z at %.3f and "
|
||||
"a program Z move raised ALARM:2" % z1)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_lid_cancel_from_hold():
|
||||
"""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
|
||||
@@ -1008,6 +1238,8 @@ def main():
|
||||
test_button_pause_resume()
|
||||
test_lid_cancels_and_returns()
|
||||
test_pause_then_lid_cancel_returns_to_the_job_start()
|
||||
test_cancel_and_abort()
|
||||
test_homing_abort_keeps_z_hold()
|
||||
test_lid_cancel_from_hold()
|
||||
test_lid_policy_hold()
|
||||
test_sigterm_mid_job()
|
||||
|
||||
@@ -24,7 +24,10 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
the stream must never lean on the kernel's end-of-data backstop
|
||||
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
|
||||
bytes carrying FIRE longer than any legitimate between-step
|
||||
interval is a stationary dwell burn
|
||||
interval is a stationary dwell burn, and so is a FIRE tick deeper
|
||||
into a stepless run than that interval, however it is spaced (the
|
||||
density dither masks a stale fire state into bursts split by dark
|
||||
ticks, so FIRE continuity says nothing under it; depth does)
|
||||
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
|
||||
shaped jobs), where the FIRE state of the previous cycle must not
|
||||
leak into the idle-gap pad bytes
|
||||
@@ -36,8 +39,9 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
rung's opening ticks carry the previous rung's duty
|
||||
11. under the density dose model no level ever reaches PWMSAR: every
|
||||
power byte carries full duty (one still leads each kernel run)
|
||||
12. density matches the level the core commanded, rung by rung, and no
|
||||
burst is longer than the base period
|
||||
12. density matches the level the core commanded, rung by rung, each
|
||||
rung lit for its whole length, and no burst is longer than the base
|
||||
period
|
||||
13. the model is a mask and never a source: run the same job under both
|
||||
models and every FIRE tick of the density run is a FIRE tick of the
|
||||
analog run, on an identical motion grid
|
||||
@@ -49,7 +53,9 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
next run: a standalone S word between moves, from a sender slow
|
||||
enough to drain the planner, must still cut at the level it asked
|
||||
for rather than dark or at a stale level (judged under the shipped
|
||||
density model, where the level is the FIRE density)
|
||||
density model, where the level is the FIRE density), and over the
|
||||
whole move: a level applied late shows as a lit span shorter than
|
||||
the move
|
||||
16. and the off transition survives the same way: an M5 executed with
|
||||
the planner drained and the kernel run over must darken the rapids
|
||||
that follow it, and a bare G0 sent with the spindle off must ship
|
||||
@@ -76,9 +82,11 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
is 0 (the floor, not $31, sets the bottom of the S range) and $35
|
||||
is the board's floor, landing at PWMSAR_FLOOR_MIN or above
|
||||
19. the dose curve's mapping of S onto delivered light is
|
||||
laser_arm_test.c case P's; every session here but the rolloff ones
|
||||
runs with laser_dose_curve = off so its S-to-level arithmetic stays
|
||||
exact
|
||||
laser_arm_test.c case P's, and the rolloff reference run (rule 23)
|
||||
pins one point of it end to end: the S300 cruise renders the density
|
||||
the curve maps 30 % light onto; every laser session but the rolloff
|
||||
ones (rules 20 and 23) and the jog run (rule 22) runs with
|
||||
laser_dose_curve = off so its S-to-level arithmetic stays exact
|
||||
20. the corner rolloff starves the slow spots: under M4 with the curve
|
||||
in force, the accelerate-in head of a line renders less density at
|
||||
the default gamma of 2 than at gamma 1, and the cruise middle
|
||||
@@ -256,12 +264,16 @@ CHURN_BUDGET_S = (CHURN_MOTION_S + CHURN_GAPS_S) * 1.5 + 0.2
|
||||
LADDER_S = (20, 30, 60, 120, 200, 300)
|
||||
LADDER_DUTY = tuple(duty_for(s) for s in LADDER_S)
|
||||
LADDER_MM = 5.0
|
||||
LADDER_FEED = 300
|
||||
JOB_LADDER = ["G91", "G21", "M3"]
|
||||
for _i, _s in enumerate(LADDER_S):
|
||||
JOB_LADDER.append("S%d" % _s)
|
||||
JOB_LADDER.append("G1 X%g F300" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM))
|
||||
JOB_LADDER.append("G1 X%g F%d" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM,
|
||||
LADDER_FEED))
|
||||
JOB_LADDER.append("G0 Y1")
|
||||
JOB_LADDER.append("M5")
|
||||
# One rung's length in machine ticks: the span each rung must stay lit for.
|
||||
LADDER_TICKS = LADDER_MM / (LADDER_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
|
||||
|
||||
# Sessions E-G: the density dose model. $35 = 0 for the ladder because
|
||||
@@ -299,6 +311,13 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
|
||||
"laser_dose_curve = off\n" + DENSITY_CONF_BASE)
|
||||
# The shipped default: the bench curve in force (no keys at all).
|
||||
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
|
||||
# Rule 19's end-to-end point: 30 % light (S300) through the bench-default
|
||||
# curve (glowforge_laser.c curve_default) lands between its (45 % density,
|
||||
# 21 % light) and (60 %, 37 %) points, 45 + 9/16 x 15 = 53.44 % density,
|
||||
# level 68 of 127. Mirrored here the way the floor is: changing the curve
|
||||
# changes this expectation.
|
||||
CURVE_S300_LEVEL = 68
|
||||
CURVE_S300_DENSITY = CURVE_S300_LEVEL / float(PWM_PERIOD)
|
||||
|
||||
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
||||
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
||||
@@ -324,6 +343,8 @@ for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||
IDLE_S_FEED))
|
||||
JOB_IDLE_S.append("M5")
|
||||
# One move's length in machine ticks: the span each level must stay lit for.
|
||||
IDLE_S_TICKS = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
|
||||
|
||||
|
||||
# Session J: the bench ladder's shape. M5 executes with the planner
|
||||
@@ -692,19 +713,47 @@ def tick_bytes(data):
|
||||
|
||||
|
||||
def check_fire_gaps(name, data):
|
||||
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
|
||||
run = 0
|
||||
"""Rule 8, read both ways so it holds under either power model: no
|
||||
stepless run carrying FIRE on every tick for the limit (a stale fire
|
||||
state as the analog model renders it), and no FIRE tick farther than
|
||||
the limit from every step, the one before it and the one after it
|
||||
(the same state as the density model renders it: the dither masks it
|
||||
into bursts shorter than the limit, split by dark ticks, so the first
|
||||
reading never trips). A cut's first FIRE tick leads its first step by
|
||||
the accel-from-rest interval and its last trails its last step by
|
||||
less, both inside the limit; a FIRE tick with no step that near on
|
||||
either side is in a gap, and lit."""
|
||||
ticks = tick_bytes(data)
|
||||
n = len(ticks)
|
||||
# Ticks from each tick to the next step tick at or after it.
|
||||
ahead = [n] * n
|
||||
nxt = None
|
||||
for i in range(n - 1, -1, -1):
|
||||
if ticks[i] & 0x25: # an X/Y/Z step
|
||||
nxt = i
|
||||
if nxt is not None:
|
||||
ahead[i] = nxt - i
|
||||
run = 0 # consecutive stepless ticks carrying FIRE
|
||||
since = n # ticks since the last step
|
||||
worst = 0
|
||||
for tick, b in enumerate(tick_bytes(data)):
|
||||
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
|
||||
for tick, b in enumerate(ticks):
|
||||
if b & 0x25:
|
||||
run = since = 0
|
||||
continue
|
||||
since += 1
|
||||
if not b & 0x10:
|
||||
run = 0
|
||||
continue
|
||||
run += 1
|
||||
worst = max(worst, run)
|
||||
if run >= FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE carried across a %d-tick zero-step gap "
|
||||
"ending at tick %d (stationary dwell burn)"
|
||||
% (name, run, tick))
|
||||
else:
|
||||
run = 0
|
||||
if since > FIRE_GAP_LIMIT_TICKS and ahead[tick] > FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE %d ticks after a step and %d before the next, "
|
||||
"at tick %d (stationary dwell burn, masked by the dither)"
|
||||
% (name, since, ahead[tick], tick))
|
||||
return worst
|
||||
|
||||
|
||||
@@ -860,8 +909,9 @@ def fire_spans(ticks, gap=RUNG_SPLIT_TICKS):
|
||||
return spans
|
||||
|
||||
|
||||
def check_density(name, data, levels, period, min_ticks):
|
||||
"""Rules 11-12: pinned duty, and density per rung matching the level."""
|
||||
def check_density(name, data, levels, period, min_ticks, move_ticks=None):
|
||||
"""Rules 11-12: pinned duty, density per rung matching the level, and
|
||||
with move_ticks given, each rung's span the whole move long."""
|
||||
# A power byte still leads every kernel run - the run start resets the
|
||||
# hardware duty - but under this model it only ever carries full duty:
|
||||
# the level rides the FIRE bits, never PWMSAR.
|
||||
@@ -878,6 +928,14 @@ def check_density(name, data, levels, period, min_ticks):
|
||||
|
||||
out = []
|
||||
for (a, b), level in zip(spans, levels):
|
||||
# The span is the whole move: a level applied late renders the
|
||||
# right density over a shorter span, which the density alone
|
||||
# cannot see. The lowest levels open and close a few periods of
|
||||
# carried debt inside the move, well under the margin.
|
||||
if move_ticks is not None and not 0.9 * move_ticks <= b - a <= 1.1 * move_ticks:
|
||||
fail("[%s] level %d fire span of %d ticks, expected ~%d (the whole "
|
||||
"move): the move ran dark before the level was applied, or "
|
||||
"FIRE outlived it" % (name, level, b - a, move_ticks))
|
||||
seg = ticks[a:b]
|
||||
got = sum(1 for t in seg if t & 0x10) / float(len(seg))
|
||||
want = level / float(PWM_PERIOD)
|
||||
@@ -987,7 +1045,7 @@ def main():
|
||||
# the arm loads into $35.
|
||||
dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF)
|
||||
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD_TICKS,
|
||||
DENSITY_MIN_MACHINE_TICKS)
|
||||
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
|
||||
check_termination("density", dens)
|
||||
if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
|
||||
fail("[density] the arm did not select the density model at floor 0")
|
||||
@@ -1043,7 +1101,7 @@ def main():
|
||||
# the one before it, and the stream ends dark.
|
||||
idle_s = run_session("idle-s", JOB_IDLE_S, conf=DENSITY_CONF_FLOORED)
|
||||
got = check_density("idle-s", idle_s, tuple(duty_for(l) for l in IDLE_S_LEVELS),
|
||||
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS)
|
||||
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS, IDLE_S_TICKS)
|
||||
check_termination("idle-s", idle_s)
|
||||
print("PASS [idle-s]: standalone S across idle gaps -> densities %s, each its own level"
|
||||
% [d for _l, d in got])
|
||||
@@ -1084,7 +1142,7 @@ def main():
|
||||
text = run_session.text
|
||||
expect_levels = tuple(duty_for(x) for x in LADDER_S)
|
||||
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD_TICKS,
|
||||
DENSITY_MIN_MACHINE_TICKS)
|
||||
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
|
||||
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in text:
|
||||
fail("[floor-derived] the arm report does not name the derived floor "
|
||||
"(text: %r)" % text[-400:])
|
||||
@@ -1482,6 +1540,12 @@ def main():
|
||||
a, b = rs[0]
|
||||
mid = rt[(a + b) // 2 - 1000:(a + b) // 2 + 1000]
|
||||
dens_ref = sum(1 for t in mid if t & 0x10) / float(len(mid))
|
||||
# Rule 19: the reference cut is one M4 line at a known S under the
|
||||
# shipped curve, so its cruise (velocity ratio 1, no rolloff) pins the
|
||||
# curve's rendering end to end.
|
||||
if abs(dens_ref - CURVE_S300_DENSITY) > 0.02:
|
||||
fail("[rolloff-ref] the S300 cruise renders %.3f, expected %.3f through the "
|
||||
"bench-default curve" % (dens_ref, CURVE_S300_DENSITY))
|
||||
tt = tick_bytes(two)
|
||||
ts = fire_spans(tt)
|
||||
if len(ts) != 1:
|
||||
|
||||
+11
-20
@@ -241,18 +241,14 @@ else
|
||||
fi
|
||||
|
||||
# Root policy gate. The release image ships root WITHOUT a password (the
|
||||
# serial console is the recovery path) and sshd refuses root and empty
|
||||
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be
|
||||
# active (uncommented) in the built sshd_config, and root's shadow field
|
||||
# must be empty. Read the built files, not the recipes: this catches a
|
||||
# drift however it got in (recipe, local.conf, an inherited class).
|
||||
# serial console is the recovery path): root's shadow field must be empty.
|
||||
# sshd's refusal of root and of empty passwords is the rootfs lint's
|
||||
# (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails
|
||||
# the build. Read the built file, not the recipes: this catches a drift
|
||||
# however it got in (recipe, local.conf, an inherited class).
|
||||
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
|
||||
[ -n "$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 \
|
||||
| awk -F: '$1=="root"{print; exit}')
|
||||
[ -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}')
|
||||
[ -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"
|
||||
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
|
||||
# line of the built fstab carries ro, the rcS default agrees (the
|
||||
# read-only-rootfs image feature), no factory-slot mount is in the
|
||||
# release fstab (those belong to the dev image), and sshd keeps its host
|
||||
# keys on /data, where the read-only rootfs cannot hold them.
|
||||
# line of the built fstab carries ro, and sshd keeps its host keys on
|
||||
# /data, where the read-only rootfs cannot hold them. rcS's
|
||||
# ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs
|
||||
# lint's.
|
||||
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
|
||||
[ -n "$FSTAB" ] \
|
||||
|| die "release rootfs carries no /etc/fstab"
|
||||
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
|
||||
| grep -Eq '(^|,)ro(,|$)' \
|
||||
|| 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 ] \
|
||||
|| 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,
|
||||
# where it would apply to every target including the release image.
|
||||
|
||||
Reference in New Issue
Block a user