forgetest: the release acceptance tool and the bench diagnostics page

A stdlib-only daemon on the dev image (HTTP :8090) that runs the acceptance
catalog against the machine from a self-contained page, keeps the append-only
result log under /data/forgetest, and exports the release artifact the gate
reads. Tests declare kind (auto / operator / live), hardware (api / takeover),
coverage globs, prerequisites, and core membership; a test's domain
fingerprint is the hash of the manifest files its globs select plus the
platform and its own implementation, so a PASS stays valid exactly while
nothing it covers changed. Campaign rules: a FAIL ends the campaign, the core
(image health, kernel latch and drills, one live emission witness) is never
inherited, invalidate-all forces a full campaign, no SKIP. Live tests need the
operator acknowledgment and the physical arm press through the controller;
takeover tests stop forgectrl for the duration with a crash-recoverable
marker; the tool never touches the laser latch.

Catalog v1: 24 tests ported from the proven bench drills with their recorded
pass criteria (image, kernel K1-K3 and fire A/B/U, forgectrl API and logs,
motion incl. dead-man, cooling, live laser, camera, update, cloud). The bench
tab lists every scripts/bench tool and runs the board-side ones as
subprocesses (takeover tools wrapped). 44 host unit tests, including the gate
verification fixtures. Installed only by forgefirm-image-dev, with the bench
scripts under /usr/share/forgetest/bench.
This commit is contained in:
ScottW514
2026-08-15 15:57:11 -04:00
parent 9b5558dfbc
commit c0f53a865f
35 changed files with 5508 additions and 2 deletions
+6
View File
@@ -14,3 +14,9 @@
# Python bytecode
__pycache__/
# manifest-from-tree.py fetch cache
/.manifest-cache/
# manifest-from-tree.py default output
/tree-manifest.json
+44
View File
@@ -0,0 +1,44 @@
# forgetest - the ForgeFIRM release acceptance tool
The daemon behind `http://<machine>:8090/` on the dev image: runs the
acceptance catalog against the machine, keeps the append-only result log,
decides which results still apply to the image that is running, exports
the release artifact `scripts/release.sh` gates on, and serves the bench
diagnostics page. The contract - catalog, campaigns, fingerprints,
inheritance, the gate, the coverage rule - is
[`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md).
## Run the host tests
cd forgetest
python3 -m unittest discover -s tests -v
## Run the daemon on a workstation (against a mock or a manifest file)
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
FORGECTRL_URL=http://<machine>:8080 python3 -m forgetest --port 8090
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
## Environment
| Variable | Default | Purpose |
|---|---|---|
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1:8080`, `/data/forgefirm/panel.token` | forgectrl client |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
## Adding a test
Register it in the subsystem module under `forgetest/suite/` with
`@test(...)`: id `subsystem.name`, kind, hardware, `covers`, `requires`,
`always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
FAIL. Then run the unit tests and the coverage lint.
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgetest
# Required-Start: $network forgectrl
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
### END INIT INFO
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
# state under /data/forgetest, and serves the acceptance + bench pages on
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
# the controllers at shutdown; a takeover left behind by a hard stop is
# recovered at the next start (forgectrl is started again).
PIDFILE=/var/run/forgetest.pid
DATA=/data/forgetest
LOG=$DATA/daemon.log
case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;;
stop)
echo "Stopping forgetest"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 1
$0 start
;;
status)
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "forgetest is running"
exit 0
fi
echo "forgetest is not running"
exit 3
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
+10
View File
@@ -0,0 +1,10 @@
"""forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
against the machine, keeps the append-only result log, decides which
results still apply to the image that is running, and exports the
release artifact the release gate reads. The bench diagnostics page rides
the same daemon.
"""
VERSION = "0.1.0"
+69
View File
@@ -0,0 +1,69 @@
"""forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host 0.0.0.0] [--manifest PATH]
Environment: FORGETEST_DATA (state directory, default /data/forgetest),
FORGETEST_MANIFEST, FORGETEST_BENCH_DIR, FORGETEST_MARKER, and the hw.py
overrides.
"""
import argparse
import os
import signal
import sys
import threading
from . import VERSION
from . import bench as _bench
from . import catalog as _catalog
from . import manifest as _manifest
from . import server as _server
from .log import Log, data_dir
from .runner import Runner
def main(argv=None):
ap = argparse.ArgumentParser(prog="forgetest")
ap.add_argument("--port", type=int, default=int(os.environ.get("FORGETEST_PORT") or 8090))
ap.add_argument("--host", default=os.environ.get("FORGETEST_HOST") or "0.0.0.0")
ap.add_argument("--manifest", default=None)
ap.add_argument("--version", action="version", version="forgetest %s" % VERSION)
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("forgetest: cannot load the image manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
os.makedirs(data_dir(), exist_ok=True)
log = Log()
bench = _bench.Bench()
runner = Runner(log, manifest, registry, bench)
token = _server.load_token()
app = _server.App(runner, token)
srv = _server.make_server(app, args.host, args.port)
stop = threading.Event()
def on_signal(*_):
stop.set()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
args.host, args.port), file=sys.stderr, flush=True)
for m in runner.messages:
print("forgetest: %s" % m, file=sys.stderr, flush=True)
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
th.start()
try:
while not stop.is_set():
stop.wait(1.0)
finally:
srv.shutdown()
srv.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
"""The release artifact (acceptance.json / acceptance.md) and its verification.
The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate
(scripts/acceptance-gate.py) calls verify() with the artifact, the
release build's manifest, and the catalog from the same source tree, and
recomputes every fingerprint - a PASS applies to the release exactly when
the recomputation matches. The artifact is self-hashed so an edited file
is caught before any of that.
"""
import json
from . import VERSION
from . import campaign as _campaign
from . import manifest as _manifest
from .log import now_ts
FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
"duration_s", "message", "evidence", "answers", "log", "operator")
def _record(rec):
return {k: rec.get(k) for k in _RESULT_KEYS if k in rec}
def build(state, tests, manifest, records, catalog_hash):
"""The artifact dict (self-hash included). records: the full log,
used to find the winning record for each test."""
results = [r for r in records if r.get("t") == "result"]
by_key = {}
for r in results:
by_key[(r.get("test"), r.get("ts"), r.get("campaign"))] = r
tests_out = []
for t in tests:
st = state["tests"][t.id]
origin = st.get("origin")
rec = None
if origin:
rec = by_key.get((t.id, origin.get("ts"), origin.get("campaign")))
entry = t.definition()
entry.update({
"title": t.title,
"source_sha": t.source_sha,
"fingerprint": st["fingerprint"],
"satisfied": st["satisfied"],
"inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None,
})
tests_out.append(entry)
art = {
"format": FORMAT,
"tool_version": VERSION,
"exported_at": now_ts(),
"image": {"name": manifest.image_name, "version": manifest.version},
"manifest_sha": manifest.content_sha,
"identity_sha": manifest.identity_sha(),
"catalog_hash": catalog_hash,
"campaign": state["campaign"],
"invalidate": state["invalidate"],
"authorized": state["authorized"],
"counts": state["counts"],
"manifest": manifest.data,
"tests": tests_out,
}
art["sha256"] = _manifest.sha256_text(_manifest.canonical(art))
return art
def to_json(art):
return json.dumps(art, sort_keys=True, indent=1) + "\n"
def to_markdown(art):
lines = []
img = art.get("image", {})
lines.append("# ForgeFIRM acceptance - %s" % img.get("version", "?"))
lines.append("")
lines.append("- Image: `%s` (%s)" % (img.get("version", "?"), img.get("name", "?")))
lines.append("- Manifest identity: `%s`" % (art.get("manifest_sha") or "?"))
lines.append("- Catalog: `%s`" % (art.get("catalog_hash") or "?"))
c = art.get("campaign") or {}
lines.append("- Campaign: `%s` opened %s" % (c.get("id", "-"), c.get("ts", "-")))
inv = art.get("invalidate")
if inv:
lines.append("- Full campaign required since %s: %s" % (inv.get("ts"), inv.get("reason")))
lines.append("- Exported: %s" % art.get("exported_at"))
lines.append("- **Release authorized: %s**" % ("YES" if art.get("authorized") else "NO"))
cnt = art.get("counts", {})
lines.append("- Tests: %s total, %s satisfied (%s inherited), %s required"
% (cnt.get("total"), cnt.get("satisfied"), cnt.get("inherited"), cnt.get("required")))
lines.append("")
lines.append("| Test | Kind | Result | Run at | Campaign | Inherited |")
lines.append("|---|---|---|---|---|---|")
for t in art.get("tests", []):
rec = t.get("record") or {}
kind = t.get("kind", "")
if t.get("always"):
kind += ", core"
if t.get("hardware") == "takeover":
kind += ", takeover"
lines.append("| `%s` | %s | %s | %s | `%s` | %s |" % (
t.get("id"), kind, rec.get("result", "-"), rec.get("ts", "-"),
rec.get("campaign", "-"), "yes" if t.get("inherited") else "no"))
lines.append("")
lines.append("Artifact sha256: `%s`" % art.get("sha256"))
lines.append("")
return "\n".join(lines)
def verify(art, release_manifest, tests, catalog_hash, expect_machine=None):
"""Gate decision. Returns (ok, rows, problems).
rows: per-test dicts for the report. problems: list of strings; empty
means the artifact authorizes the release manifest.
"""
problems = []
rows = []
body = dict(art)
sha = body.pop("sha256", None)
if sha != _manifest.sha256_text(_manifest.canonical(body)):
problems.append("artifact self-hash mismatch (edited or truncated file)")
return False, rows, problems
if art.get("format") != FORMAT:
problems.append("artifact format %r, expected %r" % (art.get("format"), FORMAT))
if not art.get("authorized"):
problems.append("artifact does not claim authorization")
if expect_machine and release_manifest.platform.get("machine") != expect_machine:
problems.append("release manifest machine %r, expected %r"
% (release_manifest.platform.get("machine"), expect_machine))
if art.get("catalog_hash") != catalog_hash:
problems.append("catalog changed since the campaign (artifact %s, tree %s)"
% ((art.get("catalog_hash") or "?")[:12], catalog_hash[:12]))
tests = list(tests)
by_id = {t.id: t for t in tests}
art_tests = {t["id"]: t for t in art.get("tests", [])}
for tid in sorted(set(by_id) - set(art_tests)):
problems.append("test %s is in the catalog but not in the artifact" % tid)
for tid in sorted(set(art_tests) - set(by_id)):
problems.append("test %s is in the artifact but not in the catalog" % tid)
epoch = (art.get("invalidate") or {}).get("ts")
for t in tests:
a = art_tests.get(t.id)
if a is None:
continue
row = {"id": t.id, "always": t.always, "inherited": bool(a.get("inherited"))}
rec = a.get("record")
ok = True
why = []
if a.get("covers") != [list(c) for c in t.covers] or a.get("always") != t.always \
or a.get("kind") != t.kind or a.get("requires") != list(t.requires):
ok = False
why.append("definition differs from the catalog in the tree")
if a.get("source_sha") != t.source_sha:
ok = False
why.append("test implementation changed since the campaign")
if not rec:
ok = False
why.append("no PASS recorded")
else:
if rec.get("result") != _campaign.PASS:
ok = False
why.append("recorded result is %s" % rec.get("result"))
fp = t.fingerprint(release_manifest)
row["fingerprint"] = fp
if rec.get("fingerprint") != fp:
ok = False
why.append("fingerprint differs from the release build (domain changed)")
if a.get("inherited"):
if t.always:
ok = False
why.append("always-required test may not be inherited")
if epoch and (rec.get("ts") or "") <= epoch:
ok = False
why.append("inherited PASS predates the invalidate-all")
row["ts"] = rec.get("ts")
row["ok"] = ok
row["why"] = why
rows.append(row)
if not ok:
problems.append("%s: %s" % (t.id, "; ".join(why)))
return not problems, rows, problems
+227
View File
@@ -0,0 +1,227 @@
"""The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab.
The registry lists every tool of scripts/bench (the README is the human
index; this is the machine one) with its safety class and argument spec.
A tool is runnable from the page once `ported` is set: the script is
installed under the tool directory (/usr/share/forgetest/bench on the
image, override FORGETEST_BENCH_DIR) and runs as a subprocess with the
form's arguments, output streamed to the page. Unported tools are listed
so the catalog of what exists is complete, with Start disabled.
Safety classes:
dry reads or dry motion, no emission, forgectrl stays up
takeover needs forgectrl stopped and the pulse device free
live laser emission possible (operator acknowledgment required)
scope needs bench instrumentation on top of the class before it
Bench runs are recorded in <data>/bench.jsonl and never enter a campaign.
"""
import json
import os
import shlex
import sys
import threading
from .log import data_dir, now_ts
DEFAULT_TOOL_DIR = "/usr/share/forgetest/bench"
def _arg(name, type="str", default=None, help="", choices=None):
a = {"name": name, "type": type, "default": default, "help": help}
if choices:
a["choices"] = list(choices)
return a
TOOLS = [
# -- board-side, dry ------------------------------------------------------
{"id": "check-pwm", "title": "Laser PWM register check", "script": "check_pwm.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Reads PWM2 PWMCR/PWMPR via /dev/mem; expects divider 13 x ~127 counts = ~40 kHz. Read-only."},
{"id": "pacing-test", "title": "Protocol-loop pacing check", "script": "pacing_test.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mm", "float", 30.0, "jog distance (+X first)"), _arg("feed", "float", 600.0, "feed rate")],
"desc": "Dry motion: idle/parked states coarse-paced, active motion tight-paced, hold/resume mid-move keeps position."},
{"id": "bench-m2", "title": "Motion-quality bench", "script": "bench_m2.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"argv_fixed": ["127.0.0.1"],
"desc": "Bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume; reports peak feed, transitions, drift."},
{"id": "bench-phase2", "title": "End-of-data protocol bench", "script": "bench_phase2.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Underrun detection/ack, parked no-replay guard, resume(0), continuous feed, run/underrun cycles. Motors locked, laser latched."},
{"id": "cp-watchdog", "title": "HV charge-pump watchdog timing", "script": "cp_watchdog_timing.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 14.0, "capture length")],
"desc": "Latches CHG_PUMP feed pulses, polls the watchdog readbacks while commanding short local jogs. Motion only, laser locked."},
{"id": "accel-fast", "title": "Head accelerometer sampler", "script": "accel_fast.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 5.0, "capture length"),
_arg("jog1", "str", None, "optional mid-capture jog, e.g. $J=G91X20F2400"),
_arg("jog2", "str", None, "optional second jog")],
"desc": "Direct-I2C sampler for the head-bus LIS2HH12s with optional mid-capture jogs. CSV to /tmp/accel.csv."},
{"id": "bump-seek", "title": "Accelerometer bump-seek homing prototype", "script": "bump_seek.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("direction", "choice", "-", "X direction", ["-", "+"]),
_arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"),
_arg("max_mm", "float", 200.0, "travel bound")],
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
# -- board-side, takeover / scope ------------------------------------------
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
"safety": "scope", "where": "board", "ported": False,
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. Locked state only."},
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
"safety": "scope", "where": "board", "ported": False,
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state only."},
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
"safety": "takeover", "where": "board", "ported": False, "args": [],
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge."},
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
"desc": "Software witnesses (cnc/state, laser_enable, laser_on, interlock bit 3); K3 refuses if HV reports good."},
{"id": "platform-drills", "title": "Kernel platform drills", "script": "platform_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "decay", "deadman / rmmod / decay / led / all",
["deadman", "rmmod", "decay", "led", "all"])],
"desc": "Dead-man trip readback, rmmod/modprobe cycles under load, decay/microstep readback, LED sequence."},
# -- cooling ------------------------------------------------------------------
{"id": "flow-confirm", "title": "Coolant flow suspicion/confirmation drill", "script": "flow_confirm_drill.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
"safety": "dry", "where": "board", "ported": False, "args": [],
"desc": "With the pump off the job-start check reads SUSPECT and the driver must escalate to FAULT."},
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
"safety": "dry", "where": "host", "ported": False,
"args": [_arg("duty", "int", 30, "heater duty percent")],
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C downstream."},
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Runs the real check from a heater-warmed baseline."},
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Short in-run re-checks and the differential metric."},
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
"safety": "dry", "where": "host", "ported": False,
"args": [_arg("mode", "choice", "watch", "watch / point / fit", ["watch", "point", "fit"]),
_arg("measured_c", "float", None, "thermometer reading for point")],
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line."},
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle. Host-side; port pending."},
# -- laser (live) --------------------------------------------------------------
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
"safety": "live", "where": "board", "ported": False,
"args": [_arg("drill", "choice", "witness", "witness / hold / faultpos", ["witness", "hold", "faultpos"])],
"argv_fixed_after": ["127.0.0.1"],
"desc": "Emission witness, disarm grace in Hold, stale-origin refusal. The operator's arm press is required for every drill."},
# -- host-side harnesses (CI) ------------------------------------------------------
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Null-sink controller stream capture against the feeder contract. Runs in the grblHAL repo's CI."},
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Arm/disarm lifecycle on the null-sink controller. Runs in the grblHAL repo's CI."},
{"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file."},
]
class Bench:
def __init__(self, tools=None, tool_dir=None, index_path=None):
self.tools = list(tools if tools is not None else TOOLS)
self._by_id = {t["id"]: t for t in self.tools}
self._tool_dir = tool_dir
self.index_path = index_path or os.path.join(data_dir(), "bench.jsonl")
self._lock = threading.Lock()
def tool_dir(self):
return self._tool_dir or os.environ.get("FORGETEST_BENCH_DIR") or DEFAULT_TOOL_DIR
def get(self, tool_id):
return self._by_id.get(tool_id)
def command(self, tool, args):
"""argv for a tool with the form's arguments. Returns
(ok, argv, error)."""
script = os.path.join(self.tool_dir(), tool["script"])
if not os.path.exists(script):
return False, None, "script not installed: %s" % tool["script"]
argv = [sys.executable, script] + list(tool.get("argv_fixed", []))
for spec in tool.get("args", []):
raw = args.get(spec["name"], spec.get("default"))
if raw is None or raw == "":
if spec.get("default") is None:
continue # optional and absent
raw = spec["default"]
try:
if spec["type"] == "int":
val = str(int(raw))
elif spec["type"] == "float":
val = repr(float(raw))
elif spec["type"] == "choice":
if str(raw) not in spec["choices"]:
return False, None, "%s must be one of %s" % (spec["name"], spec["choices"])
val = str(raw)
else:
val = str(raw)
if any(ch in val for ch in "\0\n\r"):
return False, None, "%s: invalid characters" % spec["name"]
except (TypeError, ValueError):
return False, None, "%s: invalid %s" % (spec["name"], spec["type"])
argv.append(val)
argv += list(tool.get("argv_fixed_after", []))
return True, argv, None
def record(self, tool, args, run):
rec = {"ts": run.started_ts, "tool": tool["id"], "args": args, "result": run.finished,
"log_tail": run.lines[-50:]}
with self._lock:
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
with open(self.index_path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
def last_runs(self):
out = {}
try:
with open(self.index_path, "r", encoding="utf-8") as f:
for line in f:
try:
rec = json.loads(line)
except ValueError:
continue
out[rec.get("tool")] = {"ts": rec.get("ts"), "result": rec.get("result"),
"args": rec.get("args")}
except OSError:
pass
return out
def listing(self):
last = self.last_runs()
items = []
for t in self.tools:
item = {k: t[k] for k in ("id", "title", "script", "safety", "where", "ported", "args", "desc")}
item["installed"] = os.path.exists(os.path.join(self.tool_dir(), t["script"]))
item["last"] = last.get(t["id"])
items.append(item)
return items
def describe_command(self, tool, args):
ok, argv, err = self.command(tool, args)
return " ".join(shlex.quote(a) for a in argv) if ok else err
+145
View File
@@ -0,0 +1,145 @@
"""Campaign rules: which results apply, what is required, and whether a
release is authorized.
A campaign is bound to one image (manifest content hash) and one catalog
(catalog hash). It is open from its record until a FAIL/ERROR result in
it, an invalidate-all, an explicit reset, or a different image or
catalog. Rules per test T with domain fingerprint F(T):
satisfied by campaign a PASS in the open campaign with fingerprint F(T)
satisfied by inheritance (never for the always-required core) the newest
PASS anywhere in the history with fingerprint F(T)
and newer than the last invalidate-all
required otherwise (reason: always / never-passed /
domain-changed)
authorized <=> a campaign is open and every catalog test is satisfied
Pure functions over the record list; nothing here touches hardware or
the filesystem, which keeps the rules unit-testable and lets the release
gate reason with the same code.
"""
PASS, FAIL, ERROR, ABORTED = "PASS", "FAIL", "ERROR", "ABORTED"
CLOSING = (FAIL, ERROR)
def _summary(rec):
if rec is None:
return None
keys = ("ts", "result", "campaign", "image", "fingerprint", "duration_s", "message")
return {k: rec.get(k) for k in keys if k in rec}
def open_campaign(records, manifest_sha, catalog_hash):
"""(open_campaign_record_or_None, last_campaign_record_or_None,
closed_by_or_None, invalidate_record_or_None)."""
current = None
closed_by = None
invalidate = None
for r in records:
t = r.get("t")
if t == "campaign":
current = r
closed_by = None
elif t == "result":
if current and r.get("campaign") == current.get("id") and r.get("result") in CLOSING:
closed_by = closed_by or "fail"
elif t == "invalidate":
invalidate = r
if current:
closed_by = closed_by or "invalidate"
elif t == "reset":
if current:
closed_by = closed_by or "reset"
if current and not closed_by:
if current.get("manifest_sha") != manifest_sha:
closed_by = "image"
elif current.get("catalog_hash") != catalog_hash:
closed_by = "catalog"
return (current if current and not closed_by else None), current, closed_by, invalidate
def compute(records, tests, manifest, catalog_hash, running=None):
"""The state the page shows and the exporter serializes.
tests: iterable of catalog.Test. running: id of the test in progress
(marked in the per-test status), or None.
"""
results = [r for r in records if r.get("t") == "result"]
campaign, last, closed_by, invalidate = open_campaign(records, manifest.content_sha, catalog_hash)
epoch = invalidate.get("ts") if invalidate else None
open_id = campaign.get("id") if campaign else None
by_test = {}
for r in results:
by_test.setdefault(r.get("test"), []).append(r)
out_tests = {}
tests = list(tests)
for t in tests:
fp = t.fingerprint(manifest)
hist = by_test.get(t.id, [])
last_r = hist[-1] if hist else None
origin = None
status = "none"
satisfied = False
reason = None
same = None
if open_id:
for r in reversed(hist):
if r.get("campaign") == open_id and r.get("result") == PASS and r.get("fingerprint") == fp:
same = r
break
if same is not None:
status, satisfied, origin, reason = "pass", True, same, "campaign"
elif t.always:
reason = "always"
else:
inh = None
for r in reversed(hist):
if r.get("result") != PASS or r.get("fingerprint") != fp:
continue
if epoch and (r.get("ts") or "") <= epoch:
continue
inh = r
break
if inh is not None:
status, satisfied, origin, reason = "inherited", True, inh, "inherited"
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:
status = {PASS: "stale", FAIL: "fail", ERROR: "error", ABORTED: "aborted"}.get(last_r.get("result"), "none")
if running == t.id:
status = "running"
out_tests[t.id] = {
"fingerprint": fp,
"status": status,
"satisfied": satisfied,
"required": not satisfied,
"reason": reason,
"last": _summary(last_r),
"origin": _summary(origin),
}
# requires: a prerequisite counts when it is satisfied (campaign or
# inherited); the always-required core is the freshness mechanism.
for t in tests:
missing = [r for r in t.requires if not out_tests.get(r, {}).get("satisfied")]
out_tests[t.id]["missing_requires"] = missing
out_tests[t.id]["requires_met"] = not missing
n_sat = sum(1 for v in out_tests.values() if v["satisfied"])
n_inh = sum(1 for v in out_tests.values() if v["status"] == "inherited")
counts = {"total": len(tests), "satisfied": n_sat, "inherited": n_inh,
"required": len(tests) - n_sat}
authorized = bool(campaign) and n_sat == len(tests) and len(tests) > 0
return {
"campaign": campaign,
"last_campaign": last,
"closed_by": closed_by,
"invalidate": invalidate,
"authorized": authorized,
"counts": counts,
"tests": out_tests,
}
+149
View File
@@ -0,0 +1,149 @@
"""The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records
what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), what source it covers, what it requires, and
whether it belongs to the always-required core. The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
"""
import hashlib
import inspect
import os
import re
from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn):
self.id = id
self.title = title
self.subsystem = subsystem
self.kind = kind
self.hardware = hardware
self.covers = tuple((str(c), str(g)) for c, g in covers)
self.requires = tuple(requires)
self.always = bool(always)
self.est_min = est_min
self.steps = tuple(steps)
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
@property
def source_sha(self):
"""sha256 of the module file that defines the test, line endings
normalized. Part of the fingerprint: a changed implementation
invalidates earlier passes of this test and no other."""
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = source_file_sha(path)
return self._source_sha
def fingerprint(self, manifest):
return _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
def definition(self):
"""The gate-visible definition (no implementation, no prose)."""
return {
"id": self.id,
"subsystem": self.subsystem,
"kind": self.kind,
"hardware": self.hardware,
"covers": [list(c) for c in self.covers],
"requires": list(self.requires),
"always": self.always,
}
def describe(self):
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min,
"steps": list(self.steps), "description": self.description})
return d
def source_file_sha(path):
with open(path, "rb") as f:
data = f.read().replace(b"\r\n", b"\n")
return hashlib.sha256(data).hexdigest()
def test(id, *, title, subsystem, kind="auto", hardware="api", covers=(),
requires=(), always=False, est_min=1, steps=(), description=""):
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
raise ValueError("test %s: kind %r" % (id, kind))
if hardware not in HARDWARE:
raise ValueError("test %s: hardware %r" % (id, hardware))
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
def deco(fn):
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn)
return fn
return deco
def all_tests(registry=None):
"""Tests in registration order (the suite modules import in
subsystem order, so this is the display order)."""
return list((registry if registry is not None else REGISTRY).values())
def get(id, registry=None):
return (registry if registry is not None else REGISTRY).get(id)
def validate(registry=None):
"""Every `requires` names a known test and there are no cycles."""
reg = registry if registry is not None else REGISTRY
for t in reg.values():
for r in t.requires:
if r not in reg:
raise ValueError("test %s requires unknown test %s" % (t.id, r))
seen = {}
def visit(tid, stack):
if tid in stack:
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
if seen.get(tid):
return
for r in reg[tid].requires:
visit(r, stack + [tid])
seen[tid] = True
for tid in reg:
visit(tid, [])
def catalog_hash(registry=None):
"""Identity of the catalog's definitions (ids, kinds, coverage,
requirements, core membership) - not of the implementations, which
the per-test fingerprints carry."""
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
return _manifest.sha256_text(_manifest.canonical(defs))
def load_suite():
"""Import the suite modules (each registers its tests) and validate."""
from . import suite # noqa: F401 (registers on import)
validate()
return REGISTRY
def suite_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
+73
View File
@@ -0,0 +1,73 @@
"""Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths.
Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test); whether that test exercises the
change stays with the change author.
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error.
"""
import argparse
import json
import sys
from . import catalog as _catalog
from . import manifest as _manifest
# Non-behavioral paths that need no acceptance coverage. Reviewed with the
# catalog: widening this list is a change like any other.
ALLOW = [
("*", ".github/**"),
("*", ".gitignore"),
("*", ".gitmodules"),
("*", "**/*.md"),
("*", "LICENSE*"),
("*", "COPYING*"),
("*", "docs/**"),
("*", "tests/**"),
("*", "graphify-out/**"),
("*", "**/.gitkeep"),
("forgectrl", "tools/**"), # the host-side mock, no target behavior
]
def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow)
def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("cannot load manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
tests = _catalog.all_tests(registry)
report = run(manifest, tests)
total = sum(len(v) for v in report.values())
if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests)}, indent=1, sort_keys=True))
else:
for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]:
print(" %s" % p)
print("coverage: %d uncovered path(s) across %d component(s), %d tests"
% (total, len(report), len(tests)))
if args.enforce and total:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+313
View File
@@ -0,0 +1,313 @@
"""Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port.
Everything is reachable through environment overrides so the suite can be
exercised against a mock on a host:
FORGECTRL_URL default http://127.0.0.1:8080
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d
"""
import json
import os
import socket
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
class HwError(Exception):
pass
# ------------------------------------------------------------ forgectrl
class Forgectrl:
"""Thin client for the machine-services daemon."""
def __init__(self, base=None, token=None, timeout=10.0):
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1:8080").rstrip("/")
self.timeout = timeout
self._token = token
@property
def token(self):
if self._token is None:
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
try:
with open(path, "r", encoding="utf-8") as f:
self._token = f.read().strip()
except OSError:
self._token = ""
return self._token
def host_header(self):
return urllib.parse.urlsplit(self.base).netloc
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
"""Returns (status, body). body is parsed JSON when the response
is JSON, else text (or bytes when raw=True). Never raises on an
HTTP error status - the suite asserts on codes."""
url = self.base + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
body = None
hdrs = {"Host": self.host_header()}
if headers:
hdrs.update(headers)
if data is not None:
if isinstance(data, (dict, list)):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
else:
body = data
if auth and self.token:
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
status = resp.status
content = resp.read()
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status = e.code
content = e.read()
ctype = e.headers.get("Content-Type", "") if e.headers else ""
except (urllib.error.URLError, socket.timeout, OSError) as e:
raise HwError("forgectrl %s %s: %s" % (method, path, e))
if raw:
return status, content
text = content.decode("utf-8", "replace")
if "json" in ctype:
try:
return status, json.loads(text)
except ValueError:
pass
return status, text
def get(self, path, **kw):
return self.request("GET", path, **kw)
def post(self, path, **kw):
return self.request("POST", path, **kw)
def status(self):
st, body = self.get("/status")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /status -> %s" % st)
return body
def settings(self):
st, body = self.get("/settings")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /settings -> %s" % st)
return body
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
deadline = time.time() + timeout
while time.time() < deadline:
if abort and abort():
raise HwError("aborted while waiting for idle")
try:
if self.status().get("state") == "idle":
return True
except HwError:
pass
time.sleep(poll)
return False
# ---------------------------------------------------------------- sysfs
def sysfs_root():
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
return r if r.endswith("/") else r + "/"
def sysfs_read(attr, default=None):
try:
with open(sysfs_root() + attr, "r") as f:
return f.read().strip()
except OSError:
return default
def sysfs_int(attr, default=None):
v = sysfs_read(attr)
if v is None or v == "":
return default
try:
return int(v.split()[0], 0)
except ValueError:
return default
def sysfs_write(attr, value):
with open(sysfs_root() + attr, "w") as f:
f.write(str(value))
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
script = os.path.join(base, service)
try:
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = []
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/comm" % pid) as f:
if f.read().strip() == comm:
out.append(int(pid))
except OSError:
pass
except OSError:
pass
return out
def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output)."""
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
# ------------------------------------------------------------------ grbl
class Grbl:
"""Minimal Grbl-over-TCP client. The suite is the only client while a
motion test runs; nothing here is used to poll status when a sender
may be attached (position for display comes from forgectrl)."""
def __init__(self, host=None, port=None, timeout=5.0):
self.host = host or os.environ.get("GRBL_HOST") or "127.0.0.1"
self.port = int(port or os.environ.get("GRBL_PORT") or 23)
self.timeout = timeout
self.sock = None
self.buf = b""
def __enter__(self):
self.connect()
return self
def __exit__(self, *exc):
self.close()
def connect(self):
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
# Drain the greeting.
time.sleep(0.3)
self.drain()
def close(self):
if self.sock:
try:
self.sock.close()
except OSError:
pass
self.sock = None
def drain(self):
self.sock.settimeout(0.05)
try:
while True:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except (socket.timeout, OSError):
pass
out, self.buf = self.buf, b""
return out.decode("utf-8", "replace")
def send_raw(self, data):
self.sock.settimeout(self.timeout)
self.sock.sendall(data)
def command(self, line, timeout=None):
"""Send one line, return the response lines up to ok/error."""
self.send_raw((line.strip() + "\n").encode())
return self.wait_response(timeout or self.timeout)
def wait_response(self, timeout):
deadline = time.time() + timeout
lines = []
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except socket.timeout:
pass
while b"\n" in self.buf:
raw, self.buf = self.buf.split(b"\n", 1)
s = raw.decode("utf-8", "replace").strip()
if not s:
continue
lines.append(s)
if s == "ok" or s.startswith("error:"):
return lines
return lines
def realtime(self, byte):
self.send_raw(bytes([byte]))
def status_report(self):
"""One '?' report, parsed: {'state': 'Idle', 'MPos': (x,y,z), ...}."""
self.drain()
self.send_raw(b"?")
deadline = time.time() + self.timeout
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if d:
self.buf += d
except socket.timeout:
pass
i = self.buf.find(b"<")
j = self.buf.find(b">", i + 1) if i >= 0 else -1
if i >= 0 and j > i:
rep = self.buf[i + 1:j].decode("utf-8", "replace")
self.buf = self.buf[j + 1:]
return parse_report(rep)
raise HwError("no status report from grbl")
def parse_report(rep):
parts = rep.split("|")
out = {"state": parts[0]}
for p in parts[1:]:
if ":" in p:
k, v = p.split(":", 1)
if k in ("MPos", "WPos", "WCO"):
try:
out[k] = tuple(float(x) for x in v.split(","))
except ValueError:
out[k] = v
else:
out[k] = v
return out
+79
View File
@@ -0,0 +1,79 @@
"""The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default
/data/forgetest/results.jsonl, override FORGETEST_DATA). Records:
campaign {"id","manifest_sha","catalog_hash","image"} a campaign opened
result {"campaign","test","result","fingerprint","manifest_sha",
"image","duration_s","evidence","answers","log","message"}
invalidate {"reason"} manual invalidate-all (full campaign required)
reset {"reason"} explicit campaign reset
export {"artifact_sha256","authorized","campaign"}
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
The file is only ever appended; a corrupt line is skipped, counted, and
reported, never repaired in place.
"""
import json
import os
import threading
import time
DEFAULT_DATA_DIR = "/data/forgetest"
def now_ts():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def data_dir():
return os.environ.get("FORGETEST_DATA") or DEFAULT_DATA_DIR
class Log:
def __init__(self, path=None):
self.path = path or os.path.join(data_dir(), "results.jsonl")
self._lock = threading.Lock()
self.corrupt = 0
def append(self, rec):
rec = dict(rec)
rec.setdefault("ts", now_ts())
line = json.dumps(rec, sort_keys=True, separators=(",", ":"))
with self._lock:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "a", encoding="utf-8") as f:
f.write(line + "\n")
f.flush()
os.fsync(f.fileno())
return rec
def read(self):
recs = []
self.corrupt = 0
if not os.path.exists(self.path):
return recs
with self._lock:
with open(self.path, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
self.corrupt += 1
continue
if isinstance(rec, dict) and "t" in rec:
recs.append(rec)
else:
self.corrupt += 1
return recs
def raw(self):
if not os.path.exists(self.path):
return ""
with self._lock:
with open(self.path, "r", encoding="utf-8") as f:
return f.read()
+167
View File
@@ -0,0 +1,167 @@
"""The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
identifies the build's inputs: for every component the pinned revision and
one [path, blob-id] pair per source file, plus the platform identity
(machine, kernel modules directory, device tree hashes, layer content
hashes). This module loads it and computes a test's *domain fingerprint*:
the hash of the source files its coverage globs select, plus the platform,
plus the test's own implementation. A recorded PASS applies to a build
exactly when the fingerprint recomputed from that build's manifest is the
same - the same code runs on the board and in the release gate.
"""
import hashlib
import json
import os
import re
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
# Components that ship only on the dev image. They can never be part of a
# fingerprint (the release manifest lacks them, so the gate could not
# recompute it); the test implementation is folded in separately.
DEV_ONLY_COMPONENTS = ("forgetest",)
def canonical(obj):
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def sha256_text(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def glob_to_regex(pattern):
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
stay inside one path segment. Paths use '/' (git paths)."""
out = []
i, n = 0, len(pattern)
while i < n:
c = pattern[i]
if c == "*":
if pattern[i:i + 2] == "**":
# '**/' also matches zero directories
if pattern[i:i + 3] == "**/":
out.append("(?:.*/)?")
i += 3
continue
out.append(".*")
i += 2
continue
out.append("[^/]*")
elif c == "?":
out.append("[^/]")
else:
out.append(re.escape(c))
i += 1
return re.compile("^" + "".join(out) + "$")
def match_files(files, pattern):
"""(path, blob) pairs from a component's file list that the glob selects."""
rx = glob_to_regex(pattern)
return [(p, b) for p, b in files if rx.match(p)]
class Manifest:
def __init__(self, data):
self.data = data
self.components = data.get("components", {}) or {}
self.platform = data.get("platform", {}) or {}
self.image = data.get("image", {}) or {}
self.content_sha = data.get("content_sha256")
@classmethod
def load(cls, path=None):
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
with open(path, "r", encoding="utf-8") as f:
return cls(json.load(f))
@classmethod
def from_json(cls, text):
return cls(json.loads(text))
@property
def version(self):
return self.image.get("version") or "unknown"
@property
def image_name(self):
return self.image.get("name") or "unknown"
def files(self, component):
"""The [path, blob] list of a component, or None if the component
is not in this manifest."""
c = self.components.get(component)
if c is None:
return None
return [tuple(x) for x in c.get("files", [])]
def component_names(self):
return sorted(self.components)
def identity_sha(self):
"""sha256 of the acceptance-relevant identity: every component
except the dev-only ones, plus the platform. Informational (the
gate decides per test, by fingerprint)."""
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
return sha256_text(canonical({"components": comps, "platform": self.platform}))
def fingerprint(manifest, covers, extra=()):
"""The domain fingerprint of a coverage map on a manifest.
covers: iterable of (component, glob). extra: strings folded in after
the files (the test's own implementation hash). A component the
manifest lacks contributes a marker so the fingerprint is still
defined and distinct.
"""
parts = set()
for comp, pat in covers:
if comp in DEV_ONLY_COMPONENTS:
raise ValueError("coverage may not name the dev-only component %r" % comp)
files = manifest.files(comp)
if files is None:
parts.add((comp, "@missing", ""))
continue
for p, b in match_files(files, pat):
parts.add((comp, p, b))
h = hashlib.sha256()
h.update(canonical(sorted(parts)).encode("utf-8"))
h.update(b"\n")
h.update(canonical(manifest.platform).encode("utf-8"))
for e in extra:
h.update(b"\n")
h.update(str(e).encode("utf-8"))
return h.hexdigest()
def coverage_report(manifest, tests, allow=()):
"""Which manifest paths no test covers.
tests: iterable with .covers. allow: iterable of (component, glob)
that need no coverage (docs, CI, licenses...). Returns
{component: [uncovered paths]} for the non-dev-only components.
"""
covered = {}
for t in tests:
for comp, pat in t.covers:
covered.setdefault(comp, []).append(glob_to_regex(pat))
allowed = {}
for comp, pat in allow:
allowed.setdefault(comp, []).append(glob_to_regex(pat))
report = {}
for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS:
continue
rxs = covered.get(comp, []) + allowed.get(comp, [])
star = allowed.get("*", [])
missing = []
for p, _b in manifest.files(comp):
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
continue
missing.append(p)
if missing:
report[comp] = sorted(missing)
return report
+217
View File
@@ -0,0 +1,217 @@
"""The single page: acceptance tab + bench tab. Self-contained (inline
CSS/JS, ES5, no external assets); the visual identity follows the
forgectrl control panel. State comes from GET /state on a 2 s poll; the
catalog and the bench listing are fetched once per tab and refreshed
after a run finishes."""
_HTML = r"""<!DOCTYPE html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>ForgeFIRM acceptance</title>
<style>
:root{--navy:#2b2b5e;--red:#e8262a;--blue:#0088cc;--bg:#f0f1f4;--card:#fff;--line:#dde0e6;
--txt:#222;--dim:#767a82;--ok:#3d854d;--warn:#c7760a;--inh:#5b6ab0}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px}
header{background:var(--navy);color:#fff;display:flex;align-items:center;padding:10px 16px;gap:14px}
header .app{font-size:19px;font-weight:600;letter-spacing:.3px}
header .sub{color:rgba(255,255,255,.65);font-size:13px}
header .ver{margin-left:auto;color:rgba(255,255,255,.7);font-size:13px;font-family:ui-monospace,Consolas,monospace}
nav{background:var(--card);border-bottom:1px solid var(--line);display:flex;padding:0 8px}
nav a{padding:10px 14px;color:var(--dim);text-decoration:none;border-bottom:2px solid transparent;cursor:pointer}
nav a.on{color:var(--navy);font-weight:600;border-color:var(--red)}
main{display:flex;gap:14px;padding:14px;align-items:flex-start}
#left{flex:1 1 640px;min-width:0}
#right{flex:0 0 420px;position:sticky;top:10px}
@media(max-width:1000px){main{flex-direction:column}#right{position:static;flex:1 1 auto;width:100%}}
.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:12px}
.card h2{font-size:12.5px;margin:0 0 10px;color:var(--navy);text-transform:uppercase;letter-spacing:.5px}
.banner{display:flex;flex-wrap:wrap;gap:18px;align-items:center}
.auth{font-size:20px;font-weight:700;padding:6px 14px;border-radius:6px;color:#fff;background:var(--red)}
.auth.yes{background:var(--ok)}
.kv{color:var(--dim);font-size:12.5px;line-height:1.7}
.kv b{color:var(--txt);font-weight:600}
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}
table{width:100%;border-collapse:collapse}
th{font-size:11.5px;color:var(--dim);text-align:left;font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
.tid{color:var(--dim);font-size:11.5px;font-family:ui-monospace,Consolas,monospace}
.badge{display:inline-block;font-size:10.5px;padding:2px 6px;border-radius:9px;margin-right:4px;background:#e8e9ec;color:#444;font-weight:600;letter-spacing:.2px;text-transform:uppercase}
.badge.live{background:#fbe1e1;color:#a11}
.badge.operator{background:#fdf3e3;color:#8a5200}
.badge.takeover{background:#e6e8f5;color:#33407a}
.badge.core{background:var(--navy);color:#fff}
.st{font-weight:600}
.st.pass{color:var(--ok)}.st.inherited{color:var(--inh)}.st.fail,.st.error{color:var(--red)}
.st.stale,.st.aborted{color:var(--warn)}.st.none{color:var(--dim)}.st.running{color:var(--blue)}
.req{font-size:11.5px;color:var(--warn)}
button{background:#fff;color:var(--txt);border:1px solid #c9cdd4;border-radius:4px;padding:5px 11px;font-size:13px;cursor:pointer}
button:hover{border-color:var(--navy)}
button.pri{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600}
button.pri:hover{background:#0077b3}
button.danger{background:var(--red);border-color:var(--red);color:#fff}
button:disabled{opacity:.45;cursor:default}
input[type=text],input[type=number],select{background:#fff;border:1px solid #c9cdd4;border-radius:4px;padding:5px 7px;font-size:13px}
.actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.hint{color:var(--dim);font-size:12.5px;line-height:1.55;margin:8px 0 0}
pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:10px;border-radius:4px;height:380px;overflow:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all}
#prompt{background:#fdf3e3;border:1px solid #eccb90;border-radius:6px;padding:10px 12px;margin:8px 0}
#prompt .q{font-weight:600;margin-bottom:8px}
.details{display:none;background:#f7f8fa;padding:8px 10px;border-radius:4px;font-size:12.5px;line-height:1.55;margin-top:6px}
.details.on{display:block}
.msg{color:var(--blue);font-size:13px;margin:6px 0}
.err{color:var(--red);font-size:13px;margin:6px 0}
.note{background:#fdf3e3;border:1px solid #eccb90;border-radius:6px;padding:8px 12px;margin:8px 0;font-size:13px}
.ack{display:block;margin:8px 0;font-size:12.5px}
.grp{margin-top:6px}
.tool .argrow{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0}
.tool .argrow label{font-size:12px;color:var(--dim)}
</style></head><body>
<header><span class='app'>ForgeFIRM acceptance</span><span class='sub' id='hdrsub'></span><span class='ver' id='hdrver'></span></header>
<nav><a id='tab-acceptance' class='on' onclick='showTab("acceptance")'>Release acceptance</a><a id='tab-bench' onclick='showTab("bench")'>Bench diagnostics</a></nav>
<main>
<div id='left'>
<div id='pane-acceptance'>
<div class='card'><h2>Campaign</h2>
<div class='banner'><div class='auth' id='auth'>?</div>
<div class='kv' id='banner'></div></div>
<div id='invnote'></div><div id='msgs'></div>
<div class='actions' style='margin-top:10px'>
<button class='pri' onclick='doExport()'>Export release artifact</button>
<a id='dljson' href='/export/acceptance.json' style='display:none'><button>acceptance.json</button></a>
<a id='dlmd' href='/export/acceptance.md' style='display:none'><button>acceptance.md</button></a>
<a href='/log'><button>Raw log</button></a>
<button onclick='toggleInv()'>Invalidate all&hellip;</button>
<button onclick='doReset()'>Reset campaign</button>
</div>
<div id='invform' style='display:none;margin-top:8px'>
<input type='text' id='invreason' size='60' placeholder='reason (required): what changed on the bench'>
<button class='danger' onclick='doInvalidate()'>Invalidate all results</button>
</div>
<div id='actmsg'></div>
<p class='hint'>A release is authorized when a campaign is open on this image and every catalog test is satisfied - by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign. Invalidate-all forces a full campaign; give the reason.</p>
</div>
<div id='groups'></div>
</div>
<div id='pane-bench' style='display:none'>
<div class='card'><h2>Bench diagnostics</h2>
<p class='hint'>The bench tools (scripts/bench), run on the board with the output below. Runs here never enter a campaign. Tools not yet ported are listed for completeness; live tools need the operator acknowledgment; takeover tools stop forgectrl for the duration.</p>
<div id='benchmsg'></div>
</div>
<div id='tools'></div>
</div>
</div>
<div id='right'>
<div class='card'><h2 id='runtitle'>Run</h2>
<div id='runhead' class='kv'>idle</div>
<div id='prompt' style='display:none'><div class='q' id='promptq'></div><div class='actions' id='promptb'></div></div>
<pre id='log'></pre>
<div class='actions'><button class='danger' id='abortbtn' onclick='doAbort()' disabled>Abort</button><span class='hint' id='runfoot'></span></div>
</div>
</div>
</main>
<script>
var TOKEN='__TOKEN__';
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
var lastRunKey=null;
function $(id){return document.getElementById(id)}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
function api(method,path,body,cb){var x=new XMLHttpRequest();x.open(method,path,true);x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d)};
x.send(body===undefined||body===null?null:JSON.stringify(body))}
function showTab(t){tab=t;$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
if(t==='bench'&&!bench)loadBench()}
function setMsg(id,txt,err){var e=$(id);e.innerHTML=txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":''}
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;renderBench()}})}
function poll(){api('GET','/state',null,function(s,d){if(s===200){state=d;if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
setTimeout(poll,2000)})}
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
function render(){if(!state||!catalog)return;
var m=state.manifest||{};$('hdrver').textContent=(m.version||'?');$('hdrsub').textContent=m.image||'';
var a=$('auth');a.textContent='Release authorized: '+(state.authorized?'YES':'NO');a.className='auth'+(state.authorized?' yes':'');
var c=state.campaign,cn=state.counts||{};var b='';
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> &middot; catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
if(state.log_corrupt)b+='<br><span style="color:var(--red)">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
$('banner').innerHTML=b;
var inv=state.invalidate;$('invnote').innerHTML=inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'';
var ms='';(state.messages||[]).forEach(function(x){ms+="<div class='note'>"+esc(x)+"</div>"});$('msgs').innerHTML=ms;
renderGroups();renderRun()}
function renderGroups(){var groups={},order=[];catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var busy=!!state.running;var h='';
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table><tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
groups[g].forEach(function(t){var s=state.tests[t.id]||{};var badges='';
if(t.always)badges+="<span class='badge core'>core</span>";badges+="<span class='badge "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')badges+="<span class='badge takeover'>takeover</span>";
var st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>";
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
var canStart=!busy&&s.requires_met!==false;var why=busy?'a run is in progress':(!s.requires_met?('needs: '+(s.missing_requires||[]).join(', ')):'');
var startBtn="<button class='pri' "+(canStart?'':'disabled')+" title='"+esc(why)+"' onclick='startTest(\""+esc(t.id)+"\")'>Start</button>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+esc(t.id)+"'>"+esc(t.description||'')+
(t.steps&&t.steps.length?"<br><b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":'')+"</div>";
h+="<tr><td><div>"+esc(t.title)+"</div><div class='tid'>"+esc(t.id)+" <a href='#' onclick='toggleDet(\""+esc(t.id)+"\");return false'>details</a></div>"+det+"</td><td>"+badges+"</td><td>"+st+"</td><td>"+last+"</td><td>"+startBtn+"</td></tr>"});
h+="</table></div>"});
$('groups').innerHTML=h}
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(!r){$('runhead').textContent='idle';$('log').textContent='';$('prompt').style.display='none';$('abortbtn').disabled=true;$('runtitle').textContent='Run';return}
$('runtitle').textContent=(r.kind==='bench'?'Bench: ':'Test: ')+r.title;
var hd=r.id+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(r.finished){hd+=' &middot; <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
else if(r.aborting){hd+=' &middot; <b class="st stale">aborting</b>'}
$('runhead').innerHTML=hd;
var lg=$('log');var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;lg.textContent=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight;lastRunKey=key;
if(r.prompt&&!r.finished){$('prompt').style.display='';$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o){pb+="<button class='pri' onclick='answer(\""+esc(r.prompt.id)+"\",\""+esc(o)+"\")'>"+esc(o)+"</button>"});$('promptb').innerHTML=pb}
else{$('prompt').style.display='none'}
$('abortbtn').disabled=!!r.finished;
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
var benchNeedsRefresh=false;
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
function startTest(id){var t=findTest(id);var body={test:id};
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
api('POST','/start',body,function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
function confirmLive(){return window.confirm('LIVE LASER TEST.\n\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\nStart the test?')}
function answer(pid,v){api('POST','/answer',{prompt_id:pid,value:v},function(s,d){if(s!==200)setMsg('actmsg',d.message||d.error,true)})}
function doAbort(){api('POST','/abort',{},function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
function doExport(){api('POST','/export',{},function(s,d){if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}else setMsg('actmsg',d.message||d.error,true)})}
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);if(s===200){$('invform').style.display='none';$('invreason').value=''}})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
function renderBench(){if(!bench)return;var busy=!!(state&&state.running);var groups={dry:[],takeover:[],live:[],scope:[]};
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
groups[g].forEach(function(t){var can=t.ported&&t.installed&&!busy;var why=!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':''));
h+="<div class='tool' style='border-bottom:1px solid var(--line);padding:8px 0'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='badge'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='badge'>unported</span>")+"</div>";
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
else{h+="<label>"+esc(a.name)+" <input type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"' style='width:90px'></label>"}});h+="</div>"}
h+="<div class='actions'><button class='pri' "+(can?'':'disabled')+" title='"+esc(why)+"' onclick='startTool(\""+esc(t.id)+"\")'>Start</button>";
if(t.last)h+="<span class='hint'>last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))+"</span>";
h+="</div></div>"});
h+="</div>"});
$('tools').innerHTML=h}
function startTool(id){var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
api('POST','/bench/start',body,function(s,d){setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true})}
poll();
</script></body></html>
"""
def render(token):
return _HTML.replace("__TOKEN__", token)
+448
View File
@@ -0,0 +1,448 @@
"""The runner: executes one acceptance test or one bench tool at a time.
Acceptance tests run in a worker thread with a Context: log lines, an
operator prompt channel (the page shows the question, the answer comes
back through the API), an abort flag, an evidence dict, hardware helpers,
and the takeover wrapper for tests that need forgectrl out of the way.
Results are appended to the log with the fingerprint the test ran under;
the campaign rules (campaign.py) do the rest.
Bench tools are subprocesses (bench.py registry): same single slot, same
log pane, no campaign effect.
Safety, in code rather than convention: a live test starts only with the
operator's acknowledgment in the request; the runner never touches the
laser latch; a takeover always ends with forgectrl started again, and a
marker file makes a crash mid-takeover recoverable at the next start.
"""
import os
import random
import subprocess
import threading
import time
import traceback
from . import artifact as _artifact
from . import campaign as _campaign
from . import catalog as _catalog
from . import hw
from .log import now_ts, data_dir
MAX_LINES = 4000
class Aborted(Exception):
pass
class Failed(Exception):
pass
class Run:
def __init__(self, kind, id, title):
self.kind = kind # 'test' | 'bench'
self.id = id
self.title = title
self.started = time.time()
self.started_ts = now_ts()
self.lines = []
self.dropped = 0
self.prompt = None # {"id","question","options"}
self.answers = []
self.evidence = {}
self.aborted = threading.Event()
self.finished = None # {"result","message","duration_s"}
self.proc = None
self._lock = threading.Lock()
self._cv = threading.Condition(self._lock)
self._answer = None
self._prompt_seq = 0
def log(self, msg):
line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
with self._lock:
if len(self.lines) >= MAX_LINES:
self.lines.pop(0)
self.dropped += 1
self.lines.append(line)
def snapshot(self, tail=200):
with self._lock:
lines = self.lines[-tail:]
prompt = dict(self.prompt) if self.prompt else None
return {
"kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
"log": lines, "dropped": self.dropped, "prompt": prompt,
"finished": self.finished, "aborting": self.aborted.is_set(),
}
# -- prompt channel -----------------------------------------------
def ask(self, question, options):
with self._cv:
self._prompt_seq += 1
pid = "p%d" % self._prompt_seq
self.prompt = {"id": pid, "question": question, "options": list(options)}
self._answer = None
while self._answer is None and not self.aborted.is_set():
self._cv.wait(0.5)
self.prompt = None
if self.aborted.is_set() and self._answer is None:
raise Aborted("aborted at prompt")
ans = self._answer
self._answer = None
self.answers.append({"ts": now_ts(), "question": question, "answer": ans})
return ans
def answer(self, prompt_id, value):
with self._cv:
if not self.prompt or self.prompt["id"] != prompt_id:
return False
if value not in self.prompt["options"]:
return False
self._answer = value
self._cv.notify_all()
return True
def abort(self):
self.aborted.set()
with self._cv:
self._cv.notify_all()
p = self.proc
if p is not None and p.poll() is None:
try:
p.terminate()
except OSError:
pass
class Context:
"""What a test function gets."""
def __init__(self, run, runner, test):
self.run = run
self.runner = runner
self.test = test
self.evidence = run.evidence
self._forgectrl = None
# -- reporting -----------------------------------------------------
def log(self, msg, *args):
self.run.log(msg % args if args else msg)
def check(self, cond, msg, *args):
if not cond:
raise Failed(msg % args if args else msg)
def fail(self, msg, *args):
raise Failed(msg % args if args else msg)
def aborted(self):
return self.run.aborted.is_set()
def checkpoint(self):
if self.aborted():
raise Aborted("aborted")
def sleep(self, seconds):
deadline = time.time() + seconds
while time.time() < deadline:
self.checkpoint()
time.sleep(min(0.25, max(0.0, deadline - time.time())))
# -- operator ------------------------------------------------------
def prompt(self, question, options=("Yes", "No")):
self.log("PROMPT: %s", question)
ans = self.run.ask(question, options)
self.log("ANSWER: %s", ans)
return ans
def confirm(self, question):
"""Yes/No question; a No is a test failure."""
ans = self.prompt(question, ("Yes", "No"))
if ans != "Yes":
raise Failed("operator answered No: %s" % question)
return True
def instruct(self, text):
"""A step for the operator; continues on Done, fails on Cannot."""
ans = self.prompt(text, ("Done", "Cannot"))
if ans != "Done":
raise Failed("operator could not: %s" % text)
# -- hardware ------------------------------------------------------
@property
def forgectrl(self):
if self._forgectrl is None:
self._forgectrl = hw.Forgectrl()
return self._forgectrl
def sysfs(self, attr, default=None):
return hw.sysfs_read(attr, default)
def sysfs_int(self, attr, default=None):
return hw.sysfs_int(attr, default)
def grbl(self):
return hw.Grbl()
def takeover(self):
return Takeover(self.run.log, self.test.id)
class Takeover:
"""Hardware takeover: the controller is stopped through the supervisor,
forgectrl is stopped, the marker records the ownership, and forgectrl
is started again on every exit path. Used by takeover tests (through
Context.takeover()) and by takeover bench tools."""
def __init__(self, log, who):
self.log = log # callable(str)
self.who = who
self.marker = marker_path()
def __enter__(self):
log = self.log
log("takeover: stopping the controller through forgectrl")
try:
st, body = hw.Forgectrl().post("/controller/stop")
log("takeover: POST /controller/stop -> %s" % st)
except hw.HwError as e:
log("takeover: forgectrl unreachable (%s)" % e)
with open(self.marker, "w") as f:
f.write("%s %s\n" % (now_ts(), self.who))
rc, out = hw.initd("forgectrl", "stop")
log("takeover: forgectrl stop -> rc %s" % rc)
deadline = time.time() + 15
while time.time() < deadline and (hw.pidof("forgectrl") or hw.pidof("grblHAL_glowfor")):
time.sleep(0.5)
left = hw.pidof("forgectrl") + hw.pidof("grblHAL_glowfor")
if left:
self.__exit__(None, None, None)
raise Failed("takeover: processes still alive after stop: %s" % left)
log("takeover: pulse device free")
return self
def __exit__(self, exc_type, exc, tb):
rc, out = hw.initd("forgectrl", "start")
self.log("takeover: forgectrl start -> rc %s" % rc)
try:
os.remove(self.marker)
except OSError:
pass
return False
def marker_path():
return os.environ.get("FORGETEST_MARKER") or "/run/forgetest.active"
class Runner:
def __init__(self, log, manifest, registry, bench=None):
self.log = log
self.manifest = manifest
self.registry = registry
self.bench = bench
self.catalog_hash = _catalog.catalog_hash(registry)
self._lock = threading.Lock()
self.current = None
self.last = None
self.messages = []
self.recover()
# -- startup recovery ------------------------------------------------
def recover(self):
m = marker_path()
if os.path.exists(m):
try:
with open(m) as f:
who = f.read().strip()
except OSError:
who = "?"
self.messages.append("recovered a takeover left by '%s': starting forgectrl" % who)
hw.initd("forgectrl", "start")
try:
os.remove(m)
except OSError:
pass
# -- state -----------------------------------------------------------
def tests(self):
return _catalog.all_tests(self.registry)
def running_id(self):
r = self.current
return r.id if r and r.kind == "test" and not r.finished else None
def state(self):
records = self.log.read()
st = _campaign.compute(records, self.tests(), self.manifest, self.catalog_hash,
running=self.running_id())
st["catalog_hash"] = self.catalog_hash
st["manifest"] = {"sha": self.manifest.content_sha, "identity": self.manifest.identity_sha(),
"image": self.manifest.image_name, "version": self.manifest.version}
st["log_corrupt"] = self.log.corrupt
st["messages"] = list(self.messages)
r = self.current
st["running"] = r.snapshot() if r and not r.finished else None
last = r if (r and r.finished) else self.last
st["last_run"] = last.snapshot() if last else None
return st, records
def busy(self):
r = self.current
return bool(r and not r.finished)
# -- campaign actions -------------------------------------------------
def _open_campaign_if_needed(self, state):
if state["campaign"]:
return state["campaign"]
cid = "c-%s-%04x" % (time.strftime("%Y%m%d%H%M%S", time.gmtime()), random.randrange(1 << 16))
rec = self.log.append({"t": "campaign", "id": cid, "manifest_sha": self.manifest.content_sha,
"catalog_hash": self.catalog_hash, "image": self.manifest.version})
return rec
def invalidate(self, reason):
reason = (reason or "").strip()
if not reason:
return False, "a reason is required"
if self.busy():
return False, "a run is in progress"
self.log.append({"t": "invalidate", "reason": reason})
return True, "all results invalidated; a full campaign is required"
def reset(self, reason=""):
if self.busy():
return False, "a run is in progress"
self.log.append({"t": "reset", "reason": (reason or "").strip()})
return True, "campaign reset"
def export(self):
state, records = self.state()
art = _artifact.build(state, self.tests(), self.manifest, records, self.catalog_hash)
self.log.append({"t": "export", "artifact_sha256": art["sha256"], "authorized": art["authorized"],
"campaign": (state["campaign"] or {}).get("id")})
return art
# -- starting -----------------------------------------------------------
def start_test(self, test_id, ack_live=False):
t = _catalog.get(test_id, self.registry)
if t is None:
return False, "unknown test"
with self._lock:
if self.busy():
return False, "a run is in progress"
state, _ = self.state()
ts = state["tests"][t.id]
if not ts["requires_met"]:
return False, "prerequisites not satisfied: %s" % ", ".join(ts["missing_requires"])
if t.kind == "live" and not ack_live:
return False, "live test: acknowledge eye protection, fire watch, and exhaust first"
campaign = self._open_campaign_if_needed(state)
run = Run("test", t.id, t.title)
self.last = self.current
self.current = run
run.log("start %s (%s, %s) in campaign %s" % (t.id, t.kind, t.hardware, campaign["id"]))
if t.kind == "live":
run.evidence["operator"] = {"ack_live": True, "ts": now_ts()}
th = threading.Thread(target=self._exec_test, args=(t, run, campaign), daemon=True,
name="forgetest-run")
th.start()
return True, "started"
def _exec_test(self, t, run, campaign):
ctx = Context(run, self, t)
fp = t.fingerprint(self.manifest)
result, message = _campaign.PASS, ""
try:
t.fn(ctx)
if run.aborted.is_set():
result, message = _campaign.ABORTED, "aborted"
except Aborted as e:
result, message = _campaign.ABORTED, str(e) or "aborted"
except Failed as e:
result, message = _campaign.FAIL, str(e)
except Exception as e: # noqa: BLE001 - an erroring test is a failed test
result, message = _campaign.ERROR, "%s: %s" % (type(e).__name__, e)
run.log(traceback.format_exc().rstrip())
duration = int(time.time() - run.started)
run.log("result %s%s" % (result, (": " + message) if message else ""))
rec = {"t": "result", "campaign": campaign["id"], "test": t.id, "result": result,
"fingerprint": fp, "manifest_sha": self.manifest.content_sha,
"image": self.manifest.version, "duration_s": duration, "message": message,
"evidence": run.evidence, "answers": run.answers, "log": list(run.lines)}
self.log.append(rec)
run.finished = {"result": result, "message": message, "duration_s": duration}
# -- bench tools ---------------------------------------------------------
def start_bench(self, tool_id, args=None, ack_live=False):
if self.bench is None:
return False, "no bench registry"
tool = self.bench.get(tool_id)
if tool is None:
return False, "unknown tool"
if not tool.get("ported"):
return False, "tool not yet ported to the bench page"
ok, argv, err = self.bench.command(tool, args or {})
if not ok:
return False, err
if tool.get("safety") == "live" and not ack_live:
return False, "live tool: acknowledge eye protection, fire watch, and exhaust first"
with self._lock:
if self.busy():
return False, "a run is in progress"
run = Run("bench", tool["id"], tool["title"])
self.last = self.current
self.current = run
run.log("bench %s: %s" % (tool["id"], " ".join(argv)))
th = threading.Thread(target=self._exec_bench, args=(tool, run, argv, args or {}),
daemon=True, name="forgetest-bench")
th.start()
return True, "started"
def _exec_bench(self, tool, run, argv, args):
rc = None
message = ""
try:
env = dict(os.environ)
env.setdefault("PYTHONUNBUFFERED", "1")
takeover = Takeover(run.log, "bench:" + tool["id"]) if tool.get("safety") == "takeover" else None
if takeover is not None:
takeover.__enter__()
try:
run.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env, cwd=self.bench.tool_dir())
for raw in iter(run.proc.stdout.readline, b""):
run.log(raw.decode("utf-8", "replace").rstrip())
run.proc.stdout.close()
rc = run.proc.wait()
finally:
if takeover is not None:
takeover.__exit__(None, None, None)
if run.aborted.is_set():
message = "aborted"
except Exception as e: # noqa: BLE001
message = "%s: %s" % (type(e).__name__, e)
run.log(message)
duration = int(time.time() - run.started)
result = "ABORTED" if run.aborted.is_set() else ("OK" if rc == 0 else "EXIT %s" % rc)
run.log("bench %s finished: %s" % (tool["id"], result))
run.finished = {"result": result, "message": message, "duration_s": duration, "rc": rc}
self.bench.record(tool, args, run)
# -- control --------------------------------------------------------------
def answer(self, prompt_id, value):
r = self.current
if not r or r.finished:
return False, "nothing is running"
if r.answer(prompt_id, value):
return True, "answered"
return False, "no such prompt (or the answer is not one of the options)"
def abort(self):
r = self.current
if not r or r.finished:
return False, "nothing is running"
r.abort()
return True, "abort requested"
+263
View File
@@ -0,0 +1,263 @@
"""HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an
address literal or localhost (no DNS names - the rebinding vehicle), a
cross-site Sec-Fetch-Site is refused, an Origin must itself be a literal,
and every state-changing call needs the bearer token that is generated on
first start, stored 0600 under the data directory, and embedded in the
page. Read-only calls need the origin checks only.
Routes
GET / the page
GET /state campaign + per-test state + running run
GET /catalog test definitions (title, steps, covers...)
GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence)
GET /log the raw JSONL
GET /export/acceptance.json | .md the last export
POST /start {test, ack_live} start an acceptance test
POST /bench/start {tool, args, ack_live}
POST /answer {prompt_id, value}
POST /abort
POST /invalidate {reason}
POST /reset {reason}
POST /export build + save the artifact, returns it
"""
import hmac
import json
import os
import re
import secrets
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import artifact as _artifact
from . import page as _page
from .log import data_dir
TOKEN_HEX = 32
_LITERAL_RX = re.compile(r"^[0-9.]+$")
def load_token(path=None):
path = path or os.path.join(data_dir(), "token")
try:
with open(path, "r", encoding="utf-8") as f:
tok = f.read().strip()
if len(tok) == TOKEN_HEX and all(c in "0123456789abcdef" for c in tok):
return tok
except OSError:
pass
tok = secrets.token_hex(TOKEN_HEX // 2)
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(tok + "\n")
return tok
def host_is_literal(h):
if not h:
return False
if h.startswith("["):
return True
host = h.split(":", 1)[0]
if host == "localhost":
return True
return bool(_LITERAL_RX.match(host))
def origin_ok(headers):
if not host_is_literal(headers.get("Host")):
return False
sfs = headers.get("Sec-Fetch-Site")
if sfs and sfs not in ("same-origin", "none"):
return False
origin = headers.get("Origin")
if origin and origin != "null":
p = urllib.parse.urlsplit(origin)
if not host_is_literal(p.netloc):
return False
return True
class App:
"""What the handler needs: the runner, the token, the export dir."""
def __init__(self, runner, token, export_dir=None):
self.runner = runner
self.token = token
self.export_dir = export_dir or os.path.join(data_dir(), "export")
def token_ok(self, given):
return bool(given) and hmac.compare_digest(given, self.token)
class Handler(BaseHTTPRequestHandler):
server_version = "forgetest"
app = None # set on the server class
# -- plumbing ---------------------------------------------------------
def log_message(self, fmt, *args):
if os.environ.get("FORGETEST_HTTP_LOG"):
sys.stderr.write("forgetest: %s - %s\n" % (self.address_string(), fmt % args))
def _send(self, status, body, ctype="application/json", extra=None):
if isinstance(body, (dict, list)):
body = json.dumps(body, sort_keys=True).encode("utf-8")
elif isinstance(body, str):
body = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _deny(self, status, msg):
self._send(status, {"error": msg})
def _read_ok(self):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
return True
def _write_ok(self, query):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
tok = self.headers.get("X-ForgeFIRM-Token") or (query.get("token") or [None])[0]
if not self.app.token_ok(tok):
self._deny(403, "authentication required")
return False
return True
def _body_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
return {}
if n > 1 << 20:
raise ValueError("body too large")
raw = self.rfile.read(n)
ctype = self.headers.get("Content-Type", "")
if "json" in ctype:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
# form-encoded fallback
return {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
# -- GET ---------------------------------------------------------------
def do_HEAD(self):
self.do_GET()
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
if not self._read_ok():
return
try:
if path == "/":
self._send(200, _page.render(self.app.token), "text/html")
elif path == "/state":
state, _ = r.state()
self._send(200, state)
elif path == "/catalog":
self._send(200, {"tests": [t.describe() for t in r.tests()],
"catalog_hash": r.catalog_hash})
elif path == "/bench":
b = r.bench
self._send(200, {"tools": b.listing() if b else [], "tool_dir": b.tool_dir() if b else None})
elif path == "/result":
test = (query.get("test") or [""])[0]
ts = (query.get("ts") or [""])[0]
rec = None
for x in reversed(r.log.read()):
if x.get("t") == "result" and x.get("test") == test and (not ts or x.get("ts") == ts):
rec = x
break
if rec is None:
self._deny(404, "no such result")
else:
self._send(200, rec)
elif path == "/log":
self._send(200, r.log.raw(), "text/plain")
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
fn = os.path.join(self.app.export_dir, os.path.basename(path))
if not os.path.exists(fn):
self._deny(404, "nothing exported yet")
return
with open(fn, "rb") as f:
data = f.read()
ctype = "application/json" if path.endswith(".json") else "text/markdown"
self._send(200, data, ctype, {"Content-Disposition": "attachment; filename=%s"
% os.path.basename(path)})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
# -- POST -----------------------------------------------------------------
def do_POST(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
if not self._write_ok(query):
return
try:
body = self._body_json()
except ValueError as e:
self._deny(400, "bad body: %s" % e)
return
try:
if path == "/start":
ok, msg = r.start_test(str(body.get("test", "")), ack_live=bool(body.get("ack_live")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/bench/start":
args = body.get("args") or {}
if not isinstance(args, dict):
self._deny(400, "args must be an object")
return
ok, msg = r.start_bench(str(body.get("tool", "")), args, ack_live=bool(body.get("ack_live")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/answer":
ok, msg = r.answer(str(body.get("prompt_id", "")), str(body.get("value", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/abort":
ok, msg = r.abort()
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/invalidate":
ok, msg = r.invalidate(str(body.get("reason", "")))
self._send(200 if ok else 400, {"ok": ok, "message": msg})
elif path == "/reset":
ok, msg = r.reset(str(body.get("reason", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/export":
art = r.export()
os.makedirs(self.app.export_dir, exist_ok=True)
with open(os.path.join(self.app.export_dir, "acceptance.json"), "w", encoding="utf-8") as f:
f.write(_artifact.to_json(art))
with open(os.path.join(self.app.export_dir, "acceptance.md"), "w", encoding="utf-8") as f:
f.write(_artifact.to_markdown(art))
self._send(200, {"ok": True, "authorized": art["authorized"], "sha256": art["sha256"],
"counts": art["counts"]})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
def make_server(app, host="0.0.0.0", port=8090):
handler = type("ForgetestHandler", (Handler,), {"app": app})
ThreadingHTTPServer.allow_reuse_address = True
srv = ThreadingHTTPServer((host, port), handler)
srv.daemon_threads = True
return srv
+13
View File
@@ -0,0 +1,13 @@
"""The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402
+52
View File
@@ -0,0 +1,52 @@
"""camera.* - the lid camera pipeline through forgectrl."""
from ..catalog import test
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="operator", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Lid closed. You will be asked to look at the control panel's Status tab."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), the MJPEG stream starts and stops, and the operator confirms "
"the panel shows the bed.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("cam status: %s", body)
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=half -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200, "snapshot -> %s %s", st, data[:120] if data else "")
ctx.check(data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", "snapshot is not a complete JPEG")
ev["snapshot_bytes"] = len(data)
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
ev["snapshot_full_bytes"] = len(data)
# the stream: fetch a little of it and let it close
import urllib.request
req = urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()})
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctype = r.headers.get("Content-Type", "")
chunk = r.read(65536)
except Exception as e: # noqa: BLE001
ctx.fail("stream did not open: %s", e)
ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
ctx.log("cam status after: %s", body)
ctx.confirm("Open the control panel (port 8080), Status tab: does the lid snapshot show the bed "
"(not black, not frozen, roughly the right orientation)?")
+163
View File
@@ -0,0 +1,163 @@
"""cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner)."""
import json
import os
import socket
import time
from ..catalog import test
from .. import hw
_CLOUD_COVERS = [("forgefirm-app", "**"), ("python3-gfhardware", "**"), ("python3-gfutilities", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/main.c")]
GF_LATEST = "/data/forgefirm/gf-latest.json"
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
t0 = time.time()
last = None
while time.time() - t0 < timeout:
ctx.checkpoint()
st, m = fc.get("/mode")
if st == 200 and isinstance(m, dict):
last = m
if m.get("mode") == want_mode and m.get("controller") == want_controller:
return m
if m.get("controller") == "motion-fault":
break
time.sleep(1)
return last
def grbl_port_open(timeout=5):
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud",
kind="auto", est_min=4,
covers=_CLOUD_COVERS, requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear (the supervisor's liveness probe may jog the head a few mm on a "
"controller spawn). Cloud credentials configured; the machine on the network."],
description="POST /mode switches to the cloud controller: gfcloud comes up under "
"supervision and records its connect-time service probe (/status gfsvc); the "
"camera service survives the switch; switching back brings grblHAL up with the "
"Grbl port open and Idle.")
def mode_switch(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict), "GET /mode -> %s", st)
ev["mode_before"] = m0
ctx.log("mode before: %s", m0)
ctx.check(m0.get("mode") == "grbl", "start this test in grbl mode (now %s)", m0.get("mode"))
st, cam0 = fc.get("/cam/status")
ev["cam_before"] = cam0
probe_before = None
try:
probe_before = os.stat(GF_LATEST).st_mtime
except OSError:
pass
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
ev["mode_cloud"] = m
ctx.log("mode after switch: %s", m)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
# the connect-time service probe is the evidence of a live cloud session
t0 = time.time()
probe = None
while time.time() - t0 < 120:
ctx.checkpoint()
try:
mt = os.stat(GF_LATEST).st_mtime
if probe_before is None or mt > probe_before:
with open(GF_LATEST) as f:
probe = json.load(f)
break
except (OSError, ValueError):
pass
time.sleep(2)
ev["gf_probe"] = probe
ctx.log("cloud service probe: %s", probe)
st, cam1 = fc.get("/cam/status")
ev["cam_during_cloud"] = cam1
ctx.check(st == 200 and isinstance(cam1, dict), "camera status lost during cloud mode (%s)", st)
st, body = fc.post("/mode", data={"controller": "grbl"})
ctx.log("POST /mode controller=grbl -> %s %s", st, body)
ctx.check(st == 200, "mode switch back to grbl refused: %s %s", st, body)
m = wait_mode(ctx, fc, "grbl", timeout=120)
ev["mode_after"] = m
ctx.log("mode after switch back: %s", m)
ctx.check(m and m.get("mode") == "grbl" and m.get("controller") == "running",
"grbl controller did not come back: %s", m)
ctx.check(m.get("motion") != "fault", "motion fault after the switch")
ctx.sleep(3)
ctx.check(grbl_port_open(), "Grbl port not open after the switch back")
with ctx.grbl() as g:
st = g.status_report()["state"]
ev["grbl_state"] = st
ctx.log("grbl state after: %s", st)
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "grbl reports %s", st)
ctx.check(probe is not None, "the cloud controller never recorded a service probe "
"(no credentials, no network, or the service refused) - cloud mode not proven")
st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back")
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)",
subsystem="cloud", kind="operator", est_min=5,
covers=_CLOUD_COVERS + [("grblhal-glowforge", "src/**")], requires=["cloud.mode-switch"],
steps=["homing_mode = gfcloud and cloud credentials configured; bed clear, lid closed.",
"Watch the gantry: the service drives it to the corner with camera corrections."],
description="In grbl mode, $H runs gfhome: the web-service homing session with the "
"head-accelerometer motion witness. The controller returns to Idle with "
"homed:true within the session timeout, and the operator confirms the head "
"reached the home corner.")
def gfhome_homing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
settings = fc.settings()
hm = settings.get("homing_mode")
ev["homing_mode"] = hm
ctx.check(hm == "gfcloud", "homing_mode is %r, this test needs gfcloud", hm)
ctx.instruct("Bed clear, lid closed, head anywhere. Watch the gantry during homing.")
with ctx.grbl() as g:
st = g.status_report()["state"]
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "controller is %s", st)
if st.startswith("Alarm"):
g.command("$X")
t0 = time.time()
g.send_raw(b"$H\n")
homed = False
state = None
while time.time() - t0 < 600:
ctx.checkpoint()
s = fc.status()
state = s.get("state")
try:
gs = g.status_report()["state"]
except hw.HwError:
gs = "?"
if s.get("homed") and gs.startswith("Idle"):
homed = True
break
if gs.startswith("Alarm"):
break
time.sleep(2)
ev["homing_s"] = round(time.time() - t0, 1)
ev["homed"] = homed
ctx.log("homing: homed=%s after %.1f s (kernel %s, grbl %s)", homed, ev["homing_s"], state, gs)
ctx.check(homed, "homing did not complete (grbl %s)", gs)
ctx.confirm("Did the head travel to the home corner under camera corrections and stop there?")
+114
View File
@@ -0,0 +1,114 @@
"""cooling.* - the cooling engine: flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs), and the fan
profile returning to idle after motion."""
import time
from ..catalog import test
from .. import hw
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/diag.*"),
("grblhal-glowforge", "src/gfcool*"), ("kernel-module-glowforge", "src/thermal*"),
("kernel-module-glowforge", "src/pic*")]
@test("cooling.flow-verify", title="Coolant flow check separates flow from no-flow",
subsystem="cooling", kind="auto", est_min=4,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
"forgectrl for the duration (about 3 minutes)."],
description="forgectrl's flow-verify diagnostic: one heater window with the pump on and "
"one with it commanded off, judged against the configured threshold. PASS = "
"the threshold separates the two readings with the margins forgectrl reports; "
"a thin margin is recorded as a warning.")
def flow_verify(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/diag/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
st, body = fc.post("/diag/flow-verify")
ctx.log("POST /diag/flow-verify -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "could not start flow-verify (%s %s)", st, body)
last_phase = None
result = None
t0 = time.time()
try:
while time.time() - t0 < 900:
ctx.checkpoint()
st, d = fc.get("/diag/status")
if st == 200 and isinstance(d, dict):
if d.get("phase") != last_phase:
last_phase = d.get("phase")
ctx.log("phase: %s (down %.1f C, up %.1f C)", last_phase, d.get("down_c", 0), d.get("up_c", 0))
if not d.get("running") and d.get("result") is not None:
result = d.get("result")
for line in d.get("log", [])[-12:]:
ctx.log(" diag: %s", line)
break
time.sleep(2)
except BaseException:
fc.post("/diag/abort")
raise
ctx.check(result is not None, "flow-verify did not finish within 15 minutes")
ev["result"] = result
ctx.check("error" not in result, "flow-verify error: %s", result.get("error"))
ctx.log("verdict: pass=%s threshold=%s flow_rise=%s noflow_rise=%s margins %s/%s thin=%s",
result.get("pass"), result.get("threshold"), result.get("flow_rise"),
result.get("noflow_rise"), result.get("margin_flow"), result.get("margin_noflow"),
result.get("thin_margin"))
ctx.check(result.get("pass") is True, "the threshold does not separate flow from no-flow: %s", result)
if result.get("thin_margin"):
ctx.log("WARNING: thin margin - run flow-calibrate")
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
@test("cooling.fans-quiet-after-motion", title="Fan profile returns to idle after motion and after M8/M9",
subsystem="cooling", kind="auto", est_min=2,
covers=_COOL_COVERS + [("forgectrl", "src/super.c")], requires=["motion.pacing"],
steps=["Bed clear; the head needs 20 mm of free +X travel."],
description="A dry jog and an M8/M9 cycle must not leave the run fan profile on: within "
"the cooldown the exhaust/intake tachs return to the idle level seen before.")
def fans_quiet(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
def fans():
s = fc.status()
return dict(s.get("fans") or {})
before = fans()
ev["before"] = before
ctx.log("fans before: %s", before)
with ctx.grbl() as g:
st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
g.command("G91")
g.command("$J=G91X20F2400")
t0 = time.time()
while time.time() - t0 < 30 and not g.status_report()["state"].startswith("Idle"):
ctx.sleep(0.2)
g.command("$J=G91X-20F2400")
while time.time() - t0 < 60 and not g.status_report()["state"].startswith("Idle"):
ctx.sleep(0.2)
g.command("M8")
ctx.sleep(3)
during = fans()
ev["during_m8"] = during
ctx.log("fans during M8: %s", during)
g.command("M9")
g.command("G90")
# cooldown: forgectrl's engine takes cool_cooldown_s (default tens of seconds)
settle = None
t0 = time.time()
while time.time() - t0 < 240:
ctx.sleep(5)
now = fans()
close = all(abs(now.get(k, 0) - before.get(k, 0)) <= max(150, 0.15 * max(before.get(k, 0), 1))
for k in ("exhaust", "intake_1", "intake_2"))
if close:
settle = time.time() - t0
break
ev["after"] = fans()
ev["settle_s"] = round(settle, 1) if settle is not None else None
ctx.log("fans after: %s (settled in %s s)", ev["after"], ev["settle_s"])
ctx.check(settle is not None, "fans did not return to the idle profile within 240 s: %s", ev["after"])
+182
View File
@@ -0,0 +1,182 @@
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import socket
from ..catalog import test
from .. import hw
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/main.c")]
def lan_ip():
"""The board's own non-loopback IPv4 (the address a LAN client would
use), or None."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 9))
ip = s.getsockname()[0]
except OSError:
ip = None
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
return None
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write; a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
"cooling report channel refuses a non-loopback peer; the fuse view is token-gated; "
"the flash and factory-restore chain is refused unauthenticated.")
def auth(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/status")
ctx.log("GET /status -> %s", st)
ctx.check(st == 200, "GET /status -> %s", st)
# unauthenticated writes: every one must be refused before it acts
for path, params in (("/controller/stop", None), ("/controller/start", None),
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"slot": "a"}),
("/system/reboot", None), ("/restore/factory", None)):
st, body = fc.post(path, params=params, auth=False)
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
ev["noauth " + path] = st
ctx.check(st == 403, "POST %s without a token -> %s, expected 403", path, st)
ctx.check(isinstance(body, dict) and body.get("error") == "authentication required",
"POST %s without a token: unexpected body %r", path, body)
# the upload sink refuses during body parse; only the status is asserted
st, body = fc.post("/update/upload", data=b"not a firmware archive", auth=False,
headers={"Content-Type": "application/octet-stream"})
ev["noauth /update/upload"] = st
ctx.log("POST /update/upload (no token) -> %s", st)
ctx.check(st in (400, 403), "POST /update/upload without a token -> %s", st)
# origin checks (read endpoint, so only the origin layer decides)
st, body = fc.get("/status", headers={"Host": "evil.example.net"})
ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Origin was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "cross-site"})
ev["sfs_cross"] = st
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1:8080"})
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
# the token opens the fuse view; without it, refused
st, body = fc.get("/fuse-identity", auth=False)
ev["fuse_noauth"] = st
ctx.check(st == 403, "GET /fuse-identity without token -> %s", st)
st, body = fc.get("/fuse-identity")
ev["fuse_auth"] = st
ctx.log("GET /fuse-identity with token -> %s", st)
ctx.check(st == 200, "GET /fuse-identity with the token -> %s", st)
# the cooling report channel: loopback only, even with a token
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
port = fc.base.rsplit(":", 1)[-1]
lan = hw.Forgectrl("http://%s:%s" % (ip, port), token=fc.token)
st, body = lan.post("/cool/state", params={"mode": "idle", "armed": "0"})
ev["cool_state_from_lan"] = st
ctx.log("POST /cool/state from %s -> %s %s", ip, st, body if isinstance(body, dict) else "")
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "loopback only",
"/cool/state accepted a non-loopback peer (%s %r)", st, body)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.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).")
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)
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", "mm"
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)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/status.*"), ("forgectrl", "src/cam.c"),
("forgectrl", "src/main.c")],
description="The panel page is served, /status carries the machine telemetry the panel and "
"the acceptance tool read, and /cam/status answers.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
s = fc.status()
for key in ("state", "switches", "coolant", "fans"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
+156
View File
@@ -0,0 +1,156 @@
"""image.* - the post-flash health of the running image (always-required)."""
import glob
import gzip
import os
import re
import stat
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
def kernel_config():
"""The running kernel's config as a dict, from /proc/config.gz or
/boot/config-<release>. None when neither is available."""
try:
with gzip.open("/proc/config.gz", "rb") as f:
text = f.read().decode("utf-8", "replace")
except OSError:
text = None
if text is None:
rel = _read("/proc/sys/kernel/osrelease", "").strip()
text = _read("/boot/config-%s" % rel) if rel else None
if text is None:
return None
cfg = {}
for line in text.splitlines():
m = re.match(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", line)
if m:
cfg[m.group(1)] = m.group(2)
m = re.match(r"^# (CONFIG_[A-Z0-9_]+) is not set$", line)
if m:
cfg[m.group(1)] = "n"
return cfg
def fds_of(pid):
out = []
try:
for fd in os.listdir("/proc/%d/fd" % pid):
try:
out.append(os.readlink("/proc/%d/fd/%s" % (pid, fd)))
except OSError:
pass
except OSError:
pass
return out
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the daemon ownership, the init ordering, and the "
"file modes the release depends on.")
def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
# 1. version stamp
ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
# 2. kernel options
cfg = kernel_config()
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
for opt, want in (("CONFIG_PREEMPT", ("y",)), ("CONFIG_IMX2_WDT", ("y", "m")),
("CONFIG_PANIC_ON_OOPS", ("y",))):
val = cfg.get(opt, "n")
ev[opt] = val
ctx.log("%s=%s", opt, val)
ctx.check(val in want, "%s=%s, expected one of %s", opt, val, want)
rel = _read("/proc/sys/kernel/osrelease", "").strip()
ev["kernel_release"] = rel
mods = manifest.platform.get("kernel_modules") or []
ctx.log("kernel release %s (manifest modules dirs: %s)", rel, ",".join(mods))
ctx.check(not mods or rel in mods, "running kernel %r is not the manifest's %s", rel, mods)
# 3. the module and its sysfs
ctx.check(os.path.isdir("/sys/module/glowforge"), "glowforge.ko is not loaded")
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.check(state is not None, "/sys/glowforge/cnc/state unreadable")
ctx.log("cnc/state: %s", state)
free = hw.sysfs_int("cnc/free")
ev["cnc_free"] = free
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
ctx.log("cnc/free: %s bytes", free)
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
# 4. forgectrl holds /dev/glowforge and supervises the controller
pids = hw.pidof("forgectrl")
ev["forgectrl_pids"] = pids
ctx.check(pids, "forgectrl is not running")
holders = [p for p in pids if any(l == "/dev/glowforge" for l in fds_of(p))]
ev["pulse_device_holders"] = holders
ctx.log("forgectrl pids %s, holding /dev/glowforge: %s", pids, holders)
ctx.check(holders, "no forgectrl process holds /dev/glowforge")
st, mode = ctx.forgectrl.get("/mode")
ev["mode"] = mode
ctx.check(st == 200 and isinstance(mode, dict), "GET /mode -> %s", st)
ctx.log("mode: %s", mode)
ctx.check(mode.get("controller") in ("running", "standby"),
"controller is %r (expected running or standby)", mode.get("controller"))
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
# 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])
# 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
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)
# 7. file modes and space
for path in ("/data/forgefirm/panel.token", "/data/forgefirm.conf"):
if os.path.exists(path):
m = stat.S_IMODE(os.stat(path).st_mode)
ev[path] = "%o" % m
ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
if os.path.isdir("/data"):
s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
ev["data_free_mb"] = free_mb
ctx.log("/data free: %d MiB", free_mb)
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
# 8. the manifest itself is coherent
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
+486
View File
@@ -0,0 +1,486 @@
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
(K1/K2/K3) and `scripts/bench/fire_test.py` (A/B/U) with their proven
sequences kept intact; they run under a hardware takeover (forgectrl and
the controller stopped, the pulse device free) and judge the software
witnesses: cnc/state, laser_enable (the FIRE line), laser_on and
laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3
(the commanded latch), faults and underruns. Every drill forces duty to
zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and
re-locks the latch on every exit path. K3 and fire B/U unlock the latch
for their run and therefore refuse to proceed while laser_pgood reports
the HV supply good (the operator opens the lid: the safety chain holds
HV off).
"""
import errno
import os
import struct
import time
try:
import fcntl
except ImportError: # host unit tests import the suite off-target
fcntl = None
from ..catalog import test
from .. import hw
from ..runner import Failed
# interlock_circuit bits (UAPI.md): bit 3 = the driven latch line, set = locked.
LATCH_BIT = 1 << 3
TICK_HZ = 10000
FIRE = b"\x10"
PAD = b"\x00"
XSTEP = b"\x01"
POWER0 = bytes([0x80]) # power byte, duty 0
_KERNEL_COVERS = [("kernel-module-glowforge", "**"), ("linux-fslc", "**")]
# ---------------------------------------------------------------- helpers
def wr(attr, val):
hw.sysfs_write(attr, val)
def rd(attr):
v = hw.sysfs_read(attr)
if v is None:
raise Failed("cannot read %s" % attr)
return v
def rd_pos():
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
raw = f.read(32)
return struct.unpack("<5i", raw[:20])
def snap(ctx, tag):
line = ("%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s"
% (tag, rd("cnc/state"), rd("cnc/laser_enable"), rd("cnc/laser_on"),
rd("cnc/laser_on_sampled"), rd("cnc/interlock_circuit")))
ctx.log(line)
return line
def wait_state(ctx, want, timeout, poll=0.05):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = rd("cnc/state")
if s == want:
return s
time.sleep(poll)
return rd("cnc/state")
def watch_laser_until_idle(ctx, timeout):
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
hits = []
t0 = time.time()
state = "running"
n = 0
while time.time() - t0 < timeout:
en = rd("cnc/laser_enable")
on = rd("cnc/laser_on")
if en != "0" or on != "0":
hits.append((round(time.time() - t0, 4), en, on))
state = rd("cnc/state")
if state != "running":
break
n += 1
if n % 200 == 0:
ctx.checkpoint()
return hits, state
class PulseDevice:
"""Exclusive hold of /dev/glowforge for one drill."""
def __init__(self, ctx):
self.ctx = ctx
self.fd = None
def __enter__(self):
try:
self.fd = os.open("/dev/glowforge", os.O_WRONLY)
except OSError as e:
if e.errno == errno.EBUSY:
raise Failed("/dev/glowforge is busy - the takeover did not free the pulse device")
raise
fcntl.flock(self.fd, fcntl.LOCK_EX)
return self
def write(self, data):
os.write(self.fd, data)
def rewind(self):
os.lseek(self.fd, 1, os.SEEK_SET)
def __exit__(self, *exc):
try:
wr("cnc/laser_latch", 1) # re-lock unconditionally
finally:
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
return False
def require_hv_not_good(ctx):
"""K3 and fire B/U unlock the latch: refuse while HV reports good.
Called before the takeover; gives the operator one chance to drop it
(open the lid)."""
pgood = rd("cnc/laser_pgood")
if pgood != "0":
ctx.log("laser_pgood=%s: the HV supply reports good", pgood)
ctx.instruct("This drill unlocks the laser latch with a zero-duty stream and must run "
"with the HV supply NOT good. Open the lid (the safety chain holds HV off), "
"then Done.")
pgood = rd("cnc/laser_pgood")
ctx.evidence["laser_pgood"] = pgood
ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", pgood)
def check_hv_not_good(ctx):
"""The hard check right before an unlock (no prompt: forgectrl is down)."""
pgood = rd("cnc/laser_pgood")
ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", pgood)
# ---------------------------------------------------------------- readbacks
@test("kernel.latch-locked-idle", title="Laser latch locked at idle", subsystem="kernel",
kind="auto", always=True, est_min=1,
covers=_KERNEL_COVERS + [("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/driver.c")],
description="With the machine idle the kernel latch reads locked, the FIRE line is not "
"driven, no LASER_ON sample is seen, and no stepper fault is pending; forgectrl "
"agrees.")
def latch_locked_idle(ctx):
ev = ctx.evidence
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.log("cnc/state: %s", state)
ctx.check(state is not None, "cnc/state unreadable")
ctx.check(state in ("idle", "disabled"), "machine is %r, run this test at idle", state)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None, "cnc/interlock_circuit unreadable")
ctx.log("interlock_circuit: %d (0x%x)", ilk, ilk)
ctx.check(ilk & LATCH_BIT, "latch line reads unlocked at idle (bit 3 clear)")
fire = hw.sysfs_int("cnc/laser_enable")
ev["laser_enable"] = fire
ctx.log("laser_enable (FIRE line): %s", fire)
ctx.check(fire == 0, "FIRE line driven at idle (laser_enable=%s)", fire)
on = hw.sysfs_int("cnc/laser_on")
on_s = hw.sysfs_int("cnc/laser_on_sampled")
ev["laser_on"] = on
ev["laser_on_sampled"] = on_s
ctx.log("laser_on: %s, laser_on_sampled: %s", on, on_s)
ctx.check(on == 0, "LASER_ON active at idle")
ctx.check(on_s == 0, "LASER_ON samples seen at idle (%s)", on_s)
faults = hw.sysfs_int("cnc/faults")
ev["faults"] = faults
ctx.log("faults: %s", faults)
ctx.check(faults == 0, "stepper faults pending: %s", faults)
st = ctx.forgectrl.status()
ev["forgectrl_laser_locked"] = st.get("laser_locked")
ctx.log("forgectrl /status laser_locked=%s state=%s", st.get("laser_locked"), st.get("state"))
ctx.check(st.get("laser_locked") is True, "forgectrl reports the latch unlocked")
# ---------------------------------------------------------------- K1 + K2
@test("kernel.k1-k2", title="Controlled-stop floor and resume honors the locked latch",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.latch-locked-idle"],
description="K1: a controlled stop mid-run ramps the step frequency down (tens of ms), "
"never consumes the tail as a burst or hangs. K2: with the latch locked, a "
"stop inside the leading pads and a resume with a positive waypoint replays "
"a 2 s FIRE window with laser_enable/laser_on at 0 throughout. Motors locked; "
"duty zero.")
def k1_k2(ctx):
ev = ctx.evidence
with ctx.takeover():
# ---- K1
stream = POWER0 + PAD * (6 * TICK_HZ)
ctx.log("K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s",
len(stream), len(stream) / TICK_HZ, TICK_HZ)
snap(ctx, "K1 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.5) # well past the accel ramp
st = rd("cnc/state")
ctx.check(st == "running", "K1: expected running before the stop, got %s", st)
t0 = time.time()
wr("cnc/stop", 1)
while time.time() - t0 < 5:
if rd("cnc/state") != "running":
break
dt = time.time() - t0
state = rd("cnc/state")
faults = rd("cnc/faults")
ev["k1"] = {"stop_to_idle_s": round(dt, 4), "state": state, "faults": faults}
ctx.log("K1 controlled stop: state=%s after %.4f s, faults=%s", state, dt, faults)
# drain the paused remainder laser-less so the device ends clean
wr("cnc/resume", 0)
wait_state(ctx, "running", 2, poll=0.005)
wait_state(ctx, "idle", 10)
ctx.check(state == "idle", "K1: state %s after the stop", state)
ctx.check(dt >= 0.02, "K1: stop consumed the tail as a burst (%.4f s) - decel floor broken", dt)
ctx.check(dt <= 3.0, "K1: stop took %.4f s", dt)
ctx.check(faults == "0", "K1: faults=%s", faults)
ctx.log("K1 PASS: decelerating tail %.4f s, no burst, no fault", dt)
# ---- K2
step_sec = (XSTEP + PAD * 4) * 1000 # 1000 X steps at 2 kHz (masked)
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
ctx.log("K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=15",
len(stream), len(stream) / TICK_HZ)
snap(ctx, "K2 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
wr("cnc/run", 1)
ctx.sleep(0.4) # inside the initial pads
wr("cnc/stop", 1)
state = wait_state(ctx, "idle", 5, poll=0.01)
ctx.check(state == "idle", "K2: controlled stop did not reach idle (state=%s)", state)
ctx.log("K2: paused inside the pads; resuming with waypoint +200 (latch LOCKED)")
wr("cnc/resume", 200)
wait_state(ctx, "running", 2, poll=0.005)
hits, state = watch_laser_until_idle(ctx, 20)
pos_after = rd_pos()
snap(ctx, "K2 post")
ev["k2"] = {"hits": hits[:10], "end_state": state, "pos_before": pos_before,
"pos_after": pos_after, "laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
ctx.log("K2 done: state=%s pos before=%s after=%s", state, pos_before, pos_after)
ctx.check(not hits, "K2: laser asserted with the latch locked: %s", hits[:10])
if pos_before[:3] == pos_after[:3]:
ctx.log("K2 NOTE: position did not advance under motor_lock (waypoint completion "
"unconfirmed by the counter on this board)")
ctx.log("K2 PASS: FIRE window replayed after the resume waypoint with "
"laser_enable/laser_on at 0 throughout")
# ---------------------------------------------------------------- K3
@test("kernel.k3-unlock", title="Mid-run latch unlock never re-arms FIRE", subsystem="kernel",
kind="operator", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
steps=["If the HV supply reports good the drill asks you to open the lid first (the "
"safety chain holds HV off); zero duty throughout."],
description="Stream of FIRE bits run with the latch locked (laser-less by the run-start "
"guard); the latch is unlocked during the accel ramp. The unlock drives the "
"latch pin (interlock bit 3 clears) but must not restore the FIRE drive while "
"the run is in flight: laser_enable stays 0 for the entire run.")
def k3_unlock(ctx):
ev = ctx.evidence
require_hv_not_good(ctx)
with ctx.takeover():
check_hv_not_good(ctx)
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel "
"window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ)
snap(ctx, "K3 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/ramp_rate", 10000)
try:
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
time.sleep(0.15) # inside the accel ramp
wr("cnc/laser_latch", 0)
ilk = rd("cnc/interlock_circuit")
ctx.log("K3: latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)", ilk)
hits, state = watch_laser_until_idle(ctx, 20)
snap(ctx, "K3 post")
ev["k3"] = {"interlock_after_unlock": ilk, "hits": hits[:10], "end_state": state,
"laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/ramp_rate", 125000)
except OSError:
ctx.log("WARNING: could not restore ramp_rate=125000")
ctx.check((int(ilk) & LATCH_BIT) == 0, "K3: the unlock did not drive the latch pin (interlock=%s)", ilk)
ctx.check(not hits, "K3: FIRE drive re-armed by a mid-run unlock: %s", hits[:10])
ctx.log("K3 PASS: laser_enable stayed 0 for the entire run after the mid-ramp unlock")
# ---------------------------------------------------------------- FIRE A/B/U
def _fire_stream():
return (POWER0 + # duty zero before any FIRE bit
PAD * TICK_HZ + # 1 s baseline
FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads)
PAD * TICK_HZ + # 1 s gap
FIRE * (2 * TICK_HZ)) # 2.000 s FIRE window ending AT end-of-data
def _fire_phase(ctx, mode):
"""One phase of fire_test.py; returns the evidence dict."""
unlock = mode in ("B", "U")
underrun_mode = mode == "U"
stream = _fire_stream()
ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ)
if unlock:
check_hv_not_good(ctx)
snap(ctx, "fire %s pre" % mode)
wr("cnc/motor_lock", 15)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/laser_latch", 1)
underruns_before = int(rd("cnc/underruns"))
mid = None
tail = None
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
if underrun_mode:
wr("cnc/streaming", 1) # end-of-data mid-run = true underrun
ctx.log("fire U: streaming=1, the terminal end-of-data will be a TRUE UNDERRUN")
try:
if unlock:
wr("cnc/laser_latch", 0)
ctx.log("fire %s: latch UNLOCKED for this run", mode)
wr("cnc/run", 1)
t0 = time.time()
state = ""
samples = []
while time.time() - t0 < 20:
ctx.checkpoint()
state = rd("cnc/state")
dt = time.time() - t0
en, on, ons = rd("cnc/laser_enable"), rd("cnc/laser_on"), rd("cnc/laser_on_sampled")
samples.append((round(dt, 2), state, en, on, ons))
if mid is None and 1.5 < dt < 3.0:
mid = {"t": round(dt, 2), "laser_enable": en, "laser_on": on,
"laser_on_sampled": ons, "interlock": rd("cnc/interlock_circuit")}
ctx.log("fire %s mid (inside FIRE window): laser_enable=%s laser_on=%s "
"laser_on_sampled=%s interlock=%s", mode, en, on, ons, mid["interlock"])
if state != "running":
break
time.sleep(0.05)
end_dt = time.time() - t0
tail = {"state": state, "after_s": round(end_dt, 2), "laser_enable": rd("cnc/laser_enable"),
"laser_on": rd("cnc/laser_on")}
ctx.log("fire %s done: state=%s after %.1f s (laser_enable=%s)", mode, state, end_dt,
tail["laser_enable"])
if underrun_mode:
if state == "underrun":
ctx.log("fire U: underrun state reached as EXPECTED; acking via stop")
else:
ctx.log("fire U: WARNING expected underrun state, got %s", state)
wr("cnc/stop", 1)
wr("cnc/streaming", 0)
tail["acked_state"] = rd("cnc/state")
ctx.log("fire U: acked: state=%s", tail["acked_state"])
finally:
wr("cnc/laser_latch", 1)
if underrun_mode:
try:
wr("cnc/streaming", 0)
except OSError:
pass
pos_after = rd_pos()
snap(ctx, "fire %s post" % mode)
ev = {"mid": mid, "tail": tail, "moved": pos_before[:3] != pos_after[:3],
"underruns_before": underruns_before, "underruns_after": int(rd("cnc/underruns")),
"faults": rd("cnc/faults"),
"any_laser_on": any(s[3] != "0" or s[4] != "0" for s in samples),
"any_fire_driven": any(s[2] != "0" for s in samples)}
ctx.log("fire %s: moved=%s underruns %d->%d faults=%s", mode, ev["moved"],
ev["underruns_before"], ev["underruns_after"], ev["faults"])
return ev
@test("kernel.fire-abu", title="FIRE line: latch locked, unlocked-unarmed, true underrun",
subsystem="kernel", kind="operator", hardware="takeover", always=True, est_min=3,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
steps=["Phases B and U unlock the latch with a zero-duty stream: if the HV supply reports "
"good the drill asks you to open the lid first (the safety chain holds HV off)."],
description="A: latch locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON "
"nets. B: latch unlocked with the chain unarmed, the FIRE line is driven "
"mid-window and LASER_ON stays off (the safety AND-gate holds), FIRE clear at "
"end-of-data. U: streaming declared, the terminal end-of-data is a true "
"underrun, the backstop drops FIRE and stop acks it.")
def fire_abu(ctx):
ev = ctx.evidence
require_hv_not_good(ctx)
with ctx.takeover():
try:
a = _fire_phase(ctx, "A")
ev["A"] = a
ctx.check(a["mid"] is not None, "A: no mid-window sample")
ctx.check(not a["any_fire_driven"], "A: FIRE line driven with the latch locked")
ctx.check(not a["any_laser_on"], "A: LASER_ON seen with the latch locked")
ctx.check(a["tail"]["state"] == "idle", "A: ended in %s", a["tail"]["state"])
ctx.check(not a["moved"], "A: position moved with motors locked")
ctx.log("fire A PASS: latch locked, no FIRE drive, no LASER_ON")
b = _fire_phase(ctx, "B")
ev["B"] = b
ctx.check(b["mid"] is not None, "B: no mid-window sample")
ctx.check(b["mid"]["laser_enable"] != "0",
"B: FIRE line not driven mid-window with the latch unlocked (%s)", b["mid"])
ctx.check(not b["any_laser_on"], "B: LASER_ON active with the chain unarmed - the AND-gate did not hold")
ctx.check(b["tail"]["state"] == "idle", "B: ended in %s", b["tail"]["state"])
ctx.check(b["tail"]["laser_enable"] == "0", "B: FIRE still driven after end-of-data")
ctx.check(b["underruns_after"] == b["underruns_before"], "B: underrun counted on a normal completion")
ctx.log("fire B PASS: FIRE driven mid-window, LASER_ON off, FIRE clear at end-of-data")
u = _fire_phase(ctx, "U")
ev["U"] = u
ctx.check(u["tail"]["state"] == "underrun", "U: expected the underrun state, got %s", u["tail"]["state"])
ctx.check(u["tail"]["laser_enable"] == "0", "U: FIRE still driven after the underrun")
ctx.check(not u["any_laser_on"], "U: LASER_ON active with the chain unarmed")
ctx.check(u["tail"].get("acked_state") == "idle", "U: stop did not ack the underrun (state %s)",
u["tail"].get("acked_state"))
ctx.check(u["underruns_after"] == u["underruns_before"] + 1,
"U: underrun counter %d -> %d", u["underruns_before"], u["underruns_after"])
ctx.log("fire U PASS: true underrun, backstop dropped FIRE, stop acked")
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/disable", 1) # the script's safe state
except OSError:
pass
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
+426
View File
@@ -0,0 +1,426 @@
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
Every test here can emit. The page starts one only with the operator's
eye-protection / fire-watch / exhaust acknowledgment; the test then
prompts for the scrap and the button, streams a small job through the
controller, and the machine fires only after the operator presses the
physical arm button - nothing here defeats that gate, and forgetest
never touches the laser latch. One emission per test; on abort or error
the job is soft-reset (`^X`: controlled stop, latch relocked). The
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`) and
`/cool/status` (`armed`), sampled at ~8 Hz through the arm -> fire ->
disarm lifecycle.
"""
import time
from ..catalog import test
from .. import hw
from ..runner import Failed
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, "
"scrap under the head with room to move (%s), lid closed. When the job starts the "
"white button lights and the stream blocks until you press the physical arm button; "
"the machine fires only after your press. Ready?")
def sample(ctx):
"""One combined /status + /cool/status sample, or None on error."""
fc = ctx.forgectrl
try:
st1, st = fc.get("/status")
st2, cs = fc.get("/cool/status")
except hw.HwError:
return None
if st1 != 200 or st2 != 200 or not isinstance(st, dict) or not isinstance(cs, dict):
return None
return {
"t": time.time(),
"kstate": st.get("state"),
"emission": (st.get("laser") or {}).get("emission_samples"),
"pgood": (st.get("laser") or {}).get("pgood_samples"),
"faults": st.get("faults"),
"hv": st.get("hv_current_raw"),
"ir": st.get("lid_ir"),
"homed": st.get("homed"),
"armed": cs.get("armed"),
"fire_watch": cs.get("fire_watch"),
"verdict": cs.get("verdict"),
}
def prepare(ctx, g):
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
st = g.status_report()
if "Door" in st["state"] or "Hold" in st["state"]:
g.realtime(0x18)
ctx.sleep(2)
g.drain()
st = g.status_report()
if "Alarm" in st["state"]:
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.log("connect: %s", st["state"])
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
return st
def stream(g, lines):
for ln in lines:
g.send_raw((ln + "\n").encode())
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
with no fire, Idle > 3 s, > 15 s in; or the overall timeout."""
samples = []
period = 1.0 / sample_hz
s0 = sample(ctx)
if s0:
samples.append(s0)
stream(g, job)
t_start = time.time()
next_t = t_start
seen_emission = seen_armed = disarmed_now = False
idle_since = None
while time.time() - t_start < overall_timeout:
ctx.checkpoint()
now = time.time()
if now >= next_t:
smp = sample(ctx)
if smp:
samples.append(smp)
if smp["emission"] and smp["emission"] > 0:
seen_emission = True
if smp["armed"]:
seen_armed = True
disarmed_now = seen_armed and not smp["armed"]
next_t = now + period
st = g.status_report()["state"]
if st.startswith("Idle"):
if idle_since is None:
idle_since = now
idle_for = now - idle_since
if seen_emission and idle_for > 3.0:
break
if disarmed_now and (now - t_start) > 15 and idle_for > 3.0:
break
else:
idle_since = None
time.sleep(0.05)
return samples
class LiveJob:
"""Leaves the laser commanded off on every exit; a soft reset on
abort/failure stops motion, relocks, and closes the armed window."""
def __init__(self, ctx, g):
self.ctx, self.g = ctx, g
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
try:
if exc_type is not None:
self.ctx.log("stopping the job: soft reset (%s)", exc_type.__name__)
self.g.realtime(0x18)
time.sleep(1)
self.g.command("M5", timeout=1)
except Exception: # noqa: BLE001 - best effort on the way out
pass
return False
def wait_disarm(ctx, timeout):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = sample(ctx)
if s and not s["armed"]:
return time.time() - t0
time.sleep(0.5)
return None
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
subsystem="laser", kind="live", always=True, est_min=5,
covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm)."],
description="A 20 mm square outline at S400/F600 in dynamic laser mode: emission_samples "
"(the kernel's LASER_ON sample count) goes nonzero during the fire window and "
"returns to 0 at Idle, HV current rises during the burn, the armed window is "
"observed, and the M2 program end disarms promptly at Idle (job-based, not "
"the 60 s idle grace). The operator confirms the mark.")
def emission_witness(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ev["pre_fire"] = base
ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.instruct(ARM_CUE % "20 mm +X and +Y")
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"]
samples = run_and_sample(ctx, g, job)
emis = [s["emission"] for s in samples if s["emission"] is not None]
peak = max(emis) if emis else 0
end = emis[-1] if emis else None
hv = [s["hv"] for s in samples if s["hv"] is not None]
ir_peak = [0, 0, 0, 0]
for s in samples:
if s["ir"] and len(s["ir"]) == 4:
for i in range(4):
ir_peak[i] = max(ir_peak[i], s["ir"][i])
armed_seen = any(s["armed"] for s in samples)
ev.update({"samples": len(samples), "emission_peak": peak, "emission_end": end,
"hv_min": min(hv) if hv else None, "hv_max": max(hv) if hv else None,
"lid_ir_peak": ir_peak, "armed_seen": armed_seen,
"pgood_peak": max((s["pgood"] for s in samples if s["pgood"] is not None), default=None)})
ctx.log("emission_samples peak=%s end=%s; hv %s..%s; lid_ir peak %s; armed seen %s",
peak, end, ev["hv_min"], ev["hv_max"], ir_peak, armed_seen)
# X-3: job-based disarm at Idle after M2
dt = wait_disarm(ctx, 75)
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("time-to-disarm after Idle: %s 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 (emission_samples stayed 0)")
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
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(dt is not None and dt < 10.0,
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, and is the machine now "
"idle with the button dark?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, disarmed %.1f s after Idle, mark confirmed",
peak, ev["hv_min"], ev["hv_max"], dt)
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white."],
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
"hold: the disarm grace must count down while held and close the armed window "
"(armed -> false) without the job resuming.")
def disarm_in_hold(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
ctx.instruct(ARM_CUE % "40 mm +X")
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...")
t0 = time.time()
st = None
while time.time() - t0 < 180:
ctx.checkpoint()
st = g.status_report()["state"]
if st.startswith("Run"):
break
time.sleep(0.1)
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
ctx.sleep(2)
g.realtime(ord("!"))
t1 = time.time()
while time.time() - t1 < 5:
st = g.status_report()["state"]
if st.startswith("Hold"):
break
time.sleep(0.1)
ev["held_state"] = st
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
t0 = time.time()
disarmed_at = None
left_hold = None
while time.time() - t0 < 120:
ctx.checkpoint()
s = sample(ctx)
held = g.status_report()["state"].startswith("Hold")
if s and not s["armed"]:
disarmed_at = time.time() - t0
break
if not held and left_hold is None:
left_hold = g.status_report()["state"]
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
time.sleep(1)
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
ev["left_hold"] = left_hold
# recover: laser off, abort out of hold
g.command("M5", timeout=1)
g.realtime(0x18)
ctx.sleep(1)
if "Alarm" in g.status_report()["state"]:
g.command("$X")
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
ctx.log("PASS: disarmed in Hold after %.1f s", disarmed_at)
ctx.confirm("Did the head stop after ~2 s of the +X move and stay stopped, with the button "
"going dark on its own about a minute later?")
@test("laser.expected-stop", title="Armed kill on the expected-stop path (POST /controller/stop)",
subsystem="laser", kind="live", est_min=4,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white.",
"The controller is left stopped until you judge the stop; the test then restarts it."],
description="Start a mark job at S400/F200; once emission is live, POST /controller/stop. "
"Emission must drop to 0 within 2.5 s and stay 0, the kernel must not be "
"running, and the supervisor's restart is a separate, operator-judged step "
"(POST /controller/start, no motion, no laser).")
def expected_stop(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
ctx.instruct(ARM_CUE % "40 mm +X and +Y")
stream(g, ["G91", "G21", "M4", "S400",
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
"M5", "G90", "M2"])
t0 = time.time()
smp = None
seen = False
while time.time() - t0 < 240:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
seen = True
break
time.sleep(0.15)
if not seen:
g.realtime(0x18)
raise Failed("no emission seen within 240 s (arm refused, or no button press)")
ctx.log("emission live (%s) - stopping the controller NOW", smp["emission"])
t_stop = time.time()
code, body = fc.post("/controller/stop")
post_dt = time.time() - t_stop
ctx.log("POST /controller/stop -> %s %s (%.2f s)", code, body, post_dt)
trail = []
for _ in range(40): # ~5 s at 8 Hz
s = sample(ctx)
if s:
trail.append((round(time.time() - t_stop, 2), s["emission"], s["kstate"], s["armed"]))
time.sleep(0.12)
for t in trail:
ctx.log(" post-stop %s", t)
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
not_running = all(k != "running" for _, _, k, _ in trail[-16:])
st_mode, mode = fc.get("/mode")
ev.update({"post_status": code, "post_s": round(post_dt, 2), "zero_at_s": zero_at,
"tail_zero": tail_zero, "kernel_not_running": not_running, "mode_after_stop": mode,
"trail": trail})
ctx.log("emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s; /mode %s",
zero_at, tail_zero, not_running, mode)
ctx.check(code == 200, "POST /controller/stop -> %s", code)
ctx.check(zero_at is not None and zero_at < 2.5, "emission did not drop within 2.5 s (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the stop")
ctx.check(not_running, "the kernel was still running after the stop")
ctx.instruct("The controller is STOPPED (supervision held). Judge the stop on the scrap - a "
"short cut, then an abrupt end - and confirm the machine is quiet; then Done to "
"restart the controller (no motion, no laser).")
st, body = fc.post("/controller/start")
ctx.log("POST /controller/start -> %s %s", st, body)
ctx.check(st == 200, "POST /controller/start -> %s", st)
ctx.sleep(6)
st, mode = fc.get("/mode")
ev["mode_after_start"] = mode
ctx.log("/mode after start: %s", mode)
ctx.check(isinstance(mode, dict) and mode.get("controller") == "running",
"controller not running after the restart: %s", mode)
ctx.log("PASS: stop in %.2f s, emission 0 at +%s s, controller restarted", post_dt, zero_at)
@test("laser.kill-mid-fire", title="Armed kill: SIGKILL of the controller while emitting",
subsystem="laser", kind="live", est_min=4,
covers=_LASER_COVERS,
requires=["laser.expected-stop", "motion.deadman"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white."],
description="Start a mark job at S400/F200; once emission is live, SIGKILL the controller. "
"The supervisor's exit safing must end the fire tail within the ring's in-flight "
"window: emission drops to 0 within 2.5 s and stays 0, the kernel is not "
"running, the latch reads locked, and the controller is respawned.")
def kill_mid_fire(ctx):
import os as _os
import signal as _signal
ev = ctx.evidence
fc = ctx.forgectrl
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid = m0.get("pid")
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
ctx.instruct(ARM_CUE % "40 mm +X and +Y")
stream(g, ["G91", "G21", "M4", "S400",
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
"M5", "G90", "M2"])
t0 = time.time()
smp = None
seen = False
while time.time() - t0 < 240:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
seen = True
break
time.sleep(0.15)
if not seen:
g.realtime(0x18)
raise Failed("no emission seen within 240 s (arm refused, or no button press)")
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time()
_os.kill(pid, _signal.SIGKILL)
trail = []
for _ in range(40): # ~5 s at 8 Hz
s = sample(ctx)
if s:
trail.append((round(time.time() - t_kill, 2), s["emission"], s["kstate"], s["armed"]))
time.sleep(0.12)
for t in trail:
ctx.log(" post-kill %s", t)
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
not_running = all(k != "running" for _, _, k, _ in trail[-16:])
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev.update({"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero, "kernel_not_running": not_running,
"latch_locked": locked, "trail": trail})
ctx.log("emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s; latch locked: %s",
zero_at, tail_zero, not_running, locked)
ctx.check(zero_at is not None and zero_at < 2.5, "emission did not drop within 2.5 s (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill")
ctx.check(not_running, "the kernel was still running after the kill")
ctx.check(locked, "latch not locked after the kill")
t0 = time.time()
m1 = None
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid:
break
ctx.sleep(1)
ev["mode_after"] = m1
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1)
ctx.confirm("Did the cut end abruptly at the kill (a short line, no run-on), with the machine "
"quiet and the button dark now?")
ctx.log("PASS: emission 0 at +%s s after SIGKILL, latch locked, controller respawned", zero_at)
+59
View File
@@ -0,0 +1,59 @@
"""logs.* - unified logging: the log tree, tail, and the sanitized export."""
import gzip
import io
import tarfile
from ..catalog import test
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")]
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains no panel token.")
def tree_tail_export(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/logs")
ctx.log("GET /logs -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
loggers = body.get("loggers") or []
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
ev["loggers"] = names
ctx.log("loggers: %s", names)
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
st, data = fc.post("/logs/export", raw=True)
ctx.log("POST /logs/export -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200, "POST /logs/export -> %s", st)
ev["export_bytes"] = len(data)
try:
raw = gzip.decompress(data)
tf = tarfile.open(fileobj=io.BytesIO(raw))
members = tf.getnames()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ev["members"] = len(members)
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
token = fc.token
if token:
leaked = []
for m in tf.getmembers():
if m.isfile():
content = tf.extractfile(m).read()
if token.encode() in content:
leaked.append(m.name)
ev["token_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the panel token: %s", leaked)
+459
View File
@@ -0,0 +1,459 @@
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
`scripts/bench/bench_m2.py` (motion-quality bench). Every move is relative
and round-trip; the laser stays latched (the tests never touch it); the
suite is the only Grbl client while a test runs.
"""
import time
from ..catalog import test
from .. import hw
from ..runner import Failed
_MOTION_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.*"), ("forgectrl", "src/liveness.*")]
def controller_pid():
pids = hw.pidof("grblHAL_glowfor")
if not pids:
raise Failed("controller process not found (grblHAL_glowforge)")
return pids[0]
def cpu_ticks(pid):
with open("/proc/%d/stat" % pid) as f:
s = f.read().split()
return int(s[13]) + int(s[14]) # utime + stime (all threads)
def cpu_percent(ctx, pid, window):
import os
hz = os.sysconf("SC_CLK_TCK")
a = cpu_ticks(pid)
ctx.sleep(window)
b = cpu_ticks(pid)
return 100.0 * (b - a) / (hz * window)
def wait_state(ctx, g, prefix, timeout):
end = time.time() + timeout
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
if st["state"].startswith(prefix):
return st
time.sleep(0.1)
return None
def wait_idle(ctx, g, timeout=30.0, poll=0.05):
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report)."""
peak = 0.0
states = []
deadline = time.time() + timeout
st = None
while time.time() < deadline:
ctx.checkpoint()
st = g.status_report()
state = st["state"]
if not states or states[-1] != state:
states.append(state)
f = st.get("FS") or st.get("F")
if f:
try:
peak = max(peak, float(str(f).split(",")[0]))
except ValueError:
pass
if state.startswith("Idle"):
return peak, states, st
time.sleep(poll)
return peak, states + ["TIMEOUT"], st
def clean_slate(ctx, g):
st = g.status_report()
ctx.log("connect: %s", st["state"])
if any(k in st["state"] for k in ("Alarm", "Door", "Hold")):
g.realtime(0x18) # soft reset
ctx.sleep(2)
g.drain()
st = g.status_report()
if "Alarm" in st["state"]:
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
return st
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Bed clear; the head needs 30 mm of free +X travel."],
description="Idle CPU is low; a job parked in a completed feed hold is coarse-paced (not "
"busy-spinning at the motion rate); active motion is tight-paced; a feed-hold "
"mid-move then resume preserves position (the feeder never starves).")
def pacing(ctx):
dist, feed = 30.0, 600.0
pid = controller_pid()
ev = ctx.evidence
ev["controller_pid"] = pid
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("M5")
g.command("G91")
idle = cpu_percent(ctx, pid, 3)
ev["idle_cpu_pct"] = round(idle, 1)
ctx.log("[1] idle CPU = %.1f%%", idle)
start = g.status_report().get("MPos")
ctx.check(start, "no MPos in the status report")
g.command("G1 X%.3f F%.0f" % (dist, feed), timeout=0.5)
ctx.sleep(0.6)
moving = cpu_percent(ctx, pid, 1.0)
st_mv = g.status_report()["state"]
ev["moving_cpu_pct"] = round(moving, 1)
ctx.log("[3] state=%s CPU during move = %.1f%%", st_mv, moving)
g.realtime(ord("!")) # feed hold
wait_state(ctx, g, "Hold", 5)
ctx.sleep(1.5) # decel completes (Hold:1 -> Hold:0)
held = g.status_report()
parked = cpu_percent(ctx, pid, 3)
ev["held_state"] = held["state"]
ev["parked_cpu_pct"] = round(parked, 1)
ctx.log("[2] %s: CPU parked in Hold = %.1f%%", held["state"], parked)
g.realtime(ord("~")) # resume
peak, states, st = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "did not return to Idle after the resume: %s", states)
end = st.get("MPos")
moved = end[0] - start[0]
ev["moved_mm"] = round(moved, 3)
ctx.log("[4] start X=%.3f end X=%.3f moved=%.3f (expect %.1f)", start[0], end[0], moved, dist)
# return to the starting position
g.command("G1 X%.3f F%.0f" % (-dist, feed), timeout=0.5)
wait_idle(ctx, g, 30)
g.command("G90")
final = g.status_report().get("MPos")
ev["final_drift_mm"] = round(final[0] - start[0], 3) if final else None
ctx.check(abs(moved - dist) < 0.05, "hold+resume lost steps: moved %.3f of %.1f mm", moved, dist)
ctx.check(parked < moving * 0.5 and parked < 8.0,
"parked Hold is not coarse-paced: %.1f%% (moving %.1f%%)", parked, moving)
ctx.check(idle < 8.0, "idle CPU %.1f%%", idle)
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",
subsystem="motion", kind="operator", est_min=3,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Park the head with at least 60 mm of free +X and 40 mm of free +Y travel; bed clear.",
"Watch the gantry: it must move on every jog and end where it started."],
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. "
"No jog refused, every move returns to Idle, position drift within 0.05 mm, and "
"the operator saw the gantry move.")
def jog_roundtrip(ctx):
ev = ctx.evidence
ctx.instruct("Head parked with >= 60 mm free +X and >= 40 mm free +Y, bed clear, lid closed. "
"Watch the gantry during this test.")
with ctx.grbl() as g:
st0 = clean_slate(ctx, g)
start = st0.get("MPos")
ctx.check(start, "no MPos in the status report")
ev["start"] = start
moves = []
for name, out, back in (
("X sanity 40mm", "$J=G91X40F2400", "$J=G91X-40F2400"),
("Y sanity 40mm", "$J=G91Y40F2400", "$J=G91Y-40F2400"),
("X max-rate 60mm", "$J=G91X60F12000", "$J=G91X-60F12000"),
("diag 40mm", "$J=G91X40Y40F8000", "$J=G91X-40Y-40F8000")):
for jog in (out, back):
r = g.command(jog)
ctx.check(not any(x.startswith("error") for x in r), "%s: jog refused: %s", name, r)
peak, states, _ = wait_idle(ctx, g)
leg = "out" if jog == out else "back"
ctx.log("%s %s: peak %.0f mm/min, states %s", name, leg, peak, states)
moves.append({"name": name, "leg": leg, "peak": peak, "states": states})
ctx.check("TIMEOUT" not in states, "%s %s did not return to Idle", name, leg)
ev["moves"] = moves
maxrate = max(m["peak"] for m in moves if m["name"].startswith("X max-rate"))
ev["max_rate_peak"] = maxrate
ctx.check(maxrate >= 6000, "max-rate jog peaked at only %.0f mm/min", maxrate)
# feed-hold mid-move: G1 at 600 mm/min takes 3 s for 30 mm
g.command("G91")
g.command("G1X30F600", timeout=0.5)
ctx.sleep(1.0)
g.realtime(ord("!"))
ctx.sleep(0.8)
held = g.status_report()
ev["held_state"] = held["state"]
ctx.log("after !: %s", held["state"])
g.realtime(ord("~"))
peak, states, _ = wait_idle(ctx, g)
ctx.log("after ~: states %s", states)
g.command("G1X-30F2400", timeout=0.5)
wait_idle(ctx, g)
g.command("G90")
final = g.status_report().get("MPos")
ev["final"] = final
drift = max(abs(a - b) for a, b in zip(final[:2], start[:2]))
ev["drift_mm"] = round(drift, 3)
ctx.log("final drift %.3f mm (start %s, final %s)", drift, start, final)
ctx.check("Hold" in held["state"], "feed hold did not park (state %s)", held["state"])
ctx.check(drift <= 0.05, "position drift %.3f mm", drift)
ctx.confirm("Did the gantry move on every jog (X, Y, the fast X, the diagonal, the held move) "
"and end where it started?")
ctx.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, operator confirmed",
len(moves), maxrate, drift)
# ---------------------------------------------------------------- liveness
@test("motion.liveness-probe", title="Supervisor motion-liveness verdict", subsystem="motion",
kind="auto", est_min=1,
covers=[("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), ("kernel-module-glowforge", "**")],
requires=["kernel.latch-locked-idle"],
steps=["Bed clear, lid closed: the probe jogs the head a few mm (+X first)."],
description="forgectrl's supervisor reports the head-accelerometer liveness probe as "
"verified for the running controller (the DRV8825s are not wedged); when the "
"probe was skipped at spawn, the controller is respawned once so it runs.")
def liveness_probe(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, m = fc.get("/mode")
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st)
ev["mode_before"] = m
ctx.log("mode: %s", m)
ctx.check(m.get("motion") != "fault", "supervisor reports motion-fault: %s", m)
if m.get("motion") != "verified":
ctx.log("probe %s at the last spawn - respawning the controller so it runs", m.get("motion"))
st, body = fc.post("/controller/stop")
ctx.check(st == 200, "POST /controller/stop -> %s", st)
ctx.sleep(2)
st, body = fc.post("/controller/start")
ctx.check(st == 200, "POST /controller/start -> %s", st)
t0 = time.time()
while time.time() - t0 < 60:
ctx.sleep(1)
st, m = fc.get("/mode")
if isinstance(m, dict) and m.get("controller") == "running":
break
ctx.sleep(3)
st, m = fc.get("/mode")
ev["mode_after"] = m
ctx.log("mode after: %s", m)
ctx.check(m.get("controller") == "running", "controller is %s", m.get("controller"))
ctx.check(m.get("motion") == "verified", "liveness is %r, expected verified", m.get("motion"))
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
kind="auto", 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 decelerates under control into Alarm "
"with machine position retained, $X recovers to Idle, and a subsequent jog runs "
"(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 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")
# ---------------------------------------------------------------- dead-man
def _kernel_x_mm(ctx):
pos = (ctx.forgectrl.status().get("pos") or {})
return pos.get("x")
def _return_x(ctx, delta_mm):
"""Jog back by the kernel-measured X delta (grbl's own position may be
untrusted after a kill; the kernel counters kept counting)."""
if delta_mm is None or abs(delta_mm) < 0.05:
return
with ctx.grbl() as g:
st = g.status_report()["state"]
if st.startswith("Alarm"):
g.command("$X")
g.command("$J=G91X%.3fF1200" % (-delta_mm))
wait_idle(ctx, g, 30)
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
"locked); the hung process is killed and the supervisor respawns. forgectrl "
"restart mid-move: the busy controller finishes the move unmanaged and the new "
"daemon retakes supervision at idle. After each drill the head is jogged back "
"by the kernel-measured distance.")
def deadman(ctx):
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
def latch_locked():
v = hw.sysfs_int("cnc/interlock_circuit")
return v is not None and bool(v & (1 << 3))
def wait_running(timeout=30):
t0 = time.time()
while time.time() - t0 < timeout:
st, m = fc.get("/mode")
if isinstance(m, dict) and m.get("controller") == "running" and m.get("pid"):
return m
ctx.sleep(0.5)
return None
# ---- 1. SIGKILL mid-move
m0 = wait_running(10)
ctx.check(m0, "controller not running")
pid0 = m0["pid"]
x0 = _kernel_x_mm(ctx)
unlocked_seen = False
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5)
ctx.sleep(1.0)
_os.kill(pid0, _signal.SIGKILL)
ctx.log("SIGKILL sent to controller pid %d mid-move", pid0)
t0 = time.time()
while time.time() - t0 < 15:
if not latch_locked():
unlocked_seen = True
st, m = fc.get("/mode")
if isinstance(m, dict) and m.get("controller") == "running" and m.get("pid") != pid0:
break
ctx.sleep(0.2)
m1 = wait_running(30)
respawn_s = round(time.time() - t0, 1)
ev["sigkill"] = {"old_pid": pid0, "new": m1, "respawn_s": respawn_s, "unlocked_seen": unlocked_seen}
ctx.log("after SIGKILL: respawned as %s in %s s; latch unlocked seen: %s", m1, respawn_s, unlocked_seen)
ctx.check(m1 and m1.get("pid") != pid0, "supervisor did not respawn the controller")
ctx.check(not unlocked_seen, "the latch unlocked during the kill/respawn")
ctx.check(m1.get("motion") != "fault", "motion fault after the respawn")
ctx.sleep(3)
x1 = _kernel_x_mm(ctx)
ctx.log("kernel X: %s -> %s mm", x0, x1)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 2. SIGSTOP (hang) mid-move -> kernel underrun
m1 = wait_running(10)
pid1 = m1["pid"]
x0 = _kernel_x_mm(ctx)
underruns0 = hw.sysfs_int("cnc/underruns", 0)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5)
ctx.sleep(1.0)
_os.kill(pid1, _signal.SIGSTOP)
ctx.log("SIGSTOP sent to controller pid %d mid-move", pid1)
t0 = time.time()
kstate = None
while time.time() - t0 < 10:
kstate = hw.sysfs_read("cnc/state")
if kstate == "underrun":
break
ctx.sleep(0.05)
halt_s = round(time.time() - t0, 2)
ev["sigstop"] = {"kernel_state": kstate, "halt_s": halt_s, "latch_locked": latch_locked(),
"underruns": hw.sysfs_int("cnc/underruns", 0)}
ctx.log("after SIGSTOP: kernel %s in %s s, latch locked %s, underruns %s -> %s",
kstate, halt_s, latch_locked(), underruns0, ev["sigstop"]["underruns"])
_os.kill(pid1, _signal.SIGKILL) # the hung controller cannot recover itself
ctx.check(kstate == "underrun", "the ring did not drain into a kernel underrun (state %s)", kstate)
ctx.check(latch_locked(), "latch unlocked after the underrun")
m2 = wait_running(30)
ev["sigstop"]["respawn"] = m2
ctx.check(m2 and m2.get("pid") != pid1, "supervisor did not respawn after the hang")
ctx.sleep(3)
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
m2 = wait_running(10)
pid2 = m2["pid"]
x0 = _kernel_x_mm(ctx)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
ctx.sleep(1.0)
rc, out = hw.initd("forgectrl", "restart")
ctx.log("forgectrl restart mid-move -> rc %s", rc)
peak, states, st = wait_idle(ctx, g, 30)
ev["restart"] = {"rc": rc, "states": states}
ctx.log("move after the restart: states %s", states)
ctx.check("TIMEOUT" not in states, "the move did not finish after the forgectrl restart")
g.command("G90")
t0 = time.time()
m3 = None
while time.time() - t0 < 60:
st, m3 = fc.get("/mode")
if isinstance(m3, dict) and m3.get("controller") == "running":
break
ctx.sleep(1)
ev["restart"]["mode_after"] = m3
ctx.log("mode after restart: %s", m3)
ctx.check(m3 and m3.get("controller") == "running", "supervision not retaken after the restart: %s", m3)
ctx.check(m3.get("pid") == pid2, "the busy controller was replaced (%s -> %s) instead of retaken",
pid2, m3.get("pid"))
ctx.check(latch_locked(), "latch unlocked after the restart drill")
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook pid %s",
respawn_s, halt_s, pid2)
+47
View File
@@ -0,0 +1,47 @@
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import tempfile
from ..catalog import test
from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h")]
@test("update.slots-and-signature", title="Boot slots readable, unsigned/tampered archives refused",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
"answers; `fwup` refuses a garbage archive and a tampered signature against the "
"shipped release key. Nothing is written to any slot.")
def slots_and_signature(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, slots = fc.get("/slots")
ctx.log("GET /slots -> %s %s", st, slots)
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
ev["slots"] = slots
rc, out = hw.run(["ffboot", "-l"])
ev["ffboot_l_rc"] = rc
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
text = str(slots).lower()
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
st, us = fc.get("/update/status")
ctx.log("GET /update/status -> %s %s", st, us)
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
ctx.check(not us.get("running"), "an update is running")
key = "/etc/forgefirm/keys/forgefirm-release.pub"
ctx.check(os.path.exists(key), "release key %s missing", key)
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
f.write(b"this is not a firmware archive" * 64)
garbage = f.name
try:
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
ev["fwup_garbage_rc"] = rc
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
ctx.check(rc != 0, "fwup accepted a garbage archive")
finally:
os.unlink(garbage)
+78
View File
@@ -0,0 +1,78 @@
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
catalog entries that never touch hardware."""
import hashlib
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
from forgetest import catalog as catalog_mod # noqa: E402
from forgetest import manifest as manifest_mod # noqa: E402
def blob(text):
"""A git-style blob id for text (what ls-tree would report)."""
data = text.encode()
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
def make_manifest(components=None, platform=None, version="20260101000000 (dev)", name="forgefirm-image-dev"):
comps = components if components is not None else {
"forgectrl": {"srcrev": "aaa", "files": [["src/main.c", blob("main")], ["src/ui.c", blob("ui")],
["src/auth.c", blob("auth")], ["src/cool.c", blob("cool")],
["README.md", blob("readme")]]},
"grblhal-glowforge": {"srcrev": "bbb", "files": [["src/driver.c", blob("drv")],
["src/grbl", "cccc"], ["src/grbl/core.c", blob("core")]]},
"kernel-module-glowforge": {"srcrev": "ddd", "files": [["src/cnc.c", blob("cnc")]]},
"linux-fslc": {"srcrev": "eee", "files": [["@srcrev", "eee"], ["@config", "cfg1"]]},
"forgetest": {"srcrev": None, "files": [["forgetest/x.py", blob("x")]]},
}
plat = platform if platform is not None else {
"machine": "glowforge", "kernel_modules": ["6.12.20-fslc+g0e01ec9f0d3f"],
"dtb": {"glowforge.dtb": "d" * 64},
"layers": {"meta-forgefirm": {"content_sha256": "1" * 64}, "poky": {"rev": "p" * 40}},
}
data = {"format": 1, "image": {"name": name, "version": version},
"components": comps, "platform": plat}
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": comps, "platform": plat}))
return manifest_mod.Manifest(data)
def with_file(manifest, component, path, text):
"""A copy of the manifest with one file's content changed/added."""
import copy
data = copy.deepcopy(manifest.data)
files = data["components"][component]["files"]
for f in files:
if f[0] == path:
f[1] = blob(text)
break
else:
files.append([path, blob(text)])
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def with_platform(manifest, **changes):
import copy
data = copy.deepcopy(manifest.data)
data["platform"].update(changes)
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def _noop(ctx):
pass
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None):
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
covers, requires, always, 1, (), "desc", fn or _noop)
def registry(*tests):
return {t.id: t for t in tests}
+143
View File
@@ -0,0 +1,143 @@
import copy
import json
import unittest
import helpers
from forgetest import artifact, campaign, catalog
from forgetest import manifest as manifest_mod
from test_campaign import rec_campaign, rec_result
class ArtifactTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.reg = helpers.registry(self.core, self.ui, self.cool)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
# campaign 1 on the base image: everything passes
self.recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
self.recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# image 2: cool.c changed; campaign 2 reruns core + cool
self.man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.recs.append(rec_campaign("c2", self.man2, self.chash, "2026-08-21T10:00:00Z"))
self.recs.append(rec_result("c2", self.core, self.man2, "PASS", "2026-08-21T10:01:00Z"))
self.recs.append(rec_result("c2", self.cool, self.man2, "PASS", "2026-08-21T10:02:00Z"))
def build(self, man=None):
man = man or self.man2
st = campaign.compute(self.recs, self.tests, man, self.chash)
return artifact.build(st, self.tests, man, self.recs, self.chash), st
def release_manifest(self, man=None):
"""The release build's manifest: same identity, no forgetest component."""
man = man or self.man2
data = copy.deepcopy(man.data)
data["components"].pop("forgetest", None)
data["image"] = {"name": "forgefirm-image", "version": "v0.1.0"}
return manifest_mod.Manifest(data)
def test_build_and_verify(self):
art, st = self.build()
self.assertTrue(art["authorized"])
by = {t["id"]: t for t in art["tests"]}
self.assertTrue(by["forgectrl.panel"]["inherited"])
self.assertEqual(by["forgectrl.panel"]["record"]["campaign"], "c1")
self.assertFalse(by["image.health"]["inherited"])
text = artifact.to_json(art)
art2 = json.loads(text)
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash,
expect_machine="glowforge")
self.assertEqual(problems, [])
self.assertTrue(ok)
md = artifact.to_markdown(art)
self.assertIn("Release authorized: YES", md)
self.assertIn("forgectrl.panel", md)
def test_tamper_detected(self):
art, _ = self.build()
art2 = json.loads(artifact.to_json(art))
art2["tests"][1]["record"]["result"] = "PASS"
art2["counts"]["required"] = 0
art2["authorized"] = True
art2["tests"][2]["record"]["ts"] = "2026-08-21T10:02:01Z"
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertIn("self-hash", problems[0])
def test_release_differs_in_covered_file(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "src/ui.c", "ui v3"))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("forgectrl.panel" in p and "fingerprint" in p for p in problems), problems)
# an uncovered change is fine
rel2 = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "README.md", "docs"))
ok, rows, problems = artifact.verify(art, rel2, self.tests, self.chash)
self.assertTrue(ok, problems)
def test_release_platform_differs(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_platform(self.man2, dtb={"glowforge.dtb": "0" * 64}))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertEqual(len([r for r in rows if not r["ok"]]), 3)
def test_unauthorized_artifact(self):
self.recs.append(rec_result("c2", self.ui, self.man2, "FAIL", "2026-08-21T10:03:00Z"))
art, st = self.build()
self.assertFalse(art["authorized"])
ok, rows, problems = artifact.verify(art, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("does not claim authorization" in p for p in problems))
def test_core_inherited_refused(self):
art, _ = self.build()
# forge an artifact whose core record is marked inherited (self-hash recomputed)
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "image.health":
t["inherited"] = True
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("always-required" in p for p in problems), problems)
def test_invalidate_epoch(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
body["invalidate"] = {"t": "invalidate", "ts": "2026-08-20T12:00:00Z", "reason": "tube"}
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("predates the invalidate" in p for p in problems), problems)
def test_catalog_changed(self):
art, _ = self.build()
reg = helpers.registry(self.core, self.ui, self.cool, helpers.make_test("new.one", []))
tests = list(reg.values())
ok, rows, problems = artifact.verify(art, self.release_manifest(), tests, catalog.catalog_hash(reg))
self.assertFalse(ok)
self.assertTrue(any("catalog changed" in p for p in problems))
self.assertTrue(any("new.one" in p for p in problems))
def test_implementation_changed(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "cooling.flow":
t["source_sha"] = "0" * 64
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("implementation changed" in p for p in problems), problems)
if __name__ == "__main__":
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
import unittest
import helpers
from forgetest import campaign
from forgetest import catalog
def rec_campaign(cid, man, chash, ts):
return {"t": "campaign", "ts": ts, "id": cid, "manifest_sha": man.content_sha,
"catalog_hash": chash, "image": man.version}
def rec_result(cid, t, man, result, ts, fp=None):
return {"t": "result", "ts": ts, "campaign": cid, "test": t.id, "result": result,
"fingerprint": fp or t.fingerprint(man), "manifest_sha": man.content_sha,
"image": man.version, "duration_s": 1, "message": ""}
class CampaignTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.live = helpers.make_test("laser.witness", [("grblhal-glowforge", "src/**")],
requires=("forgectrl.panel",), kind="live")
self.reg = helpers.registry(self.core, self.ui, self.cool, self.live)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
def compute(self, records, man=None, chash=None):
return campaign.compute(records, self.tests, man or self.man, chash or self.chash)
def test_empty(self):
st = self.compute([])
self.assertIsNone(st["campaign"])
self.assertFalse(st["authorized"])
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"])
self.assertEqual(st["tests"][t.id]["reason"], "always" if t.always else "never-passed")
self.assertFalse(st["tests"][self.live.id]["requires_met"])
def test_full_campaign_authorizes(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"], {"total": 4, "satisfied": 4, "inherited": 0, "required": 0})
self.assertEqual(st["tests"][self.core.id]["status"], "pass")
def test_fail_closes_campaign(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:02:00Z"),
rec_result("c1", self.cool, self.man, "FAIL", "2026-08-20T10:03:00Z")]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
self.assertFalse(st["authorized"])
# the core PASS in the closed campaign no longer counts; the ui PASS is inheritable
self.assertTrue(st["tests"][self.core.id]["required"])
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "fail")
self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed")
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")]
st = self.compute(recs)
self.assertIsNotNone(st["campaign"])
self.assertEqual(st["tests"][self.ui.id]["status"], "aborted")
recs.append(rec_result("c1", self.ui, self.man, "ERROR", "2026-08-20T10:02:00Z"))
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
def test_inheritance_follows_fingerprint(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# a new image with only cool.c changed
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
st = self.compute(recs, man=man2)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "image")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.live.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "stale")
self.assertEqual(st["tests"][self.cool.id]["reason"], "domain-changed")
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
# open a campaign on the new image, run the core + cool -> authorized
recs.append(rec_campaign("c2", man2, self.chash, "2026-08-21T10:00:00Z"))
recs.append(rec_result("c2", self.core, man2, "PASS", "2026-08-21T10:01:00Z"))
st = self.compute(recs, man=man2)
self.assertFalse(st["authorized"])
recs.append(rec_result("c2", self.cool, man2, "PASS", "2026-08-21T10:02:00Z"))
st = self.compute(recs, man=man2)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"]["inherited"], 2)
self.assertEqual(st["tests"][self.ui.id]["origin"]["campaign"], "c1")
def test_platform_change_invalidates_everything(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
man2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "f" * 64})
st = self.compute(recs, man=man2)
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
def test_invalidate_all(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
recs.append({"t": "invalidate", "ts": "2026-08-22T09:00:00Z", "reason": "new tube"})
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "invalidate")
self.assertEqual(st["invalidate"]["reason"], "new tube")
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
# after the invalidate, a new campaign's passes count and later ones inherit again
recs.append(rec_campaign("c2", self.man, self.chash, "2026-08-22T10:00:00Z"))
for i, t in enumerate(self.tests):
recs.append(rec_result("c2", t, self.man, "PASS", "2026-08-22T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
def test_reset_and_catalog_change(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
{"t": "reset", "ts": "2026-08-20T11:00:00Z", "reason": "x"}]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "reset")
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
st = self.compute(recs, chash="different")
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "catalog")
def test_requires_uses_satisfied(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")]
st = self.compute(recs)
self.assertTrue(st["tests"][self.live.id]["requires_met"])
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
st = self.compute(recs, man=man2)
self.assertFalse(st["tests"][self.live.id]["requires_met"])
self.assertEqual(st["tests"][self.live.id]["missing_requires"], ["forgectrl.panel"])
def test_running_marker(self):
st = campaign.compute([], self.tests, self.man, self.chash, running=self.ui.id)
self.assertEqual(st["tests"][self.ui.id]["status"], "running")
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
import unittest
import helpers
from forgetest import manifest as m
from forgetest import catalog
class GlobTests(unittest.TestCase):
def test_star_and_doublestar(self):
rx = m.glob_to_regex("src/*.c")
self.assertTrue(rx.match("src/main.c"))
self.assertFalse(rx.match("src/sub/main.c"))
rx = m.glob_to_regex("src/**")
self.assertTrue(rx.match("src/main.c"))
self.assertTrue(rx.match("src/sub/deep/x.h"))
self.assertFalse(rx.match("docs/x"))
rx = m.glob_to_regex("**/*.md")
self.assertTrue(rx.match("README.md"))
self.assertTrue(rx.match("docs/a/b.md"))
self.assertFalse(rx.match("docs/a/b.txt"))
rx = m.glob_to_regex("**")
self.assertTrue(rx.match("anything/at/all"))
rx = m.glob_to_regex("src/gfcool*")
self.assertTrue(rx.match("src/gfcool_client.c"))
self.assertFalse(rx.match("src/x/gfcool.c"))
def test_question_mark_and_escaping(self):
rx = m.glob_to_regex("a?c.d")
self.assertTrue(rx.match("abc.d"))
self.assertFalse(rx.match("abcxd"))
self.assertFalse(rx.match("a/c.d"))
class FingerprintTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
def test_stable_and_order_independent(self):
c1 = [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")]
c2 = list(reversed(c1))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c2))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c1))
def test_changes_only_when_covered_file_changes(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
other = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(base, m.fingerprint(other, covers), "an uncovered change must not move the fingerprint")
changed = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(base, m.fingerprint(changed, covers))
added = helpers.with_file(self.man, "forgectrl", "src/ui_extra.c", "new")
self.assertEqual(base, m.fingerprint(added, covers), "an unmatched new file does not move it")
added2 = helpers.with_file(self.man, "forgectrl", "src/x.c", "new")
self.assertNotEqual(base, m.fingerprint(added2, [("forgectrl", "src/*.c")]))
def test_platform_is_always_in(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
p2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "e" * 64})
self.assertNotEqual(base, m.fingerprint(p2, covers))
p3 = helpers.with_platform(self.man, layers={"meta-forgefirm": {"content_sha256": "2" * 64},
"poky": {"rev": "p" * 40}})
self.assertNotEqual(base, m.fingerprint(p3, covers))
def test_kernel_pseudo_files(self):
covers = [("linux-fslc", "**")]
base = m.fingerprint(self.man, covers)
k2 = helpers.with_file(self.man, "linux-fslc", "@config", "cfg2")
self.assertNotEqual(base, m.fingerprint(k2, covers))
def test_missing_component_marker(self):
f1 = m.fingerprint(self.man, [("nonexistent", "**")])
f2 = m.fingerprint(self.man, [("forgectrl", "nothing-matches-*")])
self.assertNotEqual(f1, f2)
def test_dev_only_refused(self):
with self.assertRaises(ValueError):
m.fingerprint(self.man, [("forgetest", "**")])
def test_extra_folds_in(self):
covers = [("forgectrl", "src/ui.c")]
self.assertNotEqual(m.fingerprint(self.man, covers, extra=["a"]),
m.fingerprint(self.man, covers, extra=["b"]))
def test_submodule_gitlink_and_files(self):
covers = [("grblhal-glowforge", "src/grbl/**")]
base = m.fingerprint(self.man, covers)
core = helpers.with_file(self.man, "grblhal-glowforge", "src/grbl/core.c", "core v2")
self.assertNotEqual(base, m.fingerprint(core, covers))
link = [("grblhal-glowforge", "src/grbl")]
self.assertEqual(m.fingerprint(self.man, link), m.fingerprint(core, link),
"the gitlink alone does not see a file-level change (fixture keeps the link id)")
def test_identity_sha_ignores_dev_only(self):
a = self.man.identity_sha()
b = helpers.with_file(self.man, "forgetest", "forgetest/x.py", "changed").identity_sha()
self.assertEqual(a, b)
c = helpers.with_file(self.man, "forgectrl", "src/ui.c", "changed").identity_sha()
self.assertNotEqual(a, c)
class CoverageReportTests(unittest.TestCase):
def test_report(self):
man = helpers.make_manifest()
t1 = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")])
t2 = helpers.make_test("a.two", [("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")])
rep = m.coverage_report(man, [t1, t2], allow=[("*", "**/*.md")])
self.assertEqual(set(rep), {"forgectrl"})
self.assertEqual(rep["forgectrl"], ["src/cool.c", "src/main.c"])
rep2 = m.coverage_report(man, [t1, t2, helpers.make_test("a.three", [("forgectrl", "src/**")])],
allow=[("*", "**/*.md")])
self.assertEqual(rep2, {})
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
"dev-only components are outside the report")
class CatalogTests(unittest.TestCase):
def test_catalog_hash_is_definition_only(self):
r1 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")]))
r2 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], fn=lambda ctx: 1))
self.assertEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r2))
r3 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/**")]))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r3))
r4 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], always=True))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r4))
def test_validate(self):
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)))
with self.assertRaises(ValueError):
catalog.validate(r)
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)),
helpers.make_test("a.two", [], requires=("a.one",)))
with self.assertRaises(ValueError):
catalog.validate(r)
def test_decorator_rules(self):
with self.assertRaises(ValueError):
catalog.test("bad id", title="x", subsystem="s")(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("s.x", title="x", subsystem="s", covers=[("forgetest", "**")])(lambda c: None)
def test_real_suite_loads_and_validates(self):
reg = catalog.load_suite()
self.assertIn("image.health", reg)
self.assertTrue(reg["image.health"].always)
catalog.validate(reg)
for t in catalog.all_tests(reg):
self.assertEqual(len(t.source_sha), 64)
if __name__ == "__main__":
unittest.main()
+309
View File
@@ -0,0 +1,309 @@
"""Runner + HTTP API end to end on localhost with a fake catalog and a
fake bench tool. No hardware, no forgectrl."""
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
import helpers
from forgetest import bench as bench_mod
from forgetest import catalog, server
from forgetest.log import Log
from forgetest.runner import Failed, Runner
def t_pass(ctx):
ctx.log("hello")
ctx.evidence["k"] = 1
def t_prompt(ctx):
ans = ctx.prompt("Did the light blink?", ("Yes", "No"))
if ans != "Yes":
raise Failed("operator said no")
def t_fail(ctx):
ctx.check(False, "deliberate")
def t_slow(ctx):
ctx.sleep(30)
def t_error(ctx):
raise RuntimeError("boom")
class ServerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="forgetest-")
os.environ["FORGETEST_DATA"] = cls.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(cls.tmp, "marker")
cls.man = helpers.make_manifest()
cls.reg = helpers.registry(
helpers.make_test("fake.pass", [("forgectrl", "src/ui.c")], always=True, fn=t_pass),
helpers.make_test("fake.prompt", [("forgectrl", "src/auth.c")], fn=t_prompt, kind="operator"),
helpers.make_test("fake.fail", [("forgectrl", "src/cool.c")], fn=t_fail),
helpers.make_test("fake.slow", [("forgectrl", "src/main.c")], fn=t_slow),
helpers.make_test("fake.error", [("forgectrl", "src/main.c")], fn=t_error),
helpers.make_test("fake.live", [("grblhal-glowforge", "src/**")], fn=t_pass, kind="live"),
helpers.make_test("fake.needs", [("kernel-module-glowforge", "**")], fn=t_pass,
requires=("fake.prompt",)),
)
# a fake bench tool
cls.tooldir = os.path.join(cls.tmp, "bench")
os.makedirs(cls.tooldir)
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
f.write("import sys, time\nprint('args', sys.argv[1:])\nsys.stdout.flush()\n"
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
"ported": True, "desc": "echo",
"args": [{"name": "word", "type": "str", "default": "hi", "help": ""},
{"name": "n", "type": "int", "default": 2, "help": ""}]},
{"id": "unported", "title": "U", "script": "nope.py", "safety": "dry", "where": "host",
"ported": False, "desc": "", "args": []},
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
"ported": True, "desc": "", "args": []}]
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
index_path=os.path.join(cls.tmp, "bench.jsonl"))
cls.log = Log(os.path.join(cls.tmp, "results.jsonl"))
cls.runner = Runner(cls.log, cls.man, cls.reg, cls.bench)
cls.token = server.load_token(os.path.join(cls.tmp, "token"))
cls.app = server.App(cls.runner, cls.token, export_dir=os.path.join(cls.tmp, "export"))
cls.srv = server.make_server(cls.app, "127.0.0.1", 0)
cls.port = cls.srv.server_address[1]
cls.th = threading.Thread(target=cls.srv.serve_forever, daemon=True)
cls.th.start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
shutil.rmtree(cls.tmp, ignore_errors=True)
# -- helpers ----------------------------------------------------------
def call(self, method, path, body=None, token=True, headers=None):
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port}
if token:
hdrs["X-ForgeFIRM-Token"] = self.token
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
st = r.status
ct = r.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
raw = e.read()
st = e.code
ct = e.headers.get("Content-Type", "")
if "json" in ct:
return st, json.loads(raw.decode())
return st, raw
def wait_idle(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if not d["running"]:
return d
time.sleep(0.1)
self.fail("run did not finish")
def wait_prompt(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
st, d = self.call("GET", "/state")
if d["running"] and d["running"]["prompt"]:
return d["running"]["prompt"]
time.sleep(0.05)
self.fail("no prompt appeared")
# -- tests -----------------------------------------------------------------
def test_01_auth_and_page(self):
st, d = self.call("GET", "/state", token=False)
self.assertEqual(st, 200)
st, d = self.call("GET", "/", token=False)
self.assertEqual(st, 200)
self.assertIn(self.token.encode(), d)
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
self.assertEqual(st, 403)
st, d = self.call("GET", "/state", headers={"Sec-Fetch-Site": "cross-site"})
self.assertEqual(st, 403)
st, d = self.call("POST", "/start", {"test": "fake.pass"}, token=False)
self.assertEqual(st, 403)
self.assertEqual(d["error"], "authentication required")
st, d = self.call("GET", "/catalog")
self.assertEqual(st, 200)
self.assertEqual(len(d["tests"]), 7)
st, d = self.call("GET", "/nope")
self.assertEqual(st, 404)
def test_02_run_pass_opens_campaign(self):
st, d = self.call("GET", "/state")
self.assertIsNone(d["campaign"])
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertEqual(state["tests"]["fake.pass"]["status"], "pass")
self.assertEqual(state["last_run"]["finished"]["result"], "PASS")
st, rec = self.call("GET", "/result?test=fake.pass")
self.assertEqual(st, 200)
self.assertEqual(rec["evidence"], {"k": 1})
self.assertTrue(any("hello" in l for l in rec["log"]))
def test_03_requires_and_live_gate(self):
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 409)
self.assertIn("prerequisites", d["message"])
st, d = self.call("POST", "/start", {"test": "fake.live"})
self.assertEqual(st, 409)
self.assertIn("live", d["message"])
st, d = self.call("POST", "/start", {"test": "fake.live", "ack_live": True})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.live"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.live")
self.assertTrue(rec["evidence"]["operator"]["ack_live"])
def test_04_prompt_flow(self):
st, d = self.call("POST", "/start", {"test": "fake.prompt"})
self.assertEqual(st, 200)
p = self.wait_prompt()
self.assertEqual(p["options"], ["Yes", "No"])
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Maybe"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/answer", {"prompt_id": p["id"], "value": "Yes"})
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.prompt"]["status"], "pass")
st, rec = self.call("GET", "/result?test=fake.prompt")
self.assertEqual(rec["answers"][0]["answer"], "Yes")
# now the dependent test may start
st, d = self.call("POST", "/start", {"test": "fake.needs"})
self.assertEqual(st, 200)
self.wait_idle()
def test_05_busy_abort(self):
st, d = self.call("POST", "/start", {"test": "fake.slow"})
self.assertEqual(st, 200)
st, d = self.call("POST", "/start", {"test": "fake.pass"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/abort")
self.assertEqual(st, 200)
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.slow"]["status"], "aborted")
self.assertIsNotNone(state["campaign"], "an abort does not close the campaign")
def test_06_error_and_fail_close_campaign(self):
st, d = self.call("GET", "/state")
cid = d["campaign"]["id"]
st, d = self.call("POST", "/start", {"test": "fake.error"})
state = self.wait_idle()
self.assertEqual(state["tests"]["fake.error"]["status"], "error")
self.assertIsNone(state["campaign"])
self.assertEqual(state["closed_by"], "fail")
# inherited passes survive; the core is required again
self.assertEqual(state["tests"]["fake.prompt"]["status"], "inherited")
self.assertTrue(state["tests"]["fake.pass"]["required"])
# a new start opens a new campaign
st, d = self.call("POST", "/start", {"test": "fake.pass"})
state = self.wait_idle()
self.assertIsNotNone(state["campaign"])
self.assertNotEqual(state["campaign"]["id"], cid)
def test_07_export_and_invalidate(self):
st, d = self.call("POST", "/export")
self.assertEqual(st, 200)
self.assertFalse(d["authorized"])
st, raw = self.call("GET", "/export/acceptance.json")
self.assertEqual(st, 200)
art = raw if isinstance(raw, dict) else json.loads(raw)
self.assertEqual(art["manifest_sha"], self.man.content_sha)
st, raw = self.call("GET", "/export/acceptance.md")
self.assertEqual(st, 200)
self.assertIn(b"Release authorized: NO", raw)
st, d = self.call("POST", "/invalidate", {"reason": ""})
self.assertEqual(st, 400)
st, d = self.call("POST", "/invalidate", {"reason": "new tube"})
self.assertEqual(st, 200)
st, d = self.call("GET", "/state")
self.assertEqual(d["invalidate"]["reason"], "new tube")
self.assertIsNone(d["campaign"])
for tid, ts in d["tests"].items():
self.assertNotEqual(ts["status"], "inherited", tid)
st, raw = self.call("GET", "/log", token=False)
self.assertEqual(st, 200)
self.assertIn(b'"t": "invalidate"'.replace(b" ", b""), raw.replace(b" ", b""))
def test_08_bench(self):
st, d = self.call("GET", "/bench")
self.assertEqual(st, 200)
ids = [t["id"] for t in d["tools"]]
self.assertEqual(ids, ["echo", "unported", "hot", "tk"])
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": "x"}})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "yo", "n": 5}})
self.assertEqual(st, 200, d)
state = self.wait_idle()
self.assertEqual(state["last_run"]["kind"], "bench")
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
st, d = self.call("GET", "/bench")
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
# a failing tool and an aborted one
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "fail", "n": 1}})
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "EXIT 3")
st, d = self.call("POST", "/bench/start", {"tool": "echo", "args": {"word": "slow", "n": 1}})
self.assertEqual(st, 200)
time.sleep(0.5)
st, d = self.call("POST", "/abort")
state = self.wait_idle()
self.assertEqual(state["last_run"]["finished"]["result"], "ABORTED")
# a takeover tool runs inside the takeover wrapper (init.d is absent on the host: rc 127)
st, d = self.call("POST", "/bench/start", {"tool": "tk"})
self.assertEqual(st, 200, d)
state = self.wait_idle()
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
# bench runs never touched the acceptance log
recs = self.log.read()
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
def test_09_recovery_marker(self):
marker = os.environ["FORGETEST_MARKER"]
with open(marker, "w") as f:
f.write("x fake.slow\n")
r = Runner(self.log, self.man, self.reg, self.bench)
self.assertTrue(any("recovered" in m for m in r.messages))
self.assertFalse(os.path.exists(marker))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,47 @@
SUMMARY = "ForgeFIRM release acceptance tool (dev image)"
DESCRIPTION = "Runs the release acceptance catalog against the machine from a \
self-contained web page (HTTP :8090), keeps the append-only result log under \
/data/forgetest, exports the release artifact the release gate reads, and \
carries the bench diagnostics page. Installed only on the dev image."
HOMEPAGE = "https://github.com/ScottW514/forgefirm"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
# The tool's canonical home is forgetest/ in this repo; the bench scripts
# it drives live in scripts/bench/. Both are packaged from the same tree
# that builds the images, so the catalog and the gate agree by construction.
FILESEXTRAPATHS:prepend := "${THISDIR}/../../../:"
SRC_URI = " \
file://forgetest/ \
file://scripts/bench/ \
"
S = "${WORKDIR}"
inherit python3-dir update-rc.d forgefirm-manifest
# Non-git sources: fingerprint the package directory (dev-only component;
# it identifies the campaign, never a test fingerprint).
FORGEFIRM_MANIFEST_SRC = "${WORKDIR}/forgetest/forgetest"
RDEPENDS:${PN} = "python3 forgectrl"
INITSCRIPT_NAME = "forgetest"
INITSCRIPT_PARAMS = "start 95 2 3 4 5 . stop 70 0 1 6 ."
do_install() {
# the package (sources only; python compiles on first import)
for f in $(cd ${WORKDIR}/forgetest/forgetest && find . -name '*.py' -type f); do
install -Dm 0644 ${WORKDIR}/forgetest/forgetest/$f \
${D}${PYTHON_SITEPACKAGES_DIR}/forgetest/$f
done
# the bench scripts the #bench tab drives
install -d ${D}${datadir}/forgetest/bench
for f in ${WORKDIR}/scripts/bench/*.py; do
install -m 0755 $f ${D}${datadir}/forgetest/bench/
done
install -Dm 0755 ${WORKDIR}/forgetest/forgetest.init ${D}${sysconfdir}/init.d/forgetest
}
FILES:${PN} += "${PYTHON_SITEPACKAGES_DIR}/forgetest ${datadir}/forgetest"
@@ -3,9 +3,12 @@ require forgefirm-image.bb
DESCRIPTION = "OpenGlow/ForgeFIRM development image for Glowforge"
# Strict superset of forgefirm-image: everything the main image ships, plus
# debug tooling.
# debug tooling. forgetest is the release acceptance tool (HTTP :8090) and
# the bench diagnostics page; it belongs to the bench, never to a release
# image (docs/ACCEPTANCE.md).
IMAGE_INSTALL += " \
forgectrl \
forgetest \
"
# debug-tweaks (passwordless root, root SSH login) belongs ONLY to the dev
+7 -1
View File
@@ -1,7 +1,13 @@
# ForgeFIRM bench tools
Hardware-verification tools for the ForgeFIRM bench. All run ON the
target board (dev image, python3 present) unless noted. Host-side tools
target board (dev image, python3 present) unless noted. The dev image
installs them under `/usr/share/forgetest/bench/`, and the acceptance
tool's **Bench diagnostics** tab (`http://<machine>:8090/#bench`,
`docs/ACCEPTANCE.md`) runs the board-side ones with their arguments and
the output on the page (takeover tools get forgectrl stopped and started
around the run); the acceptance catalog itself is built from ports of
these drills. Host-side tools
take the machine address from `GF_HOST` (or `argv`, where stated); the
ones that shell into the board over ssh use the `ssh` on `PATH`, or the
client named by `GF_SSH` (for example `GF_SSH='wsl -d <distro> -- ssh'`