Skip to content

assay_env: the capstone role — fingerprint to interchange file (proposed) - #26

Merged
vpzed-dev-lux merged 3 commits into
vpzed-dev:mainfrom
mellanon:feat/assay-env
Sep 2, 2026
Merged

assay_env: the capstone role — fingerprint to interchange file (proposed)#26
vpzed-dev-lux merged 3 commits into
vpzed-dev:mainfrom
mellanon:feat/assay-env

Conversation

@mellanon

@mellanon mellanon commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Proposed — no obligation, and shaped to land around the in-flight base refactor. Implements the capstone role from the-metafactory/crucible#25: capture the machine's fingerprint (control-side, via scripts/vm-fingerprint.sh), and write the interchange file /etc/assay/environment.json that assay's runner reads (contract: assay environments/README.md).

What it does

  • Runs the existing fingerprint script from the control node (delegate_to: localhost), capture to the gitignored fingerprints/ overlay.
  • Parses the three DIGESTS lines with a fully-anchored regex over the script's stderr tail; refuses loudly on any missing/truncated digest.
  • Writes the five contract fields (schema: 1, core_digest, provider_digest, provider, definition) via copy, root:root 0644.
  • Fails closed: if capture or parse fails on a machine that has an environment file from an earlier run, the role withdraws that file before failing — a stale identity is worse than none (env@none is a true statement; an unverified digest is not).
  • List it last in ansible_roles (it fingerprints what the earlier roles installed); inventory-example updated with the rationale.

Evidence

  • ansible-lint production profile: 0/0, whole tree and role.
  • Container harness (Linux, real /etc/assay): happy path byte-matches the capture's DIGESTS block; re-run on an unchanged machine changed=0; malformed capture → refusal observed red before any write, withdrawal observed (rescued=1, file gone); --check writes nothing and shows the honest diff.
  • Two independent review passes (standard + adversarial); the adversarial pass tried delegation leakage, stderr-shift parse corruption, stale-file reuse, shell injection, and the §4a invariant (writing the file must not move the core digest — it can't; CORE reads a fixed enumeration that never includes /etc/assay). All refuted.

Your call, Vincent (deliberately not decided here)

  1. Role nameassay_env follows the current underscore convention; if the base refactor lands a different naming scheme, rename at will, nothing depends on the string.
  2. provider: proxmox-ve hardcoded in defaults — there is no provider fact in tofu.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)

  • crucible#25 ACs 2 and 5 need a live-guest run (no ProxMox target on our side) — the acceptance block in the issue is copy-pasteable; also worth one live check that the rescue's state: absent behaves over SSH.
  • Cosmetic nit: under --check with 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

mellanon and others added 2 commits September 2, 2026 06:28
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
@vpzed-dev-lux

Copy link
Copy Markdown
Collaborator

Review recommendation: do not merge yet.

There is one blocking fail-closed gap in ansible/roles/assay_env/tasks/main.yaml. The rescue block covers fingerprint capture and digest parsing, but ends before the interchange directory creation and final copy task. If capture succeeds and the write subsequently fails—for example because of a full disk, permissions, or connection loss—the environment file from an earlier run remains on the guest even though the machine may have changed. That contradicts the role’s core invariant that a stale identity is worse than none.

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, git diff --check passed, local syntax checking passed, and GitHub’s validate check is green. After closing the rescue-boundary gap, I would consider this ready to merge.

…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
@mellanon

mellanon commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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 101e27e.

Blocking item — the rescue boundary. Create the interchange directory, Assemble the interchange document and Write the assay environment file are now inside the same block. Any unsuccessful refresh withdraws the previous file, whichever end it failed at.

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 failed_when: false, a stat confirms the outcome, and the message is written from the filesystem rather than from the module's report. When the file survives, the run says so in those words: COULD NOT BE REMOVED … STILL ON THE MACHINE … treat that digest as UNVERIFIED. A withdrawal this role only attempted is never reported as one that happened. While the message was open I also added the fourth branch for --check — the cosmetic nit recorded in the PR description; it no longer claims a removal that check mode never performs.

The test you asked for. Container harness, Linux, root, real /etc/assay, stubbed capture, run privileged so the failing filesystems can be mounted. Two new failure-path cases, both observed red against 40a164d first:

  • write fails ENOSPC — tmpfs sized to 64k at /etc/assay, stale file present, filled to 100%. Pre-fix: rescued=0, the stale file survives, nothing in the output mentions it. Post-fix: rescued=1, failure names Write the assay environment file with [Errno 28] No space left on device, /etc/assay/environment.json is gone afterwards, message claims REMOVED with absence confirmed.
  • write fails EROFS — read-only bind mount, stale file present. Pre-fix: rescued=0, no mention of the file at all. Post-fix: rescued=1, the removal fails, the run reports COULD NOT BE REMOVED / STILL ON THE MACHINE / UNVERIFIED, and the stale file is still byte-identical.

Regressions re-run alongside: happy path (0644 root:root, schema int 1, the capture's two digests, no combined), idempotent re-run changed=0, malformed digests refusing at the partial-file guard before any write with the file withdrawn, and --check on a correct machine changed=0 writing nothing (mtime, size and bytes identical). 38 assertions, 0 failures. ansible-lint production profile clean on the tree (25 files) and the role alone (3); site.yaml --syntax-check clean.

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 scripts/test-assay-env.sh alongside test-vm-fingerprint.sh if you would rather have it in the tree — say the word and it comes in the next push.

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 (ansible/roles/assay_env/tasks/main.yaml, the comment block directly above Refuse to capture without a capture path). All three pre-flight refusals are control-node misconfigurations: an empty assay_env_capture_path, no capture script in this checkout, an inventory serving no ansible_host/ansible_user. In every one, 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 revokes a good machine's identity over a fault on this side of the SSH connection — it would make one bad checkout's blast radius the whole fleet. From the capture command onward the guest is in play, and everything from there down is inside the block. An adversarial review pass reached the same conclusion independently. If you read it the other way I will move them, but I would want the trade recorded either way.

@vpzed-dev-lux
vpzed-dev-lux merged commit cf78664 into vpzed-dev:main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants