fix(diagnostics): categorise every finding so a red run says which of four things it is (#3312) - #3406
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF Scorecard
Scanned Files
|
Xore
enabled auto-merge (squash)
September 27, 2026 13:48
… four things it is (#3312) diagnostics.yml failed 12 scheduled runs in a row and nobody triaged it, which means it carried no signal at all. The findings were not all wrong -- several were correct -- but a real host fault, a lane that had never been able to see anything, a check asking from a vantage point Cloudflare answers differently to, and a deliberate stand-down all produced the same thing: a red X and an ::error:: line that named none of them. A run that lists five unrelated things under one heading gets triaged by ignoring it. Every finding now carries one of four categories, in the annotation's own title and as one row per finding in a ledger each job prints at the end: fault a real regression. Fatal. runner-config the check could not run because of how the runner or this repository's environment is configured. Still fatal -- folding "could not tell" into a pass is what makes a check worse than not having it, and #3283 is what that costs when it is wrong -- but its own category, so "this lane has never been able to measure anything" reads as the operator command that fixes it rather than as a dead pipeline. unmeasured the check did not run, and that is not a pass. Fatal. expected a declared, deliberate absence. Never fatal. The vocabulary lives in scripts/diagnostics-lib.sh, which every step sources; five inline copies in YAML would be five things to keep in step. isolation-audit.sh gains the same separation. The sandbox networks, the nwfilter and libvirt's socket are all absence invariants, and "missing" had two unrelated causes reported identically: a real regression (the 2026-09-23 reboot let the modular virt* units win the Conflicts= race, #3338), and a stand-down to free RAM/CPU for a heavy leg, which is standing practice here (#3135). A dated, issue-referencing declaration at /etc/apiary/sandbox-standdown now tells them apart; expired, malformed or over-long declarations are themselves FAIL, so a stale file cannot rot into a permanent excuse. A declaration excuses absence only -- a network that exists and forwards, or a planted FORWARD ACCEPT, is still a fault with one in force. Two check bugs fixed while making the categories honest: - `ss -tlnp | grep ':22 '` exits 1 both when nothing listens on :22 and when ss could not run at all, so the sshd check had never once answered on this host. It now distinguishes "nothing is listening" (OK) from "the listen address could not be read" (UNMEAS, fatal). - `docker ps | grep -E '^(hp-|sbx-)'` exits 1 on a host with no stack containers, which was reported as "could not enumerate containers (docker ps failed)" -- a claim about the tool printed when the truth was a claim about the deployment. The audit now runs from the job's checkout rather than from /opt/stacks/apiary (#2908): /opt/stacks is refreshed only by deploy.yml, which is workflow_dispatch-only, so the audit that named things #3338 had already fixed was a copy that had not been redeployed. The deployed tree is still diffed against origin/main and reported when it drifts. No check is deleted and nothing is folded into a pass to make a run green. The job stays red until an operator runs `sudo scripts/github-ci-runner/install-deploy-runner.sh --helpers-only` to apply the source-health grant; that finding is now filed as a runner-config gap naming that command rather than as a broken pipeline.
Xore
force-pushed
the
oc/3312-diagnostics-signal
branch
from
September 27, 2026 15:29
5452cbb to
3504a26
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3312.
diagnostics.ymlfailed 12 scheduled runs in a row and nobody triaged it, which means it carried no signal at all. The findings were not all wrong — several were correct — but four unrelated things all produced the same output: a red X and an::error::line that named none of them. A run that lists five unrelated findings under one heading gets triaged by ignoring it.This PR decides each reported finding, makes the check able to tell the categories apart, and keeps every one of them fatal except a declared, deliberate absence.
1. Classification of every reported failure
metrics unavailable: no DASHBOARD_SERVICE_TOKEN in /opt/stacks/honeypot-dashboard/.env0600.envand the job ran asgithub-deploy-runner, so the job's ownsedread failed on permission and the failure was reported as an absent key — a claim about the host it had no way to make. #3338's root-owned helper fixes the read, but the lane stayed red until an operator applied the grant, which a workflow cannot do for them.runner-config, still fatal, naming the one command that fixes it.OIDC discovery … returned 403, not 200unmeasured(no HTTP response at all) vsfault(answered, and not 200) are separate findings.libvirt socket not found at /var/run/libvirt/libvirt-sockvirt*units win theConflicts=race againstlibvirtdand take the socket with them. #3335's hypothesis — that #3135'sdocker pausecaused it — is disproved: that pauses containers, never libvirt.FAIL, exit 1.libvirt network 'sandbox' does not exist/'honeypot-sandbox' does not exist/'honeypot-sandbox-strict' nwfilter is missingvirshas a non-root caller resolves toqemu:///session— an empty per-user instance — so active objects onqemu:///systemread as missing. Fixed on main (#3338 pins the URI). Separately, these are absence invariants, and absence has two causes: a real regression, or a deliberate stand-down to free RAM/CPU for a training leg, which is standing practice here (#3135). Before this PR both printed the sameFAIL.EXPECTand exits 0 while a regression staysFAILand exits 1.hp-unsloth-studio has no cap_drop: ALL and is on neither listxore-owned0600notebook as that user, socap_drop: ALLalone broke it. #3339 gives itcap_drop: ALLpluscap_add: [DAC_OVERRIDE](andno-new-privileges), and #3341 dropped the tracked-gap list entry. Live:CapDrop=[ALL],CapAdd=[CAP_DAC_OVERRIDE].The exceptions list
No new entry was added to any exceptions or tracked-gaps list, so there is nothing to justify by reference. The only audit lists touched are unchanged in membership.
The mechanism I did add is a stand-down declaration, which is the same idea with a harder deadline:
scripts/sandbox-standdown.sh declare --issue '#NNNN' --until YYYY-MM-DD --reason '…', written to/etc/apiary/sandbox-standdown(root-owned0644, because the audit runs unprivileged). An exception with an owner issue and an expiry is the reference — every declaration must cite one, andissue: 1without the#is rejected by both the writer and the reader. Expired, malformed, unparseable-date, and more-than-14-days-out declarations are allFAILthemselves, so the file cannot rot into a permanent excuse. A declaration excuses absence only: a libvirt network that exists and forwards, or a plantedFORWARDACCEPTforvirbr-sandbox, is still a fault with one in force (both are pinned by tests).2. The vps job, broken down
Nobody had done this. From run
36219375542(2026-09-23) and the runs since:Configure deployment key— passes.Inspect the VPS edge— the sentinel printed, so SSH delivered the whole report. Passed. Compose ps, eve file sizes and timestamps, the ruleset age check, disk and WireGuard peers were all read successfully.Traefik origin certificate expiry (#3328)— passed; the certificate was readable and not near expiry.OIDC discovery is reachable (#1225)— the only failure:OIDC discovery HTTP status: 403. That 403 is Cloudflare's answer to the GitHub-hosted runner's address range, not OIDC's health (see the table above). The VPS job has beensuccessin the last four scheduled runs, since fix(ops): make Diagnostics truthful again — runner vantage, virsh URI, token access, libvirt stack #3338 moved the probe to the VPS.So the vps job's red X was one runner-vantage finding wearing the same uniform as the home job's. It is now filed as such.
3. What changed
Every finding is categorised, in the annotation's own title and as one row per finding in a ledger each job prints last:
faultrunner-configunmeasuredexpectedrunner-configandunmeasuredstaying fatal is deliberate and is the same positionscripts/verify-deploy.shalready takes with its exit 2. Folding "could not tell" into a pass is what makes a check worse than not having it — #3283 is what that costs when it is wrong (Elasticsearch at 1000/1000 shards, every sensor dead-lettered for six days, in a lane whose only question is whether the pipeline is flowing). What changes is that it is tellable:::error title=runner-config: …names the fix, and the ledger's counts make "0 faults, 1 runner-config gap" a healthy host with one misconfigured lane rather than an incident.The vocabulary lives in
scripts/diagnostics-lib.sh(new), which every step sources. Five inline copies in YAML would be five things to keep in step; a test asserts noalert/notecall site is uncategorised and that no call site uses a category the library does not define.Two check bugs fixed while making the categories honest:
ss -tlnp | grep ':22 'exits 1 both when nothing listens on :22 and when ss could not run at all, so the sshd check had never once answered on this host — it reported "could not confirm" about a host whose answer was simply "nothing is listening". It now distinguishesOKfromUNMEAS, andUNMEASis fatal for the isolation barriers. "TheFORWARDchain could not be read" and "theFORWARDchain is not DROP" are different findings, and only the second says the host is unprotected.docker ps | grep -E '^(hp-|sbx-)'exits 1 on a host with no stack containers, reported ascould not enumerate containers (docker ps failed)— a claim about the tool printed when the truth was a claim about the deployment. Both are faults; only one is fixable by reinstalling docker.The audit runs from the job's checkout (#2908), not from
/opt/stacks/apiary. That directory is refreshed only bydeploy.yml, which isworkflow_dispatch-only, so the audit that kept naming things #3338 had already fixed was a copy that had not been redeployed. A check that audits the host with a stale copy of its own question cannot report a fix as fixed. The deployed tree is still diffed againstorigin/mainand reported when it drifts — drift is still a real finding for everything else on the host that runs a deployed script.One wrong-signal bug fixed in passing: the cert step's
< 30 daysbranch wasreport "::error::…"followed by[ "${{ github.event_name }}" = "schedule" ] && exit 1. That compound was the last command in the block, so a manual run exited 1 anyway — aworkflow_dispatchrun went red for a warning it was explicitly meant to report only.alert()has the schedule gate inside it, so the intent is now the behaviour.4. Verification
tests/docs/test_3312_signal_categories.py(new, 35 tests) drives the realisolation-audit.shand the realscripts/diagnostics-lib.shand the realrun:body of the isolation step against synthesised hosts (every external command is a PATH stub,unshare -rm+ a bind mount for/var/run/libvirt). It is behaviour, not string-matching: a finding is asserted by its category column, not by a substring.Before/after exit codes, same host, only the declaration differs (
origin/main:scripts/isolation-audit.shvs this branch):#3135, until 2026-10-04)The expected-state case is the only one that changes, and it changes because the host now has something to say. A real fault is still 1; a rot exception is still 1.
The expected-state run says so in its own output, rather than going quiet:
Live run on this host (a scratch box, not the production runner), showing the categories on real findings — note that
no hp-* or sbx-* container exists on this host at allis now a claim about the deployment, distinct from a docker failure:The isolation step, executed as the runner executes it (its literal
run:body, with a stub audit):0 unmeasured … 1 fault(s), exit 1fault2 unmeasured … 1 fault(s), exit 1unmeasured0 unmeasured … 0 fault(s), exit 0ok…each of which then renders in the job's ledger:
Gates:
pytest tests/docs/556 passed, 1 xfailed, 17 subtests (521 before this branch; +35 new).actionlint -S warningclean.shellcheck -S warningclean on all three scripts,bash -nclean.check-doc-paths-exist.py,check-docs-reachable.py,check-doc-stale-paths.py,check-compose-env-docs.py,check-public-leaks.pyall pass.5. Constraints
|| trueorcontinue-on-erroranywhere.6. Two things this PR does not do
sudo scripts/github-ci-runner/install-deploy-runner.sh --helpers-onlyapplies the source-health helper and its grant without stopping the runner service. A merged PR cannot put host state on the box, so the metrics lane is still unmeasurable until that runs. It is now arunner-configfinding that names the command, rather than a red X that reads as a broken ingest pipeline. I would rather leave it red than make "could not tell" mean "fine".cap_drop. I looked at adding a static compose-vs-exceptions check (7 services are not inhp-*scope or are on the audit's lists). It is a separate concern from diagnostics signal and a new gate inquality.ymlis a larger surface than this issue asks for. Worth its own issue.