Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ overall testing workflow.
false. Third-party repos (e.g. docker) are covered by explicit version pins
in the ansible role defaults instead.
- **Composable post-boot software.** A spec lists ansible roles, for example,
(`ansible_roles: [nats_server, bun, claude, docker, metafactory_arc]`) and
(`ansible_roles: [nats_server, bun, claude, docker, metafactory_arc,
assay_env]`) and
`ansible-playbook ansible/site.yaml` applies them — idempotently, with every
download verified and every version pinned. A dynamic inventory reads
the tofu state, so there is no hosts file to maintain, and changing ansible
Expand Down Expand Up @@ -73,7 +74,8 @@ overall testing workflow.
│ ├── site.yaml # base, then each VM's declared roles
│ ├── inventory/tofu.py # dynamic inventory from tofu output
│ └── roles/<role>/ # base (every VM), then nats_server, bun,
│ # claude, docker, metafactory_arc
│ # claude, docker, metafactory_arc,
│ # assay_env (last: fingerprint + interchange)
├── inventory/ # one YAML file per VM [EDIT]
├── scripts/ # build-image.sh, check-ansible.sh,
│ # install-collections.sh, vm-fingerprint.sh
Expand Down Expand Up @@ -322,10 +324,23 @@ declarative grammar as the rest of the repo:
`claude` (vendor installer, binary verified against the release manifest
first), `metafactory_arc` (pinned git tag + `bun install`/`bun link`; needs
`git` in the VM's packages, and `bun` earlier in the role list — spec order
is application order), and `docker` — the only role using `become` — which
pins the signing key by full GPG fingerprint, writes a deb822 source,
installs version-pinned packages, and manages `daemon.json` with a restart
handler.
is application order), and `docker` — which pins the signing key by full GPG
fingerprint, writes a deb822 source, installs version-pinned packages, and
manages `daemon.json` with a restart handler.
- **`assay_env` is the capstone, and installs nothing.** Listed last, it runs
`scripts/vm-fingerprint.sh` from the control node (`delegate_to: localhost`),
parses the capture's `DIGESTS` block, and writes
`/etc/assay/environment.json` — the interchange
[assay](https://github.com/the-metafactory/assay/blob/main/environments/README.md)
reads to learn which environment a result was produced under. It and `docker`
are the only *spec-declared* roles using `become` — the implicit `base` role
uses it throughout. A missing or malformed digest fails the role before
anything is written, and revokes any environment file an earlier run left
behind: a file naming a digest that was never computed is worse than no
file, and so is one naming a digest this run could not confirm. The capture
lands under gitignored
`fingerprints/` and is never registered as content — it carries the guest's
`authorized_keys`.
- **Host-key checking is off** in `ansible.cfg` (same stance as
`vm-fingerprint.sh`): host keys are per-instance noise in a fleet where
rebuilds are routine, and `accept-new` would poison `known_hosts` on first
Expand Down
36 changes: 36 additions & 0 deletions ansible/roles/assay_env/defaults/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# The layer-2 capstone's knobs. Everything here is context for the file the
# role writes; none of it is identity - the identity is the digest, computed
# from the machine by scripts/vm-fingerprint.sh and never supplied here.

# Control-node path to the capture script. playbook_dir is ansible/, so its
# dirname is the repo root - the same checkout the playbook was launched
# from, which is what keeps this role from carrying its own copy of the
# script.
assay_env_fingerprint_script: "{{ playbook_dir | dirname }}/scripts/vm-fingerprint.sh"

# Where the capture lands on the CONTROL node, not the guest. Under
# fingerprints/, which .gitignore excludes wholesale (`fingerprints/**`, with
# only .gitkeep re-admitted): a capture carries the login user's
# authorized_keys and the sshd drop-ins, so it is the operator's private
# overlay and is never committed. Must be non-empty - see the refusal in
# tasks/main.yaml for why.
assay_env_capture_path: "{{ playbook_dir | dirname }}/fingerprints/{{ inventory_hostname }}.txt"

# Which factory backend built this machine. Context for a human reading a
# drift report; assay never compares it (assay environments/README.md).
#
# Hardcoded here rather than read from a hostvar because nothing upstream
# carries it yet: inventory/tofu.py builds hostvars from the vm-pve module's
# output, and that module is the only provider in this tree. When a second
# provider module lands, this belongs in the tofu output next to ansible_host
# and this default becomes the fallback, not the answer.
assay_env_provider: "proxmox-ve"

# Path, in the factory's repo, of the file that declared this environment -
# the per-VM spec inventory/<name>.yaml that tofu reads. Context, not
# identity. inventory_hostname is the VM name, which is the spec's filename.
assay_env_definition: "inventory/{{ inventory_hostname }}.yaml"

# Where assay looks. ASSAY_ENVIRONMENT_FILE can override it at run time, but
# the fleet writes the contract's default path.
assay_env_file: /etc/assay/environment.json
256 changes: 256 additions & 0 deletions ansible/roles/assay_env/tasks/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Writes /etc/assay/environment.json - the interchange assay reads to learn
# which environment a corpus ran on (crucible spec 4a; the contract is assay's
# environments/README.md). This is the layer-2 CAPSTONE: it fingerprints the
# layers already installed, so it belongs LAST in a VM spec's ansible_roles,
# after every tool role and before anything installs the software under test.
#
# The capture runs on the CONTROL node, not the guest: scripts/vm-fingerprint.sh
# SSHes in and digests what it finds. So the first task is delegate_to:
# localhost with become: false, and nothing is re-implemented on the VM.
#
# THE INVARIANT this role is written around: writing the environment file must
# not move the core digest it records. It holds because of what the capture
# does and does not look at - CORE covers os-release, hostname, timezone, the
# dpkg set, three named /etc/apt/apt.conf.d files, the Types/Suites/Components/
# Snapshot lines of /etc/apt/sources.list.d/*.sources, enabled units, the login
# user, /etc/ssh/sshd_config.d/*.conf, /etc/docker/daemon.json, and the ~/.local
# and ~/.bun file trees. /etc is never walked, only those enumerated paths are
# read, and /etc/assay is not among them. The two modules below (file, copy)
# touch nothing else: no package, no unit, no user, no group, no hostname, and
# nothing under ~/.local or ~/.bun. Adding a task that does is how this role
# starts lying about the machine it describes.
#
# The capture itself is never registered as content. It carries the login
# user's authorized_keys and the sshd drop-ins; only the four-line DIGESTS
# tail the script prints to stderr is read back.
#
# FAIL CLOSED. Everything that decides or writes the machine's identity -
# capture, parse, the digest guard, the interchange directory, the document
# and the write itself - runs inside ONE block whose rescue WITHDRAWS
# {{ assay_env_file }} before failing. Refusing to write is not enough on a
# re-run: a file from an earlier run is still sitting on a machine that may
# have moved since, and that file is what assay believes. An identity this run
# could not confirm is withdrawn rather than left standing.
#
# The write is inside the block for exactly the reason the capture is
# (vpzed-dev/smithy#26 review): a copy that dies on a full disk, a permission
# change or a dropped connection leaves the previous run's file describing a
# machine nothing just verified - the same stale identity, reached from the
# other end.
#
# And the withdrawal is VERIFIED, never assumed. Whatever breaks the write -
# no space, a read-only filesystem, a lost connection - is frequently the same
# thing that breaks the removal, so the rescue stats the path afterwards and
# says plainly when the stale file is still there. A withdrawal this role only
# attempted is never reported as one that happened.

# The three refusals below sit OUTSIDE the fail-closed block DELIBERATELY, and
# the review's softer ask - give pre-flight failures the same stale-file
# handling - is declined on purpose. Every one of them is a CONTROL-NODE
# misconfiguration: an empty capture path, no capture script in this checkout,
# an inventory serving no ansible_host/ansible_user. 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.
# From the capture command onward the guest is in play, and everything from
# there down is inside the block.
- name: Refuse to capture without a capture path
ansible.builtin.fail:
msg: >-
assay_env_capture_path is empty. Given no outfile,
scripts/vm-fingerprint.sh prints the whole capture on stdout, which
would put the guest's authorized_keys and sshd drop-ins into ansible's
registered output. Point it at a path under fingerprints/ (gitignored).
when: assay_env_capture_path | default('', true) | length == 0

# check_mode: false on both control-node tasks - this one and the capture.
# Neither writes anything to the guest: the stat reads the operator's own
# checkout, and the capture reads the guest over SSH and writes only into the
# operator's gitignored overlay. Skipped under --check they would leave the
# parse with nothing, and the run would die on an empty digest set instead of
# showing the honest diff for the file that is actually at stake.
- name: Probe for the capture script on the control node
ansible.builtin.stat:
path: "{{ assay_env_fingerprint_script }}"
delegate_to: localhost
become: false
check_mode: false
register: assay_env_script

- name: Refuse to continue without the capture script
ansible.builtin.fail:
msg: >-
The assay_env role runs {{ assay_env_fingerprint_script }} from the
control node and it is not executable there. Run ansible-playbook from
a full checkout of this repo, or point assay_env_fingerprint_script at
one.
when: not (assay_env_script.stat.exists and assay_env_script.stat.executable)

- name: Refuse to continue without an address and login user for the guest
ansible.builtin.fail:
msg: >-
The assay_env role addresses the guest as <ansible_user>@<ansible_host>
from the control node; one of those hostvars is missing. The dynamic
inventory (ansible/inventory/tofu.py) serves both - a static inventory
has to set them too.
when: ansible_user is not defined or ansible_host is not defined

- name: Establish the machine's identity, or withdraw the one on file
block:
# changed_when: false because this reports nothing about the guest
# changing. It only reads the guest, and writes on the control node -
# into the operator's private overlay, which is not the fleet's state.
# The one task here allowed to report changed is the copy at the end,
# which is the file assay actually reads.
- name: Capture the guest's fingerprint from the control node
ansible.builtin.command:
argv:
- "{{ assay_env_fingerprint_script }}"
- "{{ ansible_user }}@{{ ansible_host }}"
- "{{ assay_env_capture_path }}"
delegate_to: localhost
become: false
changed_when: false
check_mode: false
register: assay_env_capture

# With an outfile given, the script writes the capture to that file and
# prints only `fingerprint written to <path>` plus `tail -4` of it on
# stderr - the ##### DIGESTS ##### header and its three lines. That tail
# is what is parsed, so the capture body is never held in a variable.
# select() keeps only well-formed digest lines, so ssh noise on the same
# stream cannot be mistaken for one, and a truncated hash is not a match
# rather than a match on a truncated hash.
- name: Extract the digests from the capture's DIGESTS block
ansible.builtin.set_fact:
assay_env_digests: >-
{{
dict(assay_env_capture.stderr_lines
| select('match', '^(core|provider|combined)\s+sha256:[0-9a-f]{64}$')
| map('split'))
}}

- name: Refuse to write a partial environment file
ansible.builtin.fail:
msg: >-
The capture did not yield all three digests
(found: {{ assay_env_digests.keys() | list | sort | join(', ') | default('none', true) }}).
A file naming a digest that was never computed is worse than no
file, because assay believes it. Inspect
{{ assay_env_capture_path }}.
when: >-
'core' not in assay_env_digests
or 'provider' not in assay_env_digests
or 'combined' not in assay_env_digests

# Inside the block, with the write: the interchange file is the identity,
# and every step that leads to it has to answer for a stale one.
- name: Create the interchange directory
ansible.builtin.file:
path: "{{ assay_env_file | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
become: true

# schema is the number 1, not the string "1": assay refuses a schema it
# does not know, and "1" is not 1. Unquoted here, so it stays an int
# through to_nice_json and lands in the file as a JSON number.
#
# combined is deliberately absent. It is a capture-side convenience for
# diffing whole captures; the contract's fields are schema, core_digest,
# provider_digest, provider and definition, and an extra key buys
# nothing.
- name: Assemble the interchange document
ansible.builtin.set_fact:
assay_env_document:
schema: 1
core_digest: "{{ assay_env_digests.core }}"
provider_digest: "{{ assay_env_digests.provider }}"
provider: "{{ assay_env_provider }}"
definition: "{{ assay_env_definition }}"

# copy reports changed only when the bytes differ, so a re-run on a
# machine that has not moved is green and unchanged without a
# changed_when of its own. That is the idempotence claim: the file
# follows the machine, not the run.
- name: Write the assay environment file
ansible.builtin.copy:
dest: "{{ assay_env_file }}"
content: "{{ assay_env_document | to_nice_json }}\n"
owner: root
group: root
mode: "0644"
become: true

rescue:
# The machine keeps no identity this run could not confirm. Absent when
# there was nothing to remove, so `changed` below distinguishes "an
# identity was withdrawn" from "there was never one".
#
# failed_when: false so a removal that cannot succeed does not abort the
# rescue before it can SAY so. Whatever broke the write - a full disk, a
# read-only remount, a permission change - can break the unlink too, and
# a rescue that died here would fail the play with the removal's errno
# and not one word about the stale file left standing.
- name: Withdraw any environment file an earlier run left behind
ansible.builtin.file:
path: "{{ assay_env_file }}"
state: absent
become: true
register: assay_env_withdrawn
failed_when: false

# Trust the filesystem, not the module's report. This stat is the only
# evidence that the withdrawal actually happened, and the message below
# is written from it: claiming a removal that did not occur is the same
# class of lie as an environment file naming a digest nobody computed.
- name: Confirm the withdrawal by looking for the file
ansible.builtin.stat:
path: "{{ assay_env_file }}"
become: true
register: assay_env_after_withdrawal

# One task, not several guarded by `when: ... .changed` - that idiom
# reads as a missing handler to ansible-lint (no-handler), and a handler
# is exactly what this must not be: it has to fail the play, now, on this
# host. Four branches, because there are four honest endings: check mode
# (nothing is ever removed under --check), the file survived the removal,
# the file was removed, and there was never a file.
- name: Refuse to leave the machine holding an unconfirmed identity
ansible.builtin.fail:
msg: >-
Fingerprinting failed at "{{ ansible_failed_task.name }}":
{{ ansible_failed_result.msg | default('no message given') }}
{{
'This is a --check run, so nothing was removed: the environment
file at ' ~ assay_env_file ~ ' is still there and WOULD HAVE BEEN
withdrawn on a real run. Nothing on the machine has been changed
either way.'
if ansible_check_mode and assay_env_withdrawn.changed else
'The environment file an earlier run wrote at ' ~ assay_env_file ~
' COULD NOT BE REMOVED (' ~
(assay_env_withdrawn.msg | default('no message given')) ~
'), and it is STILL ON THE MACHINE. It describes a machine this
run could not confirm, so assay will keep reporting a digest
nothing has verified: treat that digest as UNVERIFIED and this
host as untrusted until the file is gone. Whatever broke the write
is the likeliest thing to have broken the removal, so fix that
first, then delete the file by hand or re-run this role.'
if assay_env_after_withdrawal.stat.exists else
'The environment file an earlier run wrote at ' ~ assay_env_file ~
' has been REMOVED, and its absence confirmed. It described a
machine this run could not confirm, and a stale identity is worse
than none: assay reports env@none for a machine with no file,
which is a true statement, where it would otherwise have reported
a digest nothing had just verified.'
if assay_env_withdrawn.changed else
'No environment file was present at ' ~ assay_env_file ~ ' and
none has been written, so the machine presents no identity at all
and assay will report env@none for it.'
}}
Fix the capture and re-run to restore it; the capture is at
{{ assay_env_capture_path }}.
7 changes: 6 additions & 1 deletion inventory-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ ipv4: dhcp
# because an archive_snapshot cannot cover download.docker.com). Roles apply
# in list order - metafactory_arc needs bun before it. Cross-layer deps: the
# bun role needs 'unzip' and metafactory_arc needs 'git' in packages.
#
# assay_env goes LAST. It installs nothing: it fingerprints whatever the roles
# before it put on the machine and writes that identity to
# /etc/assay/environment.json, so anything listed after it would be software
# the recorded identity does not describe.
# packages: [unzip, git]
# ansible_roles: [nats_server, bun, claude, docker, metafactory_arc]
# ansible_roles: [nats_server, bun, claude, docker, metafactory_arc, assay_env]

tags: ["ubuntu"]
Loading