fix(cluster): pin and cache the bootstrap toolchain outside the dir the wipe deletes (#392) - #393
Conversation
e957419 to
8c4b3a9
Compare
Review response — round 3Thank you for the review. It was right on the point that matters most: the branch still trusted three cached states on presence alone, which is precisely what it exists to remove. Every minor finding is fixed below. Nits are fixed where they are cheap and correct, and the rest are deferred with the reason. Ansible runtime
Cache poisoning and partial state
Consumers and docs
Supply chain
GateSyntax-check One thing the gate caught was mine. The first version of the new guard failed on the good tree: a substring check for End-to-end: the shipped task definitions, actually runSame harness as before: it parses this playbook and keeps its eight toolchain task definitions verbatim, and fails if any is missing. It runs in an Ubuntu 26.04 container (linux/arm64) with a decoy
Two things the e2e caught that the guard alone did not:
Coverage limit: the e2e is linux/arm64, which is msr1's architecture. amd64 — and the cache actually surviving a real teardown on all three hosts — gets verified live on the first cluster bootstrap after merge, as with #388. |
…he wipe deletes (#392) #388 cached the actions-runner tarball, but the same playbook fetched four more tools from the internet on every run into the directory it wipes: uv, a python build, ansible-core and ansible.posix. Each sat behind a skip-if-present guard that could therefore never skip, none retried, and ansible-core and ansible.posix were unpinned. On 2026-09-15 a Galaxy timeout on one host (API time-to-first-byte 23s while GitHub on the same host did 48 MB/s) failed nightly 34981852841 although the other two hosts had already registered their runners. Pinning comes first. Caching an unpinned install silently freezes whichever version installed first, with nothing recording which. uv 0.12.15, python 3.13, ansible-core 2.21.4 and ansible.posix 2.2.2 are pinned, and each cache directory is keyed by its pin so a bump installs fresh. All four were field-proven by the 14:29Z bootstrap on all three hosts. Six cluster workflows reach the venv and the collections through paths they hardcode under runner_root, not through anything this playbook exports, so moving the tools would have broken every tier. The two legacy paths stay alive as symlinks into the cache. Teardown's wipe is ansible.builtin.file state=absent, which for a directory is shutil.rmtree; that unlinks a symlink and never follows it -- measured on Linux CPython 3.13 -- so the wipe drops the links and keeps the cache. uv venv now runs with --managed-python. Without it uv prefers any matching python already on PATH over downloading into UV_PYTHON_INSTALL_DIR; an end-to-end run caught the "cached" venv linked to the test controller's python, and a fresh container could not execute it. The cluster only escaped because its system python is 3.14. UV_PYTHON_INSTALL_DIR and UV_CACHE_DIR also move uv's python and cache under /tmp, which the playbook's own pristine rule always required and uv's defaults under $HOME quietly broke. ansible.posix is trusted from the cache only if its MANIFEST.json names the pinned version; otherwise it is fetched from Galaxy, and if Galaxy will not serve it, built from the collection's GitHub source tag. It publishes no release assets, so its git source is the only non-Galaxy route, and git is present on every host. TestBootstrapToolchainIsPinnedAndCachedOutsideTheWipedDir asserts the pins, the cache locations, the manifest check, --managed-python, and the six-workflow symlink contract from both ends.
… presence Review of this branch found that the first cut still trusted three cached states on presence alone, which is the exact failure the change exists to remove, and each of them survives the wipe now that the cache outlives the run that left it: - Ansible's `creates:` checks with glob.glob, which counts a DANGLING symlink as present. A venv whose python was deleted was skipped and failed later inside a job instead of being rebuilt. The self-heal the comment promised never ran. - uv writes a wheel's entry-point scripts before its package data, metadata and RECORD, so bin/ansible-playbook can exist over an install the bootstrap timeout killed half way. - ansible-galaxy writes MANIFEST.json before any other collection file, so a manifest naming the pinned version proves nothing about the files after it. The venv and ansible-core are now one cached unit, trusted only when a completion stamp written last exists AND ansible-playbook actually runs and reports the pinned core; a miss rebuilds it from scratch. ansible.posix is trusted only with a completion stamp AND `ansible-galaxy collection verify --offline`, which checks every file against its recorded hash, AND a MANIFEST.json version equal to the pin: verify does not hash MANIFEST.json itself, and the end-to-end run caught a cached manifest altered to 0.0.0 passing it. ansible-core's dependencies were unpinned and would have been frozen by the cache at whatever resolved first. They are now resolved with `--exclude-newer 2026-09-15T00:00:00Z`, and the cutoff is part of the venv's directory name, so moving it builds a fresh venv. Each Galaxy attempt is bounded by coreutils timeout with one retry. retries alone never bound a stalled transfer: at the 2m29s stall that failed nightly 34981852841, four unbounded attempts would have used ~11 minutes of a 25-minute bootstrap before the fallback ran. The fallback has its own timeout and installs commit e98d9a07 rather than the 2.2.2 tag, which upstream could move. Smaller fixes from the same review: ANSIBLE_HOME keeps ansible-galaxy's temp dirs and API cache inside the tool cache, so "nothing lands outside /tmp" is true for it too; the cache directories carry an explicit owner; the link task drops force, which switched off ansible's only check that a link target exists, and a stat now fails the play if a target is missing; changed_when no longer masks a module failure. RUNNER_BOOTSTRAP.md documents the persistent tool cache instead of claiming nothing persists, and the teardown check is renamed to say it confirms the runner dir only. TestBootstrapToolchainCacheIsTrustedOnlyWhenVerified and TestCachedAnsiblePosixCheckComparesTheManifestVersion guard all of it. Its first version failed on the good tree: a substring check for "force: true" matched the comment explaining why there is no force. It now matches force as a YAML key in any truthy spelling.
8c4b3a9 to
2f8f518
Compare
Closes #392.
What was wrong
#388 cached the actions-runner tarball. The same playbook still fetched four more tools from four internet services on every run, into the directory it wipes, behind skip-if-present guards that therefore never skipped, with no retries:
Nightly
34981852841never ran a cell because of the last one.ansible-galaxy collection install ansible.posixgave msa2-client nothing for 2 m 29 s and the whole bootstrap failed, although msa2-server and msr1 had already registered. Measured from the hosts in the same minute: Galaxy's API had a 23.3 s time-to-first-byte and returned 302-with-no-body to msa2-server, while GitHub on the same hosts delivered 48.7 and 61.8 MB/s. The pipe was fine; Galaxy was not.The change
Pin, then cache. Caching an unpinned install would silently freeze whichever version installed first, with nothing recording which. Pins are uv
0.12.15, python3.13, ansible-core2.21.4, ansible.posix2.2.2. All four were field-proven by the 14:29Z bootstrap on all three hosts, and the versioned uv installer really pins (APP_VERSION="0.12.15"). Each lives in a directory underrunner_cache_dirkeyed by its pin, so a bump installs fresh instead of reusing a stale copy.Six workflows hardcode the tool paths — so they stay, as symlinks.
matrix-nightly-tier,matrix-weekend-tier,benchmark-tier,matrix-checkptr-tier,matrix-race-tierandmatrix-pr-tiernever read the exports in "Start runner". Each re-derivesRUNNER_ROOT=/tmp/actions-runner-$(hostname -s)and puts${RUNNER_ROOT}/ansible-venv/binonGITHUB_PATHand${RUNNER_ROOT}/ansible-collectionsinANSIBLE_COLLECTIONS_PATH. Moving the venv would have broken every cluster tier; a grep of every consumer before touching the path is what caught it. The playbook now recreates those two paths each run as symlinks into the cache, and none of the six files changes.That is safe only if teardown's wipe does not follow the links. It is
ansible.builtin.file state=absent, and in ansible-core 2.21.4ensure_absentfor a directory is exactlyshutil.rmtree(b_path, ignore_errors=False). Measured on macOS and on Linux aarch64 CPython 3.13.15 (shutil.rmtree.avoids_symlink_attacks = True): bothrmtreeandrm -rfof a directory containing a symlink leave the target intact.uv venv --managed-python. The first end-to-end run found a real flaw: uv prefers a matching python already onPATHover downloading intoUV_PYTHON_INSTALL_DIR, so the "cached" venv linked to the test controller's python and a fresh container could not execute it. The cluster only escaped because its system python is 3.14. Verified in isolation with a decoypython3.13planted onPATH, uv 0.12.15:UV_PYTHON_INSTALL_DIRandUV_CACHE_DIRalso move uv's python and cache under/tmp, which this playbook's own "nothing lands outside /tmp" rule always required and uv's$HOMEdefaults quietly broke.A cache that skips on presence is a liability, so ansible.posix is trusted only if its
MANIFEST.jsonnames the pinned version; otherwise it is fetched from Galaxy, and if Galaxy will not serve it, built from the collection's GitHub source tag. ansible.posix publishes no release assets, so its git source is the only non-Galaxy route;git2.53.0 is on all three hosts. uv is likewise only trusted ifuv --versionreports the pin.Guard and controls
TestBootstrapToolchainIsPinnedAndCachedOutsideTheWipedDirchecks the pins, that every fetch uses its pin, that no install target or skip guard sits underrunner_root, that every tool lives underrunner_cache_dir, the manifest check,--managed-python, and the six-workflow symlink contract from both ends — it counts the workflows that consume each legacy path (logs6and6) and fails if that count ever reaches zero.Each injection was asserted applied before the test ran, and reverted with
cpconfirmed by SHA-256:runner_rootansible_posix_version: "latest"--managed-pythonansible-playbook --syntax-checkisrc=0on both playbooks (the unedited baseline was alsorc=0).End-to-end: the shipped tasks, actually run
The harness is built by parsing this playbook and keeping its eight toolchain task definitions verbatim (it asserts all eight are found, so it cannot silently shrink). Ubuntu 26.04 container with no python3.13, so uv genuinely downloads python into the cache. Every result below is read from the filesystem, not the play recap.
uv venv --clearon an existing venv, uv 0.12.15PATH…/tools/uv-python/; uv 0.12.15, core 2.21.4, posix 2.2.2;ansible-doc ansible.posix.sysctlresolves through the legacy workflow pathsansible.builtin.file state=absentrunner_rootgone; uv, venv, collection and uv-python all survive0.0.02.2.22.2.2--network none, run by the cached venv's own ansible-playbookrescued=0; uv taskok(no fetch); links recreated; offline contract worksWhat this does not claim
rescued=1for ansible.posix, because a missing manifest is what routes into the install. That is the design, not a failure.go test ./...: another agent was building on the same host, which has run out of process slots three times today. CI runs the full suite here.