fix(cluster): cache the actions-runner tarball outside the dir the wipe deletes - #388
Conversation
…pe deletes (#387) The bootstrap fetched 215 MiB per host on every cluster run. The `force: false` that was meant to skip an existing download pointed at a path inside the directory the preceding "Wipe stale runner dir" task had just removed, so the file could never be found and the guard was dead code. On 2026-09-15 that download took 13 minutes 5 seconds and GitHub cancelled the nightly's 15-minute bootstrap job in the task after it; the matrix never ran. The same day's race tier spent 12 minutes 20 seconds there and survived on 2 minutes 40 seconds of margin. Every bootstrap recorded before that finished in under three minutes, so the download is the variable and nothing else in the playbook is. Retrying does not address it. get_url's `timeout` is the URL-open timeout, not a transfer cap, so `retries: 5` never fires on a slow-but-progressing transfer; it runs until the job is killed. Caching removes the transfer instead of trying to bound it. The wipe stays. Its comment explains why a stale .runner is unsafe -- it pins a registration GitHub has deleted -- but that argument is about the runner configuration, not about bytes GitHub returns byte-identical for a version-addressed filename. The cache is /tmp/celeris-runner-tarballs, not the obvious /tmp/actions-runner-cache: the latter IS runner_root on a host named "cache", and the wipe would take the cache with it. The guard test caught that in the first draft. A cache turns one bad download into every later run's problem, so the extract doubles as the integrity check: if the cached tarball will not unpack, it is discarded and fetched once more rather than skipped forever as a successful no-op. Bootstrap timeout-minutes goes 15 to 25 across all six cluster workflows. A cold cache still has to move 215 MiB three times, and 15 had no margin for that even on a good day.
|
Following up on this PR's "Not addressed here" section, which said the link question was unmeasured. It is measured now, and one piece of what I wrote there was wrong. Wrong: I cited my SSH sessions hanging as a signal. They were not one. Tailscale SSH was waiting on an interactive re-authentication check ( Right, but for reasons I had not established: the link is degraded. Tracked as #390. The decisive comparison is the same task on the same hosts twelve hours apart: Measured live on an idle cluster: 1.1 MB/s single-stream from GitHub on each of two hosts, but 867 KB/s aggregate when both run at once — two streams total less than one, so it is a single contended pipe, which predicts the 785 s almost exactly. That does not change anything in this PR. Moving 645 MiB per bootstrap was wasteful at 46 MB/s too; it just was not visible until the pipe narrowed. But it does mean the cache is not a fix for #390, and I am holding the 24-hour soak and the benchmark until the link recovers. |
…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.
…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.
…he wipe deletes (#392) (#393) * fix(cluster): pin and cache the bootstrap toolchain outside the dir the 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. * fix(cluster): trust the cached toolchain only when verified, never on 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.
Closes #387.
The defect
ansible/runner-setup.ymlwipedrunner_root, then downloaded the actions-runner tarball intorunner_rootwithforce: false. That flag is supposed to skip a download when the file is already present — but the file it looks for lives in the directory the previous task just deleted, so it could never be there. Dead code, and 215 MiB re-fetched per host on every cluster run.What it cost
The nightly's
matrix validatewas skipped and no cell ran. Nothing else was in thematrix-tier-clustergroup, so this was a timeout, not an eviction. The download alone was 13 m 05 s of it; every other task in that playbook took 3–20 seconds.225,628,509 bytes in 785 s is ≈287 KB/s.
Retrying was already tried —
dc6e702 ci: retry actions-runner tarball download— and cannot help:get_url'stimeoutis the URL-open timeout, not a transfer cap, soretries: 5never fires on a slow-but-progressing transfer. It just runs until the job is killed.The change
Cache outside the wipe.
runner_cache_dir,force: falseagainst it, unarchive from there into the freshly wipedrunner_root. After the first run per host per runner version the download disappears entirely.The wipe stays exactly as it was. Its comment explains why a stale
.runneris unsafe — it pins a registration GitHub has since deleted andrun.shexits with "the runner registration has been deleted from the server" — but that is an argument about the runner configuration, not about bytes GitHub hands back byte-identical for a filename that already contains the version.The cache is
/tmp/celeris-runner-tarballs, not/tmp/actions-runner-cache. The obvious name isrunner_rooton a host calledcache, and the wipe would delete it. My first draft used the obvious name and the guard test rejected it — included below, because a guard catching its own author on the first run is the only evidence that it bites.A bad cache self-heals. A cache turns one corrupt download into every future run's problem, and a skipped-because-present file looks exactly like success. The extract is therefore the integrity check:
block/rescuediscards the cached tarball and fetches it once more if it will not unpack.timeout-minutes: 15 → 25on the bootstrap job in all six cluster workflows. A cold cache still moves 215 MiB three times; 15 had no margin even on a good day.Controls
Every injection was diffed against a backup before the test ran, and reverted with
cp, confirmed by SHA-256 match (not justgit status).get_urldest back insiderunner_roota get_url writes to "{{ runner_root }}/{{ runner_tarball }}" instead of the cache/tmp/actions-runner-cachesits under runner_root … the wipe deletes it and force:false becomes dead code againrunner-teardown.yml references runner_cache_dirtimeout-minutes: 15matrix-race-tier.yml: bootstrap timeout-minutes=15, want >= 20Vacuity:
TestClusterBootstrapHasHeadroomForAColdCachelogschecked 6 cluster workflow(s)and fails outright if it finds zero, so it cannot pass over an empty set.runner-teardown.ymlis byte-identical toHEAD— it only ever removedrunner_root, and control 3 guards that it stays that way.What I ran
gofmt -l .clean,go vet ./...clean, and the root package green both untagged and under-tags mage. I did not run the fullgo test ./...locally: another agent is working in this repo and the heavyvalidationpackage has exhausted this host's process table three times today. CI runs the full suite on this PR, including the tagged job added by #385.Not addressed here
Whether the cluster's link to github.com was degraded today specifically, or whether 287 KB/s is its normal ceiling for a large object, is unmeasured — my own SSH sessions to the cluster hung repeatedly while investigating, and one 3-packet ping run showed 100 % loss between two runs that showed 0 %. That is worth its own look, but it is not what this PR claims to fix: the point is that the bootstrap should not be moving 645 MiB on a good day either.