assay_env: the capstone role — fingerprint to interchange file (proposed) - #26
Conversation
Nothing wrote /etc/assay/environment.json, so every machine this repo built
was unfingerprinted as far as assay was concerned - env@none on every run,
and results with no identity for the conditions that produced them. The
capture existed (scripts/vm-fingerprint.sh) and the contract existed (assay
environments/README.md); the two were not connected.
assay_env is the layer-2 capstone. It installs nothing. It runs the existing
capture script from the CONTROL node (delegate_to: localhost, become: false -
the script SSHes in, so re-implementing it on the guest would fork it),
parses the DIGESTS block, and writes the interchange file the contract
specifies: schema as the number 1, core_digest and provider_digest as
sha256:<64 hex>, plus provider and definition as context.
Three things it does not do, deliberately:
- It never holds the capture as content. Given an outfile the script prints
only `fingerprint written to <path>` plus tail -4 on stderr, and that tail
is what is parsed - the body carries the guest's authorized_keys and sshd
drop-ins. The role refuses to run at all if assay_env_capture_path is
empty, because without an outfile the whole capture would arrive on stdout
and land in ansible's registered output.
- It never writes a partial file. The parse keeps only lines matching
^(core|provider|combined)\s+sha256:[0-9a-f]{64}$, so ssh noise cannot be
mistaken for a digest and a truncated hash is a non-match rather than a
match on a truncated hash. Missing any of the three fails before the first
write: a file naming a digest that was never computed is worse than no
file, because assay believes it.
- It never moves the digest it records. CORE reads enumerated /etc paths
(os-release, three apt.conf.d files, the Types/Suites/Components/Snapshot
lines of sources.list.d/*.sources, sshd_config.d/*.conf, docker/daemon.json)
and the ~/.local and ~/.bun trees. It does not walk /etc, so /etc/assay is
outside it. file and copy are the only modules touching the target, and
they touch nothing else - no package, no unit, no user, no hostname.
provider is hardcoded to proxmox-ve with a comment rather than read from a
hostvar: inventory/tofu.py builds hostvars from the vm-pve module's output
and that is the only provider in this tree. When a second one lands it
belongs in the tofu output and this default becomes the fallback.
Wired into the inventory example as the last role, with the reason: anything
listed after it is software the recorded identity does not describe.
README's "docker - the only role using become" was true and no longer is.
Verified on the control node: ansible-lint clean at the production profile;
site.yaml syntax-check clean; a stubbed capture with a truncated core digest
refuses at "Refuse to write a partial environment file" with nothing written,
a well-formed one passes every guard, and `grep -c SECRET` over a -vvv run is
0 with the capture task's stdout empty. Not yet run against a live guest -
that is the acceptance run.
Refs the-metafactory/crucible#25
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
Review follow-up on 904774f. Three fixes. Fail closed on a failed re-capture. "Nothing has been written" was true and misleading: on a second run it is the file from the FIRST run that matters, still sitting on a machine that may have moved since, and that file is what assay believes. Refusing to write leaves a stale identity standing on an unverified machine - the exact failure this role exists to prevent, one run later. Capture, parse and the digest guard now sit in a block whose rescue removes the environment file before failing, so an identity nothing has just confirmed is withdrawn rather than inherited. assay reporting env@none for a machine with no file is a true statement; assay reporting a digest from last week is not. The rescue is one fail task with an inline conditional, not two guarded by `when: ... .changed` - that idiom trips ansible-lint's no-handler, and a handler is what this must not be: it has to fail the play, now, on this host. check_mode: false on the two control-node tasks. Neither writes to the guest: the stat reads the operator's own checkout, the capture reads the guest over SSH and writes only into the gitignored overlay. Skipped under --check they left the parse with nothing and the run died on an empty digest set, instead of showing the diff for the file actually at stake. README said assay_env and docker "are the only roles using become". False: base uses it throughout (ansible/roles/base/tasks/main.yaml:13 and handlers). Scoped to spec-declared roles, which is the distinction the README already draws two bullets up. Verified as root on Linux (python:3.13-slim, container as both control node and guest, stubbed capture): - happy path writes /etc/assay/environment.json 0644 root:root; core_digest and provider_digest equal the capture's DIGESTS block, schema is int 1, keys are exactly the five the contract names - second run on an unchanged machine: changed=0 - --check over a correct machine: changed=0, no crash - malformed capture with the file already standing: rescued=1, the file is gone from /etc/assay afterwards, play fails with both the original detail and what was withdrawn - --check on the fileless machine: shows the full + diff, writes nothing ansible-lint clean at the production profile, whole tree and role alone; site.yaml syntax-check clean. Refs the-metafactory/crucible#25 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
|
Review recommendation: do not merge yet. There is one blocking fail-closed gap in Please move directory creation, document assembly, and the final copy into the same rescued block so any unsuccessful refresh withdraws the previous file. Ideally, give preflight failures equivalent stale-file handling as well. A failure-path test where an old environment file exists and the final copy is forced to fail would lock this behavior down. Everything else I reviewed looks sound: the JSON matches Assay’s interchange contract, digest parsing is strict, the capture contents are not registered, argv avoids shell injection, |
…wal is verified Review follow-up on 40a164d, from vpzed-dev-lux on vpzed-dev#26. The block ended before the interchange directory and the copy, so the fail-closed guarantee covered only half the ways this role can fail. Capture succeeds, the write dies - full disk, a permission change, a dropped connection - and the environment file from an earlier run is still sitting on a machine that may have moved since. Same stale identity the block already existed to prevent, reached from the other end. Directory creation, document assembly and the copy now sit inside the same block, so any unsuccessful refresh withdraws the file that is no longer true. That widened the rescue's own failure mode. Whatever breaks the write is frequently what breaks the removal: on a read-only filesystem the copy fails with EROFS and the unlink fails too, and the old rescue would have failed the play with the removal's errno and no word about the stale file left standing. So the withdrawal now runs with failed_when: false, a stat confirms the outcome, and the message is written from the filesystem rather than from the module's report. If the file survived, the run says so in those words - COULD NOT BE REMOVED, still on the machine, treat the digest as UNVERIFIED. A withdrawal this role only attempted is never reported as one that happened. Fourth branch while the message was open: under --check nothing is ever removed, and saying "has been REMOVED" there was the cosmetic dishonesty recorded as known-outstanding on the PR. It now says the file is still there and would have been withdrawn on a real run. Pre-flight refusals stay OUTSIDE the block, declining the review's softer ask, and the tasks now carry a comment saying why. An empty capture path, a missing capture script, an inventory with no ansible_host/ansible_user are all control-node misconfigurations: the role has not run, nothing has touched the guest, and the file already on it still describes the machine the last successful run confirmed. Withdrawing an identity because the operator launched the playbook from the wrong directory would revoke a good machine's identity over a fault on this side of the SSH connection, and would make one bad checkout's blast radius the whole fleet. Verified as root on Linux (python:3.13-slim, container as both control node and guest, real /etc/assay, stubbed capture), 38 assertions, 0 failures: - write fails ENOSPC (tmpfs sized to 64k and filled) with a stale file present: rescued=1, failure names "Write the assay environment file", /etc/assay/environment.json is gone afterwards, message claims REMOVED and absence confirmed. Same case against the pre-fix role: rescued=0, the stale file SURVIVES, nothing in the output mentions it - red observed before green - write fails EROFS (read-only bind mount) with a stale file present: rescued=1, the removal fails, the run says COULD NOT BE REMOVED / STILL ON THE MACHINE / UNVERIFIED, and the stale file is still byte-identical. Pre-fix: rescued=0, no mention of the file at all - --check with a failing capture and a file on disk: the message names check mode, claims no removal, and the file is untouched - regressions all hold: happy path writes 0644 root:root with schema int 1, the capture's two digests and no combined key; re-run on an unchanged machine changed=0; malformed digests still refuse at the partial-file guard before any write, with the file withdrawn; --check on a correct machine is changed=0 and writes nothing (mtime, size and bytes identical) The harness is scratch and deliberately uncommitted: it needs a privileged Linux container to mount the failing filesystems, which is not something this repo's CI has today. ansible-lint clean at the production profile, whole tree (25 files) and role alone (3 files); site.yaml syntax-check clean. Refs the-metafactory/crucible#25 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm
|
Thanks — the boundary call was precise, and naming the specific failure modes (full disk, permissions, connection loss) is what made the second-order problem visible. Fixed in Blocking item — the rescue boundary. Widening the block widened the rescue's own failure mode, which turned out to be the more interesting half. Whatever breaks the write is frequently what breaks the removal: on a read-only filesystem the copy fails EROFS and the unlink fails too, and the old rescue would have died there — failing the play with the removal's errno and not one word about the stale file left standing. So the withdrawal now runs with The test you asked for. Container harness, Linux, root, real
Regressions re-run alongside: happy path (0644 root:root, The harness is deliberately uncommitted: it needs a privileged Linux container to mount the filesystems these cases require, which CI does not have today. Happy to land it as Pre-flight refusals — keeping them outside the block, deliberately. This is the one place I have not followed the recommendation, and the tasks now carry a comment stating the trade so it is a documented decision rather than an oversight ( |
Proposed — no obligation, and shaped to land around the in-flight
baserefactor. Implements the capstone role from the-metafactory/crucible#25: capture the machine's fingerprint (control-side, viascripts/vm-fingerprint.sh), and write the interchange file/etc/assay/environment.jsonthat assay's runner reads (contract: assay environments/README.md).What it does
delegate_to: localhost), capture to the gitignoredfingerprints/overlay.schema: 1,core_digest,provider_digest,provider,definition) viacopy, root:root 0644.env@noneis a true statement; an unverified digest is not).ansible_roles(it fingerprints what the earlier roles installed); inventory-example updated with the rationale.Evidence
ansible-lintproduction profile: 0/0, whole tree and role./etc/assay): happy path byte-matches the capture's DIGESTS block; re-run on an unchanged machinechanged=0; malformed capture → refusal observed red before any write, withdrawal observed (rescued=1, file gone);--checkwrites nothing and shows the honest diff./etc/assay). All refuted.Your call, Vincent (deliberately not decided here)
assay_envfollows the current underscore convention; if thebaserefactor lands a different naming scheme, rename at will, nothing depends on the string.provider: proxmox-vehardcoded in defaults — there is no provider fact intofu.py's hostvars today and vm-pve is the only module, so defaults felt honest; the comment in the file records that it should move to tofu output when a second provider exists.Known-outstanding (recorded, not hidden)
state: absentbehaves over SSH.--checkwith a failing capture, the failure message says the old file "has been REMOVED" though check mode removed nothing (safe direction; fix if the file is touched again).🤖 Generated with Claude Code
https://claude.ai/code/session_018PtJarXm8d8bSHDbhmskVm