From 0e37b0812e0b243b46626a34ad4b4715b4a9d81a Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 2 Sep 2026 08:00:51 -0400 Subject: [PATCH] acceptance: a later FAIL blocks inheritance, ffboot is a component, the units fallback is valid The inheritance walk skipped every record that was not a PASS on the current fingerprint, so a FAIL or ERROR recorded after a PASS on the same image was stepped over and the older PASS inherited into the next campaign. The newest record on the fingerprint now decides: a PASS is inherited, a FAIL or ERROR blocks it (reason failed-since), an ABORTED run says nothing. Unit tests for all three orders. ffboot, the tool that rewrites the boot environment on every install and slot switch, was packaged from scripts/ outside every fingerprint. It now lives in the recipe's files and the recipe inherits the manifest class; the tree manifest tool fingerprints file components the same way, and the update tests cover the component. forgectrl.settings-bounds fell back to ui_units=mm, which the whitelist refuses, so the always-required test failed on a fresh machine; the fallback is metric. --- forgetest/forgetest/campaign.py | 19 +++++++++-- forgetest/forgetest/suite/forgectrl.py | 2 +- forgetest/forgetest/suite/update.py | 3 +- forgetest/tests/test_campaign.py | 33 +++++++++++++++++++ .../recipes-forgefirm/ffboot/ffboot.bb | 10 ++++-- .../recipes-forgefirm/ffboot/files}/ffboot | 0 scripts/manifest-from-tree.py | 33 +++++++++++++++++++ 7 files changed, 92 insertions(+), 8 deletions(-) rename {scripts => meta-forgefirm/recipes-forgefirm/ffboot/files}/ffboot (100%) diff --git a/forgetest/forgetest/campaign.py b/forgetest/forgetest/campaign.py index 14188ff..6514693 100644 --- a/forgetest/forgetest/campaign.py +++ b/forgetest/forgetest/campaign.py @@ -96,16 +96,29 @@ def compute(records, tests, manifest, catalog_hash, running=None): elif t.always: reason = "always" else: + # The newest record on the current fingerprint decides: a PASS + # is inherited, a FAIL or ERROR after it blocks the inheritance + # (the test has to be run again), an ABORTED run says nothing. inh = None + blocked = None for r in reversed(hist): - if r.get("result") != PASS or r.get("fingerprint") != fp: + if r.get("fingerprint") != fp: continue if epoch and (r.get("ts") or "") <= epoch: continue - inh = r - break + res = r.get("result") + if res == PASS: + inh = r + break + if res in (FAIL, ERROR): + blocked = r + break if inh is not None: status, satisfied, origin, reason = "inherited", True, inh, "inherited" + elif blocked is not None and any(r.get("result") == PASS and r.get("fingerprint") == fp + and (r.get("ts") or "") < (blocked.get("ts") or "") + for r in hist): + reason = "failed-since" else: reason = "domain-changed" if any(r.get("result") == PASS for r in hist) else "never-passed" if not satisfied and last_r is not None: diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index d1c8044..371d666 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -165,7 +165,7 @@ def settings_bounds(ctx): key = k break if key is None: - key, val = "ui_units", "mm" + key, val = "ui_units", "metric" ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val) else: val = before[key] diff --git a/forgetest/forgetest/suite/update.py b/forgetest/forgetest/suite/update.py index bfc250a..8e327c6 100644 --- a/forgetest/forgetest/suite/update.py +++ b/forgetest/forgetest/suite/update.py @@ -5,7 +5,8 @@ import tempfile from ..catalog import test from .. import hw -_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h")] +_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"), + ("ffboot", "**")] @test("update.slots-and-signature", title="Boot slots readable, unsigned/tampered archives refused", diff --git a/forgetest/tests/test_campaign.py b/forgetest/tests/test_campaign.py index ed14cf8..d8b5195 100644 --- a/forgetest/tests/test_campaign.py +++ b/forgetest/tests/test_campaign.py @@ -65,6 +65,39 @@ class CampaignTests(unittest.TestCase): self.assertEqual(st["tests"][self.cool.id]["status"], "fail") self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed") + def test_a_fail_after_a_pass_blocks_inheritance(self): + # The ui test passes in c1, then fails on the same image (an + # intermittent gate): the FAIL closes c1. In c2 the older PASS must + # not be inherited over the newer FAIL: the test is required again. + recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"), + rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"), + rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:02:00Z"), + rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")] + st = self.compute(recs) + t = st["tests"][self.ui.id] + self.assertEqual(t["status"], "fail") + self.assertEqual(t["reason"], "failed-since") + self.assertTrue(t["required"]) + self.assertFalse(t["satisfied"]) + self.assertFalse(st["authorized"]) + + def test_an_abort_after_a_pass_does_not_block_inheritance(self): + recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"), + rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"), + rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:02:00Z"), + rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")] + st = self.compute(recs) + self.assertEqual(st["tests"][self.ui.id]["status"], "inherited") + + def test_a_pass_after_a_fail_is_inherited(self): + recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"), + rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:01:00Z"), + rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z"), + rec_result("c2", self.ui, self.man, "PASS", "2026-08-20T11:01:00Z"), + rec_campaign("c3", self.man, self.chash, "2026-08-20T12:00:00Z")] + st = self.compute(recs) + self.assertEqual(st["tests"][self.ui.id]["status"], "inherited") + def test_error_closes_aborted_does_not(self): recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"), rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:01:00Z")] diff --git a/meta-forgefirm/recipes-forgefirm/ffboot/ffboot.bb b/meta-forgefirm/recipes-forgefirm/ffboot/ffboot.bb index a932e99..14bae61 100644 --- a/meta-forgefirm/recipes-forgefirm/ffboot/ffboot.bb +++ b/meta-forgefirm/recipes-forgefirm/ffboot/ffboot.bb @@ -6,9 +6,13 @@ layout." LICENSE = "MIT" LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" -# ffboot's canonical home is scripts/ffboot in this repo (the factory-side -# installer downloads it from there); the recipe packages that same file. -FILESEXTRAPATHS:prepend := "${THISDIR}/../../../scripts:" +# ffboot rewrites the boot environment on every install and slot switch, +# so it is a fingerprinted component like the daemons: the manifest entry +# records the two files, and the acceptance tests that cover ffboot are +# invalidated when either moves (the installer copies the tool out of the +# rootfs it just wrote). +inherit forgefirm-manifest +FORGEFIRM_MANIFEST_SRC = "${THISDIR}/files" SRC_URI = " \ file://ffboot \ diff --git a/scripts/ffboot b/meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot similarity index 100% rename from scripts/ffboot rename to meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot diff --git a/scripts/manifest-from-tree.py b/scripts/manifest-from-tree.py index 1d0f17f..9cd0506 100644 --- a/scripts/manifest-from-tree.py +++ b/scripts/manifest-from-tree.py @@ -43,6 +43,11 @@ RECIPES = [ ("python3-gfhardware", "meta-glowforge-bsp/recipes-devtools/python/python3-gfhardware.bb", "meta-openglow"), ("python3-gfutilities", "meta-openglow-core/recipes-devtools/python/python3-gfutilities_git.bb", "meta-openglow"), ] +# Components built from files in a layer (FORGEFIRM_MANIFEST_SRC in the +# recipe): (component, directory relative to the repo, layer, recipe). +FILE_COMPONENTS = [ + ("ffboot", "meta-forgefirm/recipes-forgefirm/ffboot/files", "forgefirm", "ffboot.bb"), +] CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"), "meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"), "meta-openglow-core": ("meta-openglow", "meta-openglow-core")} @@ -138,6 +143,29 @@ def ls_tree(repo, rev, url, cache, prefix, files): ls_tree(sub_repo, obj, sub_url, cache, prefix + path + "/", files) +def dir_files(path): + """[path, blob-id] per regular file under path, relative paths, the + same walk and hashing as forgefirm-manifest.bbclass (hash-object, so + the ids compare with tree ids).""" + paths = [] + for root, dirs, fns in os.walk(path): + dirs[:] = sorted(x for x in dirs if x not in (".git", "__pycache__")) + for fn in fns: + if fn.endswith((".pyc", ".pyo")): + continue + p = os.path.join(root, fn) + if os.path.isfile(p) and not os.path.islink(p): + paths.append(os.path.relpath(p, path).replace(os.sep, "/")) + paths.sort() + if not paths: + return [] + # absolute paths: hash-object --stdin-paths resolves relative ones against + # the enclosing repository's top level, not the cwd + ids = git(["hash-object", "--stdin-paths"], cwd=path, + input=(chr(10).join(os.path.join(path, p) for p in paths) + chr(10)).encode()).decode().split() + return [[p, i] for p, i in zip(paths, ids)] + + def layer_content(path): out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], cwd=path) paths = sorted(set(p.decode("utf-8", "replace") for p in out.split(b"\0") if p)) @@ -176,6 +204,11 @@ def main(argv=None): files.sort() components[name] = {"srcrev": rev, "source": url, "files": files, "recipes": [os.path.basename(rel)]} print("%s: %s (%d files)" % (name, rev[:12], len(files)), file=sys.stderr) + for name, rel, layer, recipe in FILE_COMPONENTS: + base = REPO if layer == "forgefirm" else args.meta_openglow + files = dir_files(os.path.join(base, rel)) + components[name] = {"srcrev": None, "source": "files", "files": files, "recipes": [recipe]} + print("%s: files (%d files)" % (name, len(files)), file=sys.stderr) ksrc = args.kernel_srcrev if not ksrc: