mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Catalog: the report channel, the event stream and what-changed leave
Three catalog tests leave for the host proofs that now carry them; the catalog goes from 106 to 103. - cooling.report-channel. forgectrl's tests/host_routes.sh (e69d967) runs the channel against a live daemon: a run report with the controller's secret writes exhaust 65535; no secret, a wrong secret and a short secret are 403 and the duty file is unchanged; an accepted idle while the ring still reads running writes 32768; the secret dies with the controller. cool_report_test.py and super_test case L own the secret, and cooling.fans-quiet-after-motion owns the live M8/M9 return to the idle duty. The forged Host: localhost was the same secret refusal. - events.stream. events_test.c owns the hello, the replacement, and motors.released; host_routes.sh owns the live cap (three peers held, a fourth 503, GET /settings 200 beside them, closing one admitting the next). forgectrl.lease and exthost.events still cover src/events.*, and _EventStream stays for forgectrl.lease. - setup.what-changed. setup_test holds tray -> laser.focus and fan -> airflow under the image's table beside tube -> laser.floor; host_routes.sh holds /wiz/changed (403, 400) and that every step the menu names is one GET /wiz lists. src/setup.* and src/wiz.* stay covered by the other setup tests, and help.js, panel.js and index.html by camera.key-read. preserved() and flag_of() go with it; recommended() stays. tests/test_setup_suite.py drops setup.what-changed from the id list, the takeover list and the covers lookup, and its flag_of case keeps only the recommended() assertion. test_setup_suite.py passes (36 tests). The coverage lint on the tree manifest finds no uncovered path across the 103 tests; its one finding is setup.check-lens's empty src/lensfind.* entry, which the forgectrl pin bump clears. pyflakes on the four files reports only the unused t0 in cooling.py that HEAD already has. The full forgetest suite was not run. Fingerprints: the cuts change the shared text of suite/cooling.py, suite/forgectrl.py and suite/setup.py, so 56 of the 103 tests' implementation hashes move (cooling 12, setup 26, forgectrl 5, and exthost 13 through its import of suite/forgectrl.py). The next forgectrl pin bump already owes 54 of them; exthost.platform and setup.machine-name are the two it adds. releases/ is unchanged.
This commit is contained in:
@@ -1500,60 +1500,3 @@ def fail_tier_stop(ctx):
|
||||
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||
|
||||
|
||||
@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/auth.*")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="The report channel (POST /cool/state) is what tells the engine a job is running: a "
|
||||
"forged idle report would stand the fans down under a cut. M8 opens a run session "
|
||||
"from the suite's Grbl client, and once the engine is in phase run with its run fan "
|
||||
"profile commanded, a process on this host that is not the controller reports "
|
||||
"mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up "
|
||||
"secret and a forged Host header. Each is refused (403). Over the next five seconds "
|
||||
"the engine stays in phase run and no commanded fan duty drops. M9 "
|
||||
"ends the session, and the engine leaves phase run on the controller's own report.")
|
||||
def report_channel(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
forged = {"mode": "idle", "armed": "0"}
|
||||
|
||||
with ctx.grbl() as g:
|
||||
def in_run(c):
|
||||
return c.get("phase") == "run"
|
||||
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
|
||||
g.command("M8")
|
||||
try:
|
||||
ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5)
|
||||
ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase"))
|
||||
ctx.sleep(2) # the run fan profile is commanded
|
||||
before = _cool(fc)
|
||||
duties0 = _duties()
|
||||
ev["before"] = {"phase": before.get("phase"), "duties": duties0}
|
||||
refused = {}
|
||||
for name, headers in (("no secret", {}),
|
||||
("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}),
|
||||
("a made-up secret and a forged Host",
|
||||
{"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})):
|
||||
st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False)
|
||||
refused[name] = [st, str(body)[:100]]
|
||||
ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100])
|
||||
ev["refused"] = refused
|
||||
held = []
|
||||
for _ in range(5):
|
||||
ctx.sleep(1)
|
||||
c = _cool(fc)
|
||||
held.append([c.get("phase"), _duties()])
|
||||
ev["after"] = held
|
||||
ctx.log("phase and duties over 5 s after three forged idle reports: %s", held)
|
||||
ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held])
|
||||
ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0),
|
||||
"a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held)
|
||||
finally:
|
||||
g.command("M9")
|
||||
_session_ended(ctx, fc, "report channel")
|
||||
ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report")
|
||||
ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the "
|
||||
"engine held its phase and its fan duties")
|
||||
|
||||
@@ -515,98 +515,6 @@ class _EventStream:
|
||||
pass
|
||||
|
||||
|
||||
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
|
||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/grblport.*")],
|
||||
description="GET /events from three loopback source addresses: each gets 200, "
|
||||
"text/event-stream, and the hello event. A fourth address gets 503 with the "
|
||||
"reason, and GET /settings still answers while the three are held. A second "
|
||||
"stream from the first address is served, and the older one gets bye and the "
|
||||
"end of its response. An edge arrives on an open stream: POST /motion/release "
|
||||
"and /motion/energize show up as motors.released and motors.energized, with "
|
||||
"ids that count up (X and Y lose their reference, as after any release). With "
|
||||
"every stream closed, the places come back within two keep-alive intervals.")
|
||||
def events_stream(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = hw.Forgectrl()
|
||||
streams = []
|
||||
try:
|
||||
# A stream an earlier test closed keeps its place until the daemon's
|
||||
# next write to it (its keep-alive), so the three places may not be
|
||||
# free yet: that is the daemon as documented, and the last part of
|
||||
# this test measures it. The three are opened once they can be.
|
||||
deadline = time.time() + 25
|
||||
while True:
|
||||
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
||||
streams.append(_EventStream(fc.base, src))
|
||||
if all(s.status == 200 for s in streams) or time.time() > deadline:
|
||||
break
|
||||
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
|
||||
[s.status for s in streams])
|
||||
for s in streams:
|
||||
s.close()
|
||||
streams = []
|
||||
ctx.sleep(3)
|
||||
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
|
||||
hello = s.text(1.0)
|
||||
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
|
||||
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
|
||||
fourth = _EventStream(fc.base, "127.0.0.5")
|
||||
body = fourth.text(1.0)
|
||||
fourth.close()
|
||||
ev["fourth"] = [fourth.status, body.strip()[-120:]]
|
||||
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
|
||||
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
|
||||
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
|
||||
st, _body = fc.get("/settings")
|
||||
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
|
||||
|
||||
# One per address, by replacement.
|
||||
newer = _EventStream(fc.base, "127.0.0.2")
|
||||
hello = newer.text(1.0)
|
||||
ctx.check(newer.status == 200 and "event: hello" in hello,
|
||||
"a second stream from one address -> %s %r", newer.status, hello[:120])
|
||||
bye = streams[0].text(2.0)
|
||||
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
|
||||
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
|
||||
ctx.check(streams[0].eof, "the older stream was not ended")
|
||||
streams[0].close()
|
||||
streams[0] = newer
|
||||
|
||||
# An edge, on a stream that has been open all along.
|
||||
st, body = fc.post("/motion/release")
|
||||
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
|
||||
ctx.sleep(1.0)
|
||||
st, body = fc.post("/motion/energize")
|
||||
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
|
||||
text = streams[1].text(2.0)
|
||||
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
|
||||
ctx.log("the stream carried: %s", ev["edges"])
|
||||
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
|
||||
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
|
||||
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
|
||||
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
|
||||
finally:
|
||||
for s in streams:
|
||||
s.close()
|
||||
if fc.status().get("motors_released"):
|
||||
fc.post("/motion/energize")
|
||||
|
||||
# A closed client is noticed at the daemon's next write to it.
|
||||
def reopened():
|
||||
s = _EventStream(fc.base, "127.0.0.5")
|
||||
try:
|
||||
return s.status == 200
|
||||
finally:
|
||||
s.close()
|
||||
took = ctx.wait_for(reopened, 25, poll=1.0)
|
||||
ev["place_back_s"] = took
|
||||
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
|
||||
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
|
||||
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
|
||||
|
||||
|
||||
def _lease(fc):
|
||||
return (fc.status().get("lease") or {})
|
||||
|
||||
|
||||
@@ -1711,101 +1711,6 @@ def cert_page(ctx):
|
||||
|
||||
# ------------------------------------------------------- the lifecycle
|
||||
|
||||
@contextlib.contextmanager
|
||||
def preserved(ctx, *paths):
|
||||
"""The files put back as found under a forgectrl restart when the
|
||||
block ends, on every exit path. For a test whose routes change the
|
||||
record through the daemon (which keeps it in memory and rewrites
|
||||
it whole), so the restore must happen under a restart."""
|
||||
saved = {p: read_file(p) for p in paths}
|
||||
try:
|
||||
yield saved
|
||||
finally:
|
||||
with ctx.takeover():
|
||||
for p, data in saved.items():
|
||||
write_file(p, data)
|
||||
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
|
||||
|
||||
|
||||
def flag_of(w, wid):
|
||||
"""The reason a GET /wiz body gives for recommending wid, or None when
|
||||
it does not recommend it."""
|
||||
return recommended(w).get(wid)
|
||||
|
||||
|
||||
@test("setup.what-changed", title="A replaced part recommends its steps again",
|
||||
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
||||
("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="GET /wiz carries the what-changed menu: every change with the steps it "
|
||||
"recommends, all of them in the catalog (the tube the laser floor; the pump and "
|
||||
"the coolant the coolant flow; a fan the airflow check; the head the machine, "
|
||||
"the lens travel, the focus, motion, and the cameras; the tray the focus; a "
|
||||
"service the switches and motion). POST /wiz/changed what=tray recommends the "
|
||||
"focus burn with the tray as the reason, and what=fan the airflow check with the "
|
||||
"fan as the reason; neither moves the gate, which only the advisories, the "
|
||||
"account, the required steps, and the intake fan count close. An unknown change "
|
||||
"is refused (400). "
|
||||
"The record is put back as found under a forgectrl restart.")
|
||||
def what_changed(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
want = {"tube": ["laser.floor"], "pump": ["cooling.flow"], "coolant": ["cooling.flow"],
|
||||
"fan": ["airflow"], "head": ["machine", "lens.travel", "laser.focus", "motion", "cameras"],
|
||||
"tray": ["laser.focus"], "service": ["switches", "motion"]}
|
||||
before = wiz(fc)
|
||||
ev["before"] = wiz_summary(before)
|
||||
menu = before.get("changes") or []
|
||||
catalog_ids = {z["id"] for z in before.get("wizards") or []}
|
||||
ev["menu"] = {c.get("id"): [x.get("id") for x in c.get("wizards") or []] for c in menu}
|
||||
ctx.check(ev["menu"] == want, "the menu reads %s, expected %s", ev["menu"], want)
|
||||
unknown = [x["id"] for c in menu for x in c.get("wizards") or [] if x.get("id") not in catalog_ids]
|
||||
ctx.check(not unknown, "the menu names wizards outside the catalog: %s", unknown)
|
||||
ctx.check(all(c.get("title") for c in menu), "a change has no title")
|
||||
gate_before = before.get("gate")
|
||||
gated_before = bool(mode(fc).get("gated"))
|
||||
ev["gated_before"] = gated_before
|
||||
|
||||
with preserved(ctx, record_path()):
|
||||
st, body = fc.post("/wiz/changed", data={"what": "nothing-like-this"})
|
||||
ctx.log("POST /wiz/changed what=nothing-like-this -> %s", st)
|
||||
ctx.check(st == 400, "an unknown change -> %s, expected 400", st)
|
||||
|
||||
st, body = fc.post("/wiz/changed", data={"what": "tray"})
|
||||
ctx.log("POST /wiz/changed what=tray -> %s", st)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "what=tray -> %s", st)
|
||||
w = body if isinstance(body, dict) else wiz(fc)
|
||||
reason = flag_of(w, "laser.focus")
|
||||
ev["tray_reason"] = reason
|
||||
ctx.check(reason is not None and "tray" in reason,
|
||||
"the tray does not recommend the focus burn: %s", w.get("recommended"))
|
||||
ctx.check(w.get("gate") == gate_before and bool(mode(fc).get("gated")) == gated_before,
|
||||
"a recommendation moved the gate: %s", wiz_summary(w))
|
||||
|
||||
st, body = fc.post("/wiz/changed", data={"what": "fan"})
|
||||
ctx.log("POST /wiz/changed what=fan -> %s", st)
|
||||
ctx.check(st == 200, "what=fan -> %s", st)
|
||||
w = wiz(fc)
|
||||
reason = flag_of(w, "airflow")
|
||||
ev["fan_reason"] = reason
|
||||
ctx.check(reason is not None and "fan" in reason,
|
||||
"a fan does not recommend the airflow check: %s", w.get("recommended"))
|
||||
m = mode(fc)
|
||||
ev["gated_after_fan"] = m.get("gated")
|
||||
ctx.check(w.get("gate") == gate_before and bool(m.get("gated")) == gated_before,
|
||||
"a recommendation moved the gate: %s, %s", wiz_summary(w), m)
|
||||
ctx.log("tray -> focus recommended (%s); fan -> airflow recommended (%s); gate %s as found",
|
||||
ev["tray_reason"], reason, gate_before)
|
||||
|
||||
after = wiz(fc)
|
||||
ev["after"] = wiz_summary(after)
|
||||
ctx.check(recommended(after) == recommended(before),
|
||||
"the recommendations do not read as found: %s, was %s", recommended(after), recommended(before))
|
||||
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
||||
|
||||
|
||||
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||
subsystem="setup", kind="auto", est_min=2,
|
||||
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||
|
||||
@@ -23,7 +23,7 @@ IDS = ("setup.gate-blocks-controllers",
|
||||
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||
"setup.first-run-page", "setup.record-export",
|
||||
"setup.mirror")
|
||||
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||
|
||||
@@ -48,30 +48,22 @@ class RegistrationTests(unittest.TestCase):
|
||||
def test_the_record_swapping_tests_are_takeovers(self):
|
||||
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||
"setup.first-run-flow", "setup.first-run-page",
|
||||
"setup.what-changed", "setup.mirror"):
|
||||
"setup.mirror"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
|
||||
|
||||
def test_the_lifecycle_tests_cover_their_files(self):
|
||||
changed = set(self.reg["setup.what-changed"].covers)
|
||||
export = set(self.reg["setup.record-export"].covers)
|
||||
mirror = set(self.reg["setup.mirror"].covers)
|
||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||
self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
|
||||
|
||||
def test_flag_of_reads_the_status_document(self):
|
||||
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"},
|
||||
{"id": "airflow", "reason": "not run yet"}, "stray"]}
|
||||
self.assertEqual(setup.flag_of(w, "laser.floor"), "the tube was replaced")
|
||||
self.assertEqual(setup.flag_of(w, "laser.focus"), "the tray was replaced")
|
||||
self.assertEqual(setup.flag_of(w, "airflow"), "not run yet")
|
||||
self.assertIsNone(setup.flag_of(w, "stray")) # only an entry with an id counts
|
||||
self.assertIsNone(setup.flag_of(w, "cameras"))
|
||||
self.assertIsNone(setup.flag_of({}, "x"))
|
||||
self.assertEqual(setup.recommended(w), {"laser.floor": "the tube was replaced",
|
||||
"laser.focus": "the tray was replaced",
|
||||
"airflow": "not run yet"})
|
||||
|
||||
Reference in New Issue
Block a user