diff --git a/ansible/roles/metafactory_cortex/defaults/main.yaml b/ansible/roles/metafactory_cortex/defaults/main.yaml new file mode 100644 index 0000000..57ee89d --- /dev/null +++ b/ansible/roles/metafactory_cortex/defaults/main.yaml @@ -0,0 +1,43 @@ +# Knobs for installing cortex - the software under test. Everything here +# decides WHICH code lands and how it is found again; none of it is the +# environment's identity, and none of it is cortex's configuration (a stack, +# secrets, NATS wiring are a later layer and deliberately out of this role). + +# Installed by URL, not by registry name. +# +# `arc install cortex` resolves the bare name through arc's default remote +# source (arc v0.45.0 src/lib/sources.ts, createDefaultSources -> +# DEFAULT_API_SOURCE): a network lookup against a name -> repo mapping that +# lives outside this repo, outside the VM spec, and outside anyone's control +# here. A URL plus a full commit SHA names the code completely and cannot be +# repointed under us, which is the only way this factory is allowed to +# identify the thing it is measuring. +metafactory_cortex_repo: https://github.com/the-metafactory/cortex + +# Where the metafactory_arc role leaves the arc CLI. A variable rather than a +# literal for two reasons: where arc's entry point lands is that role's +# business and not this one's, and a role whose dependency probe cannot be +# pointed somewhere else is a role whose refusal path cannot be exercised +# without breaking the machine you are testing on. +metafactory_cortex_arc_bin: "{{ ansible_facts['user_dir'] }}/.bun/bin/arc" + +# The name arc records for the package in its DB, which is the target's own +# arc-manifest.yaml `name:`. Used ONLY to find the checkout again in +# `arc list --json` output so the role can read its HEAD. It never selects +# code - metafactory_cortex_repo does that. +metafactory_cortex_package: cortex + +# metafactory_cortex_pin is deliberately NOT defined in this file. +# +# A default pin would be a lie about intent. It would let a VM spec that +# names this role but forgets the pin install *something* anyway, and every +# receipt downstream would then carry a commit nobody chose - which is the +# precise failure this factory exists to prevent (crucible DD-14: the pin is +# the claim under test, and AC-3 measures the installed commit against the +# declared one). An absent pin has to stop the run, not fall back to a +# neighbour's answer. +# +# tasks/main.yaml asserts it is set AND is a full 40-character commit SHA, +# before any network call. Full SHA only, not a tag or branch: a tag can be +# moved and a branch moves by design, and this repo does not identify an +# environment - or what was installed into one - by anything mutable. diff --git a/ansible/roles/metafactory_cortex/tasks/main.yaml b/ansible/roles/metafactory_cortex/tasks/main.yaml new file mode 100644 index 0000000..47c3bc5 --- /dev/null +++ b/ansible/roles/metafactory_cortex/tasks/main.yaml @@ -0,0 +1,542 @@ +# Installs cortex - the software under test - at an exact commit, then proves +# the commit took. Login user, no become: arc installs entirely under the +# user's home, exactly like the metafactory_arc role it depends on. Depends on +# metafactory_arc having run first - list metafactory_arc before +# metafactory_cortex in ansible_roles. +# +# ORDERING relative to assay_env: cortex goes BEFORE it. assay_env is the +# layer-2 capstone and records the environment's identity; the target is not +# part of that identity (see the invariant below), so installing it first and +# fingerprinting after is the ordering that matches the runbook. The recorded +# digest is the same either way, and since smithy#27 that is true without +# qualification rather than in principle. +# +# THE INVARIANT this role is written around: installing the target must not +# move the CORE digest. An environment whose identity moved every time the +# thing under test moved could not answer the one question the digest exists +# for - "were these two runs performed under the same conditions?" - and +# "same environment, two target versions" is the comparison the whole +# exercise is built on. THE INVARIANT HOLDS on current main WHEN THE PARENT +# DIRECTORIES THE CAPTURE WALKS ALREADY EXIST - see the qualifier below, and +# residual 1 at the foot of this file. Every path arc writes for the target +# is pruned from the capture or never walked at all, and the list below is +# the whole of it; what is not pruned is the parents those paths hang from. +# +# Out of reach: +# +# ~/.local/share/metafactory/arc/repos/ the clone (arc v0.45.0 +# src/lib/paths.ts: reposDir = /metafactory/arc/repos on the +# default layout). vm-fingerprint.sh prunes `$d/share/metafactory` +# wholesale from the layer2 files trees. +# ~/.local/bin/ the CLI shim. Pruned +# wholesale as `$d/bin` (smithy#6 / crucible#14 option (a) - the shims +# carry no marker a prune could match, so the whole directory goes). +# ~/.config/metafactory/... arc's DB and config. Not in +# the capture at all: it walks only ~/.local and ~/.bun. +# ~/.claude/skills|agents|... arc's host drops. Same - not +# walked. +# +# ~/.bun/install/cache the download blobs and the +# extracted package trees bun writes when arc runs `bun install` in the +# target's repo (arc README, "If a package's repo root has a +# package.json with dependencies"). Pruned WHOLESALE - smithy#27, +# merged. Confirm it on this tree rather than trusting this comment: +# scripts/vm-fingerprint.sh carries `-path "$d/install/cache" -prune` +# in BOTH layer2 find passes, the name walk and the content hash. A +# prune present in only one of the two would still move the digest. +# +# The last entry is called out because it was NOT always true, and the +# history is the reason to trust the current state rather than re-litigate +# it. The prune used to match `$d/install/cache/*.npm` - the compressed +# download blobs only - and deliberately kept the extracted package trees +# beside them. That reading held while bun was the only thing growing the +# directory. It stopped holding once an arc-installed target ran `bun +# install` for its own dependencies: the TARGET then populated the hashed +# set, and the environment digest moved with the target's dependency tree, +# which is precisely the coupling the two-plane split exists to prevent. +# smithy#27 closed it by pruning the cache wholesale, the same call already +# made for `bin` and `share/metafactory`. +# +# So on a host where ~/.local and ~/.local/share already exist, a capture +# taken before this role runs and one taken after have an identical CORE +# digest, and the digest-identity acceptance criterion this role was written +# against (crucible#26: "a capture taken before and after this role runs has +# an identical core digest") is satisfied by the fingerprint script as it +# stands. Nothing in this role has to make that pass; nothing in this role +# can break it either - the guarantee lives above the seam. +# +# THE QUALIFIER IS NOT DECORATION. On a spec that omits the `claude` role - +# a perfectly valid spec - those parents may not exist, and the FIRST +# install of the target creates them. The capture then moves by exactly +# those directory path lines. The example ordering in inventory-example.yaml +# happens to list `claude` first, but a commented example is not a +# dependency contract and must not be read as one. Residual 1 at the foot of +# this file has the mechanism. +# +# Upstream's own receipt for the fix is the check, not this paragraph: +# evidence/op-20260901-post-pr-27-fingerprint-diff.md diffs a capture taken +# before smithy#27 against one taken after. The only lines that move are the +# 537 `.bun/install/cache` entries leaving the walk, and the core and +# combined digests that necessarily follow them. No other path in the +# capture shifted - so the fix removed the leak and nothing else. +# +# The target is therefore recorded by REFERENCE - name plus resolved commit - +# in the run receipt rather than by inclusion in the digest, which is what +# metafactory_cortex_installed_commit below is for. Two edges remain, and +# they are not the same kind: the first install can create parent +# directories the prune does not cover, which DOES move the core digest on a +# host lacking them, and cortex's dependency repos install unpinned +# (the-metafactory/arc#398), which does not. Both are in the RESIDUAL note +# at the end of this file. +# +# THREE THINGS ABOUT arc v0.45.0 THAT SHAPE EVERY TASK BELOW, all read out of +# the pinned source rather than assumed: +# +# 1. `arc install` REFUSES to run non-interactively without --yes. src/cli.ts +# guards on `!opts.yes && !process.stdin.isTTY` and exits 1 with "arc +# install requires an interactive terminal for capability confirmation". +# Ansible is never a TTY, so --yes is mandatory here, not a convenience. +# +# 2. cortex declares NON-optional secrets (GH_TOKEN, GITHUB_TOKEN in its +# arc-manifest.yaml). Without --skip-secrets, install tries to provision +# them, which is a prompt this role can neither answer nor see. It is also +# out of scope by construction: this role installs the target and stops. +# Configuring it - stack, secrets, bus - is a later layer. --skip-secrets +# leaves them unstored, and the cortex daemon says so clearly at first use. +# +# 3. --pin IS SILENTLY IGNORED WHEN THE PACKAGE IS ALREADY INSTALLED. This is +# the important one. src/commands/install.ts returns +# `{ success: true, alreadyInstalled: true }` from its duplicate guard as +# soon as an ACTIVE row with the same repo_url exists - before any clone - +# and the pin checkout (`checkoutPinnedRef`) sits further down, inside +# `if (!existsSync(installPath))`. So re-running with a DIFFERENT pin +# exits 0, changes nothing, and leaves the old commit in place. arc's +# README says "re-run `arc install --pin ` to return to a specific +# ref"; at v0.45.0 that is not true for a package already installed. +# +# That is a green run over stale input - the exact thing this factory +# exists to catch - so the role refuses instead: a checkout already at a +# different commit stops the play and names `arc remove`. It does not +# remove and reinstall on its own, because `arc remove` is destructive to +# whatever else that package is carrying and this role is not the right +# place to decide that unattended. +# +# WHY THE ASSERTION AT THE END IS NOT OPTIONAL: given (3), an install that +# reports success is not evidence that the declared commit is on the disk. A +# role that installs but cannot prove WHAT it installed is a healthy trace +# over stale input. The role therefore reads the checkout's HEAD back with +# git and fails, showing both commits, if it is not the declared one. + +- name: Refuse to install an unpinned target + ansible.builtin.assert: + that: + - metafactory_cortex_pin is defined + - metafactory_cortex_pin | default('', true) | string | length > 0 + fail_msg: >- + metafactory_cortex_pin is not set. This role installs the software + under test, and the commit it installs is the claim every downstream + receipt reports (crucible DD-14/AC-3), so there is no default to fall + back to - a pin nobody chose would be worse than no install. Declare + the full commit SHA for the VM, e.g. + `-e metafactory_cortex_pin=<40-char sha>`, or in host_vars/group_vars. + See inventory-example.yaml for why the VM spec cannot carry it yet. + quiet: true + +# `| string` first: a pin written unquoted in YAML can arrive as a number +# rather than text, and a regex test against a number is a different question +# than the one being asked here. +# +# `\Z`, not `$`. Ansible's `match` test is Python's re.match, where `$` also +# matches immediately BEFORE a trailing newline - so a pin fed in from a +# command substitution or a file read would pass this gate carrying a "\n", +# and then fail the far less legible string comparison against +# `git rev-parse` output at the foot of the role. `\Z` anchors at the true +# end of the string and rejects it here, where the message can say why. +- name: Refuse a pin that is not a full commit SHA + ansible.builtin.assert: + that: + - metafactory_cortex_pin | string is match('^[0-9a-f]{40}\Z') + fail_msg: >- + metafactory_cortex_pin is "{{ metafactory_cortex_pin }}", which is not + a full 40-character lowercase commit SHA. arc's --pin would accept a + tag or a branch, but this repo does not identify what it installed by + anything mutable: a tag can be moved and a branch moves by design, and + either would make the commit in the run receipt unreproducible later. + Resolve the ref to its commit first (`git ls-remote + {{ metafactory_cortex_repo }} `) and declare that. + quiet: true + +# check_mode: false on every read-only task in this role - the two dependency +# probes, both `arc list --json` reads, and the three git reads of the +# checkout. None of them writes anything: they run --version, read arc's +# local package DB, and read a git checkout's HEAD and status. Skipped under +# --check they would leave the parse below with no stdout, and the run would +# die on a from_json of the empty string instead of showing the honest +# picture for the one task that IS at stake - the install, which stays +# check-skipped and reports what it would do. A --check run therefore either +# confirms the machine already holds the declared commit, or refuses for the +# same reason a real run would. +# +# Probed by RUNNING arc, not by stat'ing the path. +# +# metafactory_arc installs arc with `bun link`, which leaves a SYMLINK in +# ~/.bun/bin - and a stat of a symlink reports a DANGLING link as present. +# That is not hypothetical: the first version of this task used stat, and on +# a box whose bun global tree had been rebuilt underneath the link it sailed +# through, handing the operator `[Errno 2] No such file or directory` from a +# task three steps later instead of the refusal below. Executing arc also +# proves the rest of the chain the install depends on in one go: the link +# resolves, bun can run it, and the CLI answers. +- name: Probe for arc (cross-role dependency) # noqa: command-instead-of-module + ansible.builtin.command: "{{ metafactory_cortex_arc_bin }} --version" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_arc_probe + changed_when: false + failed_when: false + check_mode: false + +- name: Refuse to continue without a working arc + ansible.builtin.fail: + msg: >- + The metafactory_cortex role installs the target with arc, and + `{{ metafactory_cortex_arc_bin }} --version` did not answer + (rc={{ metafactory_cortex_arc_probe.rc | default('n/a') }}: + {{ metafactory_cortex_arc_probe.stderr | default(metafactory_cortex_arc_probe.msg | default(''), true) | trim }}). + List 'metafactory_arc' before 'metafactory_cortex' in the VM spec's + ansible_roles. If arc is installed somewhere else on this machine, + point metafactory_cortex_arc_bin at it. + when: metafactory_cortex_arc_probe.rc | default(1) != 0 + +- name: Probe for git (layer-1 dependency) # noqa: command-instead-of-module + ansible.builtin.command: git --version + register: metafactory_cortex_git_probe + changed_when: false + failed_when: false + check_mode: false + +- name: Refuse to continue without git + ansible.builtin.fail: + msg: >- + The metafactory_cortex role needs git on the guest - arc clones the + target with it, and the pin assertion reads the checkout back with it; + add 'git' to the VM spec's packages list (layer 1) and reprovision, or + install it by hand. + when: metafactory_cortex_git_probe.rc | default(1) != 0 + +# --- decide whether an install is needed ------------------------------- +# +# `arc list --json` is a read of arc's local package DB - no network, unlike +# `arc info`, which also fetches release notes. It reports installPath per +# package, which is where the clone actually is; that is asked rather than +# derived, because the path arc chooses depends on its layout resolution +# (src/lib/paths.ts: the XDG split vs the legacy single tree, selected by +# whether ARC_CONFIG_ROOT is in play) and a path this role hardcoded could +# quietly stop matching. + +- name: Ask arc which packages it already has + ansible.builtin.command: + cmd: "{{ metafactory_cortex_arc_bin }} list --json" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_list_before + changed_when: false + check_mode: false + +- name: Note where arc says the target is checked out, if anywhere + ansible.builtin.set_fact: + metafactory_cortex_path_before: >- + {{ + (metafactory_cortex_list_before.stdout | from_json).packages + | default([]) + | selectattr('name', 'equalto', metafactory_cortex_package) + | selectattr('installPath', 'defined') + | map(attribute='installPath') + | list | first | default('', true) + }} + +# failed_when: false - a recorded package whose checkout is not a git repo is +# a state this role has to report precisely, not die on with git's own error. +- name: Read the HEAD of the checkout arc already has # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git rev-parse HEAD + chdir: "{{ metafactory_cortex_path_before }}" + register: metafactory_cortex_head_before + when: metafactory_cortex_path_before | length > 0 + changed_when: false + failed_when: false + check_mode: false + +# See point (3) in the header. Everything about arc's behaviour here is +# "exit 0, change nothing", so the refusal has to happen on this side. +- name: Refuse to re-pin a checkout arc will not move + ansible.builtin.fail: + msg: >- + {{ metafactory_cortex_package }} is already installed at + {{ metafactory_cortex_path_before }}, and its HEAD is + {{ metafactory_cortex_head_before.stdout | default('unreadable', true) | trim }} + where the VM declares {{ metafactory_cortex_pin }}. + arc will not correct that from here: at v0.45.0 + `arc install --pin` short-circuits on an already-installed package and + returns success WITHOUT checking out the new ref, so re-running here + would report green and change nothing. Remove it first and re-run this + role - `arc remove {{ metafactory_cortex_package }}` - or, on a test + VM, destroy and re-provision, which is this fleet's normal reset. + when: + - metafactory_cortex_path_before | length > 0 + - metafactory_cortex_head_before.rc | default(1) != 0 + or (metafactory_cortex_head_before.stdout | default('', true) | trim) != metafactory_cortex_pin | string + +# CHECK MODE, and the one case it used to get wrong (smithy#30 review). +# +# Ansible skips the install task below under --check, which is correct - it +# writes. But every read in the proof block after it carries +# `check_mode: false` and therefore DOES run. On a host where the target is +# not installed yet, those reads describe a machine the install was never +# allowed to touch: arc still reports no package, and the "Refuse to report a +# commit" gate fires. A dry run would reject a host a real run provisions +# perfectly well, which is the opposite of what --check is for. +# +# So a dry run has to MODEL the pending install rather than measure its +# absence. This fact is the switch, and it is deliberately narrow: it is true +# only when check mode and a missing checkout coincide. A --check run against +# a host that already HAS the target keeps taking the full proof path +# unchanged - there the reads describe real disk, the assertions are +# meaningful, and a dry run that verifies an existing install is exactly the +# reassurance worth having. +- name: Note whether this run would install the target + ansible.builtin.set_fact: + metafactory_cortex_install_pending: >- + {{ ansible_check_mode | bool and metafactory_cortex_path_before | length == 0 }} + +# Only reached when there is no checkout at all, which is also the only case +# in which arc honours --pin. Re-running with the SAME pin skips this task +# entirely, which is the role's idempotence claim: a second run reports zero +# changes because there is nothing left to do, not because a command was +# told to keep quiet. +- name: Install the declared commit of {{ metafactory_cortex_package }} + ansible.builtin.command: + argv: + - "{{ metafactory_cortex_arc_bin }}" + - install + - "{{ metafactory_cortex_repo }}" + - --pin + - "{{ metafactory_cortex_pin | string }}" + - --yes + - --skip-secrets + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_install + when: metafactory_cortex_path_before | length == 0 + changed_when: true + +# --- prove what actually landed ---------------------------------------- +# +# Re-derived from arc rather than reusing anything measured above, so the +# assertion stands on its own: it describes the disk as it is now, on both +# the freshly-installed and the already-correct path. + +- name: Ask arc again where the target is checked out + ansible.builtin.command: + cmd: "{{ metafactory_cortex_arc_bin }} list --json" + environment: + PATH: "{{ ansible_facts['user_dir'] }}/.bun/bin:{{ ansible_facts['env']['PATH'] }}" + register: metafactory_cortex_list_after + when: not metafactory_cortex_install_pending | bool + changed_when: false + check_mode: false + +- name: Note the installed checkout's path + ansible.builtin.set_fact: + metafactory_cortex_path: >- + {{ + (metafactory_cortex_list_after.stdout | from_json).packages + | default([]) + | selectattr('name', 'equalto', metafactory_cortex_package) + | selectattr('installPath', 'defined') + | map(attribute='installPath') + | list | first | default('', true) + }} + when: not metafactory_cortex_install_pending | bool + +- name: Refuse to report a commit for a checkout that is not there + ansible.builtin.fail: + msg: >- + arc reports no installed package named + '{{ metafactory_cortex_package }}' after the install step. Either the + install did not run, or the target's arc-manifest.yaml records a + different `name:` than this role expects - set + metafactory_cortex_package to match it. Nothing has been asserted + about this machine, so it holds no verified target commit. + arc install said: {{ metafactory_cortex_install.stdout | default('(the install step did not run)', true) }} + when: + - not metafactory_cortex_install_pending | bool + - metafactory_cortex_path | default('', true) | length == 0 + +- name: Read the installed checkout's HEAD # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git rev-parse HEAD + chdir: "{{ metafactory_cortex_path }}" + register: metafactory_cortex_head + when: not metafactory_cortex_install_pending | bool + changed_when: false + check_mode: false + +- name: Read whether the installed checkout has been modified # noqa: command-instead-of-module + ansible.builtin.command: + cmd: git status --porcelain + chdir: "{{ metafactory_cortex_path }}" + register: metafactory_cortex_status + when: not metafactory_cortex_install_pending | bool + changed_when: false + check_mode: false + +# The assertion the whole role exists for. Both values are in the message on +# purpose: "the pin did not take" is not actionable, and the commit that DID +# land is the first thing anyone reading the failure needs. +- name: Assert the installed commit is the declared pin + ansible.builtin.assert: + that: + - (metafactory_cortex_head.stdout | trim) == metafactory_cortex_pin | string + fail_msg: >- + The installed target is NOT at the declared commit. + declared (metafactory_cortex_pin): {{ metafactory_cortex_pin }} + installed (git rev-parse HEAD): {{ metafactory_cortex_head.stdout | trim }} + checkout: {{ metafactory_cortex_path }} + Nothing downstream may report this machine as running the declared + commit. Inspect the checkout, or `arc remove + {{ metafactory_cortex_package }}` and re-run this role. + success_msg: >- + {{ metafactory_cortex_package }} is at {{ metafactory_cortex_pin }}, + verified by git in {{ metafactory_cortex_path }}. + quiet: true + when: not metafactory_cortex_install_pending | bool + +# A commit is only an honest name for the code if the code is still the +# commit. `git rev-parse HEAD` says nothing about the working tree, so a +# checkout sitting at exactly the declared pin with edits on top would sail +# through the assertion above and be reported downstream as "running " +# while running something else. That is the same class of lie as an unpinned +# install, arriving one step later, so it gets the same treatment. +# +# ONE WAY A LEGITIMATE INSTALL CAN TRIP THIS, so it is not mistaken for a +# bug: arc runs `bun install` in the checkout, normally with +# --frozen-lockfile (added automatically when the repo ships a committed +# bun.lock, which cortex does), and frozen does not rewrite the lockfile. +# But arc retries ONCE WITHOUT --frozen-lockfile when the frozen attempt +# fails because the lockfile drifted from package.json (arc README), and +# that retry can rewrite bun.lock - leaving `M bun.lock` here. Failing is +# the right answer in that case, not a false positive: it means the +# dependency set actually installed is not the one the pinned commit +# declares, which is a determinism hole in the thing being measured. The +# porcelain output in the message names the file, so the cause is visible +# rather than inferred. +- name: Assert the installed checkout is unmodified + ansible.builtin.assert: + that: + - (metafactory_cortex_status.stdout | trim) | length == 0 + fail_msg: >- + The installed target is at the declared commit but its WORKING TREE IS + DIRTY, so the code on this machine is not the code + {{ metafactory_cortex_pin }} names. + checkout: {{ metafactory_cortex_path }} + git status --porcelain: + {{ metafactory_cortex_status.stdout | trim | replace('\n', '\n ') }} + Nothing downstream may report this machine as running the declared + commit. Restore the checkout to the pin - `git -C + {{ metafactory_cortex_path }} checkout -- .` for modified tracked + files, `git -C {{ metafactory_cortex_path }} clean -fd` for untracked + ones - or `arc remove {{ metafactory_cortex_package }}` and re-run this + role for a clean install. On a test VM, destroy and re-provision. + success_msg: >- + {{ metafactory_cortex_path }} is clean. + quiet: true + when: not metafactory_cortex_install_pending | bool + +# What the smoke loop reads. Set only after BOTH assertions above, so the +# fact exists on a host if and only if the commit was proved on that host and +# the tree proved unmodified - a fact set before the checks would be readable +# on a machine that failed them. +- name: Record the verified commit for the smoke loop + ansible.builtin.set_fact: + metafactory_cortex_installed_commit: "{{ metafactory_cortex_head.stdout | trim }}" + when: not metafactory_cortex_install_pending | bool + +# The dry-run counterpart to everything above: say what a real run would do, +# report the host as CHANGED because it would be, and do not pretend to have +# proved anything. metafactory_cortex_installed_commit is deliberately NOT +# set here - the fact means "this commit was proved on this host", and in +# check mode nothing was. A downstream receipt finding no fact is correct; +# one finding a fact set by a dry run would be a lie with a sha in it. +- name: Report the install this dry run would have performed + ansible.builtin.debug: + msg: >- + check mode: {{ metafactory_cortex_package }} is not installed on this + host, so a real run would install it at {{ metafactory_cortex_pin }} + from {{ metafactory_cortex_repo }}. The post-install proof - HEAD + equals the pin, working tree clean - is DEFERRED TO A REAL RUN, + because it reads a checkout that check mode was not allowed to create. + This host is reported changed, not verified, and no verified commit + has been recorded for it. + changed_when: true + when: metafactory_cortex_install_pending | bool + +# --- RESIDUAL, stated rather than hidden ------------------------------- +# +# Two things this role cannot close, recorded so the next reader does not have +# to re-derive them. A third used to head this list - the ~/.bun/install/cache +# leak, which made the digest move with the target's own dependency tree - +# and it is gone: smithy#27 prunes the cache wholesale and is merged. +# +# Of the two that remain, the FIRST can still move the core digest, on a host +# whose parent directories do not exist yet. It is a narrower failure than +# the cache leak was - a fixed cost paid once, by directory name, not a +# quantity that tracks the target - but it is not zero, and the invariant +# above is stated with that condition attached rather than despite it. +# +# 1. Beyond the paths arc writes, the prune does NOT cover their PARENT +# directories: vm-fingerprint.sh prunes `.local/share/metafactory` and +# `.local/bin`, but `.local` and `.local/share` are themselves printed by +# the find that builds the layer2 files list. On a VM where they do not +# already exist, the first arc install creates them and the CORE digest +# moves by exactly those path lines. +# +# Only the claude role closes both: it writes +# ~/.local/share/claude/versions/, creating `.local` AND +# `.local/share`. nats_server is NOT sufficient on its own - it creates +# `.local/bin`, which brings `.local` into being but leaves `.local/share` +# to be created by whatever first needs it, which on a spec without claude +# is this role. So the safe statement is: list `claude` before this role, +# or accept that the first capture on a claude-less spec moves by one +# directory name. Either way the difference is a path line, not the +# target's contents - but "list claude first" is advice in a comment, and +# a spec that ignores it is still a valid spec, so this role cannot +# enforce it and does not claim to. +# +# Where the real fix lands: the planned follow-up to model role +# dependencies explicitly, so an ordering requirement is declared and +# checked rather than written in prose and hoped for. That is upstream's +# refactor to make and is deliberately NOT attempted in this PR - a +# role-local workaround here (pre-creating the parents, say) would put a +# second, quieter answer in the tree and make the real one harder to +# land. +# +# 2. The pin fixes cortex's own commit and nothing else. cortex's +# arc-manifest.yaml declares depends_on.packages - five adapter/renderer +# repos - and arc installs those with `install({ repoUrl: dep.repo })`, +# passing no pinnedRef (src/commands/install.ts). They therefore land on +# whatever their default branch is at the time. This is an arc-side gap, +# not a smithy one, and it is tracked as the-metafactory/arc#398. +# +# What it does NOT do, since smithy#27: move the core digest. The whole of +# reposDir sits under the pruned `share/metafactory`, so an unpinned +# dependency landing on a newer commit changes nothing the capture hashes, +# and it does not affect the commit this role asserts either - that is +# cortex's own HEAD, which the pin fixes and the self-assert proves. +# +# What it still costs is full-tree determinism: two runs at the SAME pin +# can have different dependency source under reposDir. The environment is +# reproducible and the target's own commit is reproducible; the target's +# transitive source is not, and a claim that the pin fixes everything +# installed would be wrong. It matters for anyone reasoning about a result +# from the tree rather than from the pin. diff --git a/inventory-example.yaml b/inventory-example.yaml index 74e9bb5..03a96cb 100644 --- a/inventory-example.yaml +++ b/inventory-example.yaml @@ -34,11 +34,68 @@ ipv4: dhcp # in list order - metafactory_arc needs bun before it. Cross-layer deps: the # bun role needs 'unzip' and metafactory_arc needs 'git' in packages. # +# metafactory_cortex installs the software under test at an exact commit and +# then proves the commit took. It needs metafactory_arc before it, and it +# goes before assay_env, which is the order the runbook reads in: install the +# target, then fingerprint. +# +# The target is not part of the environment's identity, and every path arc +# writes for it is pruned from the capture or never walked - including +# ~/.bun/install/cache, which smithy#27 now prunes wholesale. So on a host +# where ~/.local and ~/.local/share already exist, a capture taken after this +# role has the same core digest as one taken before it. The header of +# ansible/roles/metafactory_cortex/tasks/main.yaml carries the full argument +# and points at upstream's receipt for the fix. +# +# That condition is why `claude` leads the list below rather than sitting +# anywhere in it: it is the only role that creates both ~/.local and +# ~/.local/share. On a spec that omits it - which is a valid spec - the first +# install of the target creates those directories itself and the core digest +# moves by exactly those path lines. This ordering is an EXAMPLE, not a +# dependency contract; declaring and checking it properly is upstream's +# planned role-dependency follow-up, not something this role can enforce. +# +# Separately, the pin fixes cortex's own commit only - its dependency repos +# install unpinned (an arc-side gap, the-metafactory/arc#398). That one does +# not move the digest, but it does mean the source tree under reposDir is not +# fully determined by the pin. +# # 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, assay_env] +# ansible_roles: [nats_server, bun, claude, docker, metafactory_arc, metafactory_cortex, assay_env] + +# The commit metafactory_cortex installs. The placeholder below is +# deliberately not a real commit - it is well-formed (40 lowercase hex) so it +# reaches the role's SHA check rather than tripping it, and obviously fake so +# it cannot be mistaken for a considered choice. Replace it with a real +# commit from the target repo: +# +# git ls-remote https://github.com/the-metafactory/cortex main +# +# Full SHA only. The role rejects a tag or a branch: both can move, and a +# target this fleet cannot name immutably is one whose test results cannot be +# reproduced later. +# +# metafactory_cortex_pin: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef +# +# NOTE - this key does NOT reach ansible from a VM spec today, and the line +# above is an example of the VALUE, not a working spec entry. spec is a +# strict object type (modules/vm-pve/variables.tofu) and ansible/inventory/ +# tofu.py builds hostvars from a fixed list (ansible_host, ansible_user, +# vm_id, vm_ansible_roles, vm_packages, vm_archive_snapshot, vm_timezone). +# A key that is in neither is dropped by OpenTofu's object conversion with no +# error and no warning - verified against OpenTofu 1.11.7 - so writing it +# here as a live key would document a lie. Until a spec passthrough exists, +# supply it at run time: +# +# ansible-playbook ansible/site.yaml --limit \ +# -e metafactory_cortex_pin=<40-char sha> +# +# or from ansible/inventory/host_vars/.yaml. The role fails loudly when +# it is unset, so a forgotten pin stops the run instead of installing +# something nobody chose. tags: ["ubuntu"]