diff --git a/forgetest/forgetest/suite/extcat.py b/forgetest/forgetest/suite/extcat.py index d90bcd3..43b69c6 100644 --- a/forgetest/forgetest/suite/extcat.py +++ b/forgetest/forgetest/suite/extcat.py @@ -22,7 +22,6 @@ import tempfile from ..catalog import test from .exthost import FWUP, _forgeext, _write -from .setup import request INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions/releases/latest/download/index.ffi" STAGE_DIR = "/data/forgefirm/tmp" @@ -83,11 +82,12 @@ def _staged(): "index (signed by a key that is not the OpenGlow extension key) is refused in words, a package " "handed over as an index is refused by the product gate, and the index kept is left as it was. " "POST /ext/catalog/get refuses an id with no such form (400), and one the kept index does not " - "list (404, or 409 with no index kept), before anything is fetched. POST /ext/catalog/refresh " - "asks the fixed https:// address: it keeps OpenGlow's index when one is published there, and " - "when none is, it is 502 in curl's words with the kept index left as it was. Nothing is left in " - "the staging directory. The fetch of a listed package, held to its size and SHA-256, and the " - "tiers an install takes from it, are forgectrl's extpkg_test and forgeext's install_test.") + "list (404, or 409 with no index kept), before anything is fetched. Nothing is left in the " + "staging directory. POST /ext/catalog/refresh is not asked here: GitHub counts every request " + "of the index's address as a download, and that count is the operators'. The refresh (curl, " + "https alone and bounded, 502 in curl's words, 409 in the host's, the file removed), the fetch " + "of a listed package held to its size and SHA-256, and the tiers an install takes from it, " + "are forgectrl's extpkg_test and forgeext's install_test.") def catalog(ctx): fc = ctx.forgectrl ev = ctx.evidence @@ -150,21 +150,7 @@ def catalog(ctx): want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first") ev["get_unlisted"] = [st, why] ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why) - - # The fetch from the one address. - st, body, _h = request(fc.base, "POST", "/ext/catalog/refresh", data={}, - headers={"X-ForgeFIRM-Token": fc.token, "Host": fc.host_header()}, timeout=90) - text = body.decode("utf-8", "replace") - ev["refresh"] = [st, text[:400]] - ctx.log("POST /ext/catalog/refresh -> %s %s", st, text[:200]) - if st == 200: - doc = json.loads(text) - ctx.check(isinstance(doc.get("index"), dict) and doc["index"].get("version"), - "a kept index has its version: %s", text[:200]) - else: - ctx.check(st == 502 and "could not be fetched" in text, "a fetch that fails is 502 in curl's words: %s %s", - st, text[:200]) - ctx.check(_forgeext("index").get("index") == kept.get("index"), "a failed fetch changed the index kept") + ctx.check(_forgeext("index").get("index") == kept.get("index"), "the refusals changed the index kept") ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before) finally: shutil.rmtree(work, ignore_errors=True) diff --git a/forgetest/forgetest/suite/updlock.py b/forgetest/forgetest/suite/updlock.py index e4bb9cb..9ab0e44 100644 --- a/forgetest/forgetest/suite/updlock.py +++ b/forgetest/forgetest/suite/updlock.py @@ -5,10 +5,12 @@ """An update job holds the machine, on the machine. -Its own module, so that no other test's fingerprint moves. The job is the -download of the published release: it fetches the firmware archive and -checks its signature, and applies nothing. The archive it leaves is -removed when it was not there before. +Its own module, so that no other test's fingerprint moves. The job is a +probe download of the published release: the download job itself, run on +the release's acceptance record instead of its firmware file, because +GitHub counts every request of the firmware file as an install. The +verification refuses the record and the probe keeps nothing; a download +the machine had staged is left as it was. """ import os @@ -17,19 +19,33 @@ from ..catalog import test from .motion import _job_post, _words DL_FW = "/data/forgefirm/download.fw" +DL_PROBE = "/data/forgefirm/download-probe" +HELD = "an update job (download) holds the machine" + + +def _as_found(path): + try: + st = os.stat(path) + return [st.st_size, st.st_mtime_ns] + except OSError: + return None @test("update.job-locks", title="An update job holds the machine: the settings are locked while it runs", - subsystem="update", kind="auto", est_min=3, - covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/lease.*")], + subsystem="update", kind="auto", est_min=2, + covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/relcheck.*"), ("forgectrl", "src/lease.*")], requires=["update.release-check"], - description="With a release published on the releases API, POST /update/download starts the download " - "job (202), which fetches the release's firmware archive and checks its signature and applies " - "nothing. While it runs, /status names the job (update:download, of kind system) as the " - "machine lease's holder, and a settings write and a posted job are each refused in its name " - "(409, the settings locked). When it ends, the lease is free and the same settings write is " - "taken. The downloaded archive is removed when it was not there before. That a log export " - "does not lock the settings while every other holder does is lease_test's.") + description="With a release published on the releases API, POST /update/download?probe=1 starts the " + "download job (202) on the release's acceptance record in place of its firmware file, so " + "the test adds nothing to the firmware file's download count. The machine lease is taken " + "before the 202: a settings write and a posted job right after it are each refused in the " + "job's name (409, an update job (download) holds the machine, the settings locked), and " + "/status names the job (update:download, of kind system) as the holder while it runs. The " + "job fetches the record through the download's own path and ends refused by the signature " + "check with the record discarded. When it ends, the lease is free and the same settings " + "write is taken. A download the machine had staged is untouched and the probe leaves no " + "file. That a log export does not lock the settings while every other holder does is " + "lease_test's; that a probe never names the firmware file is relcheck_test's.") def job_locks(ctx): fc = ctx.forgectrl ev = ctx.evidence @@ -39,9 +55,11 @@ def job_locks(ctx): # the kept answer does not outlive a restart of the daemon: ask now st, rel = fc.post("/update/check") ctx.log("the release was not checked since the daemon started: POST /update/check -> %s", st) - ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version", "bytes")} if isinstance(rel, dict) else rel - ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to download: %s", rel) - had = os.path.exists(DL_FW) + ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version")} if isinstance(rel, dict) else rel + ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to probe: %s", rel) + staged = _as_found(DL_FW) + ev["staged_download"] = staged + ctx.check(_as_found(DL_PROBE) is None, "a probe's file is left over from before: %s", DL_PROBE) units = fc.settings().get("ui_units") or "metric" def holder(): @@ -52,37 +70,52 @@ def job_locks(ctx): return j if isinstance(j, dict) else {} try: - st, body = fc.post("/update/download") - ctx.check(st == 202, "POST /update/download -> %s %s", st, _words(body)[:200]) - ctx.wait_for(lambda: holder().get("owner") == "update:download" or None, 20, poll=0.2) - held = holder() - ev["holder"] = held - ctx.log("the lease while the download runs: %s", held) - ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system", - "the download job does not hold the machine: %s", held) - refused = {} - st, body = fc.post("/settings", params={"ui_units": units}) - refused["a settings write"] = [st, _words(body)[:200]] - st2, body2 = _job_post(fc, "G21\n", name="beside-an-update") - refused["a posted job"] = [st2, _words(body2)[:200]] + # The record is small, so the job can end within a second. The + # lease is taken before the 202, so the settings write goes first + # and at once: it is the same value, so a probe that ended before + # it only costs another probe. The posted job goes only while the + # settings are refused in the job's name. + refused = None + for attempt in (1, 2, 3): + st, body = fc.post("/update/download", params={"probe": "1"}) + ctx.check(st == 202, "POST /update/download probe=1 -> %s %s", st, _words(body)[:200]) + st, body = fc.post("/settings", params={"ui_units": units}) + if st == 200: + ctx.wait_for(lambda: job().get("running") is False or None, 120, poll=0.5) + result = job().get("result") or {} + ctx.log("probe %d ended before the settings write was read (%s); again", attempt, result) + ctx.check("signature verification failed" in str(result.get("error")), + "the probe ended, and not refused by the signature check: %s", result) + continue + st2, body2 = _job_post(fc, "G21\n", name="beside-an-update") + held = holder() + refused = {"a settings write": [st, _words(body)[:200]], "a posted job": [st2, _words(body2)[:200]]} + break + ctx.check(refused is not None, "three probes each ended before a settings write could be refused") ev["refused"] = refused - ctx.log("beside the download: %s", refused) - ctx.check(job().get("running") is True, "the download ended before the refusals were read: %s", job()) - ctx.check(st == 409 and "settings are locked" in _words(body), "a settings write beside the download -> %s %s", - st, _words(body)[:200]) - ctx.check(st2 == 409 and "holds the machine" in _words(body2), "a job beside the download -> %s %s", - st2, _words(body2)[:200]) - ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0) + ev["holder"] = held + ctx.log("beside the probe: %s; the lease: %s", refused, held) + ctx.check(st == 409 and HELD in _words(body) and "settings are locked" in _words(body), + "a settings write beside the probe -> %s %s", st, _words(body)[:200]) + ctx.check(st2 == 409 and HELD in _words(body2), "a job beside the probe -> %s %s", st2, _words(body2)[:200]) + if held: + ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system", + "the probe does not hold the machine as the download job: %s", held) + else: + ctx.log("the probe had ended before /status was read; its refusals name it") + ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=1.0) done = job() ev["job"] = done - ctx.log("the download ended: %s", done) - ctx.check(done and (done.get("result") or {}).get("ok") is True, "the download did not end well: %s", done) + ctx.log("the probe ended: %s", done) + result = done.get("result") or {} + ctx.check(result.get("ok") is False and "signature verification failed" in str(result.get("error")), + "the probe did not end refused by the signature check (the record fetched and discarded): %s", + result) ctx.check(not holder(), "the lease was not given back: %s", holder()) st, body = fc.post("/settings", params={"ui_units": units}) ctx.check(st == 200, "the same settings write after the job -> %s %s", st, _words(body)[:200]) finally: - # The job runs on after a check that failed: its archive lands when it ends. + # The job runs on after a check that failed: its end is waited for. ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0) - if not had and os.path.exists(DL_FW): - os.remove(DL_FW) - ctx.log("removed the downloaded archive") + ctx.check(_as_found(DL_FW) == staged, "the staged download moved: %s, and %s before", _as_found(DL_FW), staged) + ctx.check(_as_found(DL_PROBE) is None, "the probe left its file: %s", DL_PROBE)