Repository navigation
feat(runner): self-healing runner version subsystem (foundation, flag-off) - #220
Merged
Merged
Conversation
…-off) Design + foundation for keeping the fleet alive when GitHub's undocumented minimum runner version moves past the embedded pin (the 2.337.0 stall: every JIT runner registered, was refused, exited 0, and the fleet sat idle for a day because nothing distinguished "ran a job" from "got no job" and the only fix was a new release). docs/arch/self-healing-runner.md covers the three mechanisms: boot-time verification against a minisign-signed runner-manifest.json, a zero-work runner detector (registered -> self-exit within a window with no job-started signal; exit_code is deliberately not an input) with a per-platform circuit breaker, and a break-glass host-side auto-upgrade that requires the signed manifest and GitHub's runners/downloads sha256_checksum to agree before any bytes are trusted. Implemented, all inert until [runner.self_heal] enabled = true (default false; auto_upgrade is a second default-false gate): - pkg/runnerversion: manifest schema + validation; minisign verification (Ed25519 + BLAKE2b prehash, global signature, key-id check) with an embedded public-key placeholder that fails closed; content-addressed store with verify-before-commit, atomic pointer swap, rollback, boot re-hash and a cache bounded to current+previous; fetcher with https-only, free-space guard, size cap and single-flight; GitHub runners/downloads source; two-source upgrader; embedded-vs-cache selector with embedded as the floor; zero-work detector + breaker; _diag corroboration scan. - pkg/metrics/runnerversion.go: ephemerd_runner_version_info, ephemerd_runner_pin_age_seconds, ephemerd_runner_zero_work_total, ephemerd_runner_breaker_open, ephemerd_runner_manifest_errors_total. - pkg/config/selfheal.go: [runner.self_heal] table, defaults, validation. - pkg/runner/version.go: additive exported wrappers (ParseVersion, CompareVersions, EmbeddedArchive, ExtractArchive); runner.go unchanged. Nothing in cmd/ephemerd or pkg/scheduler calls any of this yet. mage/download/download.go and the release workflows are untouched. Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…er /home/runner) Correction from live fleet debugging: Linux jobs do not run the embedded runner. runner-ci-linux is FROM ghcr.io/actions/actions-runner and the runtime treats it as official, running the IMAGE's /home/runner/run.sh with needsRunnerMount=false. The image shipped 2.336.0 (below GitHub's floor) and every Linux job died regardless of the embedded pin. Patching the image's overlayfs lower layer in place proved unreliable on metal (page cache serves the stale runner to new container mounts). The Linux mechanism is therefore a per-job overlay of a verified fresh runner tree OVER /home/runner, via the prepareJobRunnerTree machinery custom images already use: - pkg/runtime/runneroverride.go: RunnerOverrideSource + planRunnerMount, the pure decision that replaces the inline needsRunnerMount expression in Create. Nil source == historical rule verbatim (TestPlanRunnerMount pins it). Create's mount block and dind bindMappings follow the plan. - pkg/runtime/runnerprobe_linux.go: Runtime.ProbeImageRunner reads the image's /home/runner version (bin/Runner.Listener.deps.json carries "Runner.Listener/X.Y.Z", verified against the 2.337.0 archive) and the owner uid/gid from a read-only view snapshot. - pkg/runnerversion/imageprobe.go, override.go: ProbeRunnerTree, ImageOverride (engaged state, alert + gauge), DecideImageOverride (a healthy image is never masked; a failed probe never overrides), PrepareOverrideTree (chown to the image's runner uid so _work/ can be created under the merged dir). - Selector.BootVerify now runs the image probe and returns the decision. - [runner.self_heal] image_override (default false, requires enabled); metrics ephemerd_runner_image_version_info and ephemerd_runner_image_override_active. - Design doc: new "Linux official-image path" section, host coverage (Windows-host VMs are a documented gap), failure modes, pinning note. Still inert: runtime.Config.RunnerOverride is nil in cmd/ephemerd. Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…ests TestFailOpenRedirectsWhenUpstreamIsDown (npm, pip, pub) simulated a dead registry by closing its httptest server, which releases the server's ephemeral port. Every test in these packages runs in parallel and each one binds two more 127.0.0.1:0 listeners, so another test's fake registry or proxy can land on that very port moments later. The proxy under test then reaches a live stranger and gets a 200 instead of the 307 fail-open redirect (CI on #220: "packument status = 200, want 307 to the origin"). The fake upstreams now have a `down` switch: once set, every connection is aborted without a response (http.ErrAbortHandler) while the listener stays bound, so the address can never be reused mid-test. The proxy sees the same transport error a dead host produces, and the fail-open path is exercised deterministically. Pre-existing on main (pkg/proxies is untouched by this branch); fixed here to unblock #220.
…be + fetch Review fixes for the self-heal seam, all inert while the flag is off: - planRunnerMount now carries jobRoot, and Create builds "job-<id>" under it. With the image override engaged the lowerdir is <data>/runners/verified/extracted/<ver>-linux, and the old filepath.Dir(plan.dir) put each job's overlay upper/work INSIDE the store's extracted/ root: Store.Prune (run after every swap) would RemoveAll a running job's upperdir, and sweepOrphanState, which only looks under Dir(RunnerDir), would never reclaim a crashed one. jobRoot is Dir(RunnerDir) whenever a runner is configured, i.e. exactly where job-* dirs have always lived. TestPlanRunnerMount pins it. - Store.Prune only deletes directories named like its own extractions (<version>-<goos>); anything else under extracted/ is logged and left. - ProbeImageRunner's view-snapshot key includes a boot epoch: the sequence restarts at 1 on every daemon start, so a view leaked by a crash mid-probe would have collided with the next boot's first probe. - Fetcher's default client refuses a redirect off https and caps the chain, so the https-only rule holds across the release-asset -> CDN hop rather than only on the first URL.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Design doc + a reviewable, flag-off foundation for a self-healing runner version subsystem. It addresses the runner-floor stall: GitHub's undocumented minimum runner version moved past what the fleet runs, every JIT runner registered → was refused → exited 0, and the fleet sat idle for ~a day because (a) nothing distinguished "ran a job and exited" from "never got a job and exited" and (b) the only fix was a rebuild.
Nothing is wired.
cmd/ephemerdandpkg/schedulerdo not call any of this;runtime.Config.RunnerOverrideis nil everywhere.[runner.self_heal] enabled,image_overrideandauto_upgradeall default tofalse.mage/download/download.go(RunnerVersion,pinnedSHA256, Alpine pins) and.github/workflows/are untouched. Draft on purpose — please review the design before anything gets wired.Correction from live fleet debugging (2026-11-10)
The first draft assumed the runner the fleet runs is the embedded tarball. For Linux — the dominant path — it is not.
runner-ci-linuxisFROM ghcr.io/actions/actions-runner:latest;isOfficialRunnerImage(pkg/runtime/runtime.go) treats it as official, soCreateruns the IMAGE's/home/runner/run.shwithneedsRunnerMount=false. The embedded runner is only ever mounted for custom images and on Windows/macOS. The image was at 2.336.0 (below floor) and every Linux job died regardless of the embedded pin.Also confirmed on metal: patching the image's overlayfs lower layer in place is unreliable (page cache serves the stale runner to new container mounts). So the Linux mechanism is a bind/overlay mount of a verified fresh runner tree over
/home/runner, using the per-job overlay machineryprepareJobRunnerTreealready provides for custom images — not a layer edit. The doc now has a dedicated section (The Linux official-image path) and this PR implements the seam for it.Design —
docs/arch/self-healing-runner.mdMechanisms, all preserving SHA pinning (there is a dedicated section walking every path):
/home/runner(read-only view snapshot +Runner.Listener.deps.json, which carries"Runner.Listener/2.337.0"— verified against the real archive), compare all against a minisign-signedrunner-manifest.json(schema:version,floor,channel stable|canary,rollback,published_at, per-platform{url, sha256}), refuse work belowfloor(cordon, not thrash).in_progress/busy signal naming that runner.exit_codeis explicitly not an input. K=3 consecutive per platform opens the breaker (provisioning suspended, pool degraded, alert fired once), 15 m cooldown → one half-open probe. Optional_diaggrep corroborates the deprecation message.3a. Image override (Linux) — when the image's runner is below floor (or the breaker is open with a newer tree on the node), engage an overlay of the best verified runner tree over
/home/runneron every subsequent job; the tree is chowned to the image'srunneruid/gid (probed) so_work/can be created; loud fixed-string alertrunner image override engaged: IMAGE RUNNER IS STALE+ephemerd_runner_image_override_active=1until a rebuilt image is probed ≥ floor. A healthy image is never masked; a failed probe never overrides on a guess.3b. Break-glass verified auto-upgrade (host daemon only) — when 3a needs a tree the node doesn't have (today's shape: image 2.336.0 AND embedded 2.337.0 both below floor): resolve from the signed manifest AND GitHub
GET …/actions/runners/downloads(sha256_checksum); both must agree on version+hash; fetch https-only with a free-space guard and size cap; stream-hash; verify-before-commit into a content-addressed store bounded to current+previous; atomic pointer swap; the next job uses it (via 3a on the image path, via the selector on mounted paths); alert; auto-PR with a deadline (designed, not implemented).Host coverage for 3a: Linux hosts directly; macOS via the virtio-fs-shared data dir; Windows-host WSL VMs are a documented gap (the VM cannot see the host's tree; needs a host→VM verified blob push over Dispatch — deferred).
GitHub primitives were checked against live docs/API (
runners/downloadsschema;assets[].digestpopulated on v2.338.2;<!-- BEGIN SHA os-arch -->release-notes markers) with confidence noted per item. The doc also records the known prereq that mayfly never renders metricsbind_addr, so nothing is scraped fleet-wide yet.Implemented
pkg/runnerversion/manifest.goManifest, strictValidate(schema=1, X.Y.Z, floor ≤ version, https URLs whose basename must equal the upstream asset name, lower-hex sha)pkg/runnerversion/minisign.go+keys/runner-manifest.pubEmbeddedPublicKey()returnsErrNoPublicKeyand every verify fails closed until an operator generates one offline (minisign -G).Sign()exists so tests round-trip; cross-checking against the realminisignCLI is a follow-up.pkg/runnerversion/loader.goHTTPManifestLoader(https-only, bounded, verify-then-parse)pkg/runnerversion/store.goPuthashes the stream and commits only on match;Extract; atomicSwap/Rollback;Verifyre-hashes at boot and drops corrupt blobs;Prunebounds to current+previous; temp cleanup; replay guardpkg/runnerversion/fetch.godiskspace.Checkfloor,Content-Length+ limited-reader cap, single-flight per SHApkg/runnerversion/github.goGitHubSourceover go-github v72ListRunnerApplicationDownloads/ org variantpkg/runnerversion/upgrader.goErrDisabledbefore any I/O whenAutoUpgrade=false; channel, replay, downgrade-without-rollback refusalspkg/runnerversion/selector.goBootVerifynow also runs the image probe and returnsBootReport.Image(the override decision)pkg/runnerversion/imageprobe.goProbeRunnerTree/ParseListenerDepsVersion: version + owner uid/gid of any runner treepkg/runnerversion/override.goImageOverride(engaged state the runtime consults;Engagefires the alert + gauge),DecideImageOverride(the decision table in the doc),PrepareOverrideTree(chown to the image's runner uid/gid)pkg/runtime/runneroverride.goRunnerOverrideSourceinterface +planRunnerMount, the pure decision that replaced the inlineneedsRunnerMountexpression inCreate. With a nil source the plan is the historical rule verbatim; with an engaged override and an official image on Linux it mounts the override tree at/home/runnerthrough the existingprepareJobRunnerTreeoverlay. dindbindMappingsfollow the plan's mount.pkg/runtime/runnerprobe_linux.go(+_other.go)Runtime.ProbeImageRunner: read-only view snapshot of the image rootfs +mount.WithTempMount(same technique aspatchSnapshotRunner) +ProbeRunnerTree(/home/runner); view removed afterwardspkg/runnerversion/detector.goOnDegradedfired once per trippkg/runnerversion/diag.go_diag/Runner_*.logtail scan for the refusal linepkg/metrics/runnerversion.goephemerd_runner_version_info{version,source}(source now includesimage-override),ephemerd_runner_pin_age_seconds,ephemerd_runner_zero_work_total{platform},ephemerd_runner_breaker_open{platform},ephemerd_runner_image_version_info{image,version},ephemerd_runner_image_override_active,ephemerd_runner_manifest_errors_total{reason}pkg/config/selfheal.go[runner.self_heal]table incl.image_override; defaults;Validate(); one field added toRunnerConfigpkg/runner/version.goParseVersion,ParseVersionString,CompareVersions,Manager.EmbeddedArchive,ExtractArchive.runner.gois byte-identical tomain.The one edit to existing behaviour-bearing code
pkg/runtime/runtime.goCreate:needsRunnerMount := (customImage || windows) && …becameplan := planRunnerMount(…)and the three uses ofr.cfg.RunnerDir/r.cfg.RunnerMountin the mount block and dindbindMappingsread fromplan.TestPlanRunnerMountpins the historical rule (custom/official/Windows/unconfigured) and the new case.Config.RunnerOverrideis nil inmain.go, so the compiled daemon behaves exactly as before.Tests
Unit tests for every file above (
go test ./pkg/runnerversion/... ./pkg/metrics/... ./pkg/config/...+TestPlanRunnerMountinpkg/runtime+ the three newpkg/runnertests),-raceclean onpkg/runnerversion,go vetclean on windows andCGO_ENABLED=0 GOOS=linux,golangci-lint(repo config) clean on the touched packages, whole-modulego buildclean. Covered: every manifest validation branch; minisign round-trip in both modes, tampered data / tampered trusted comment / wrong key; store verify-before-commit leaves nothing behind on mismatch or oversize, swap/rollback/prune bounds, corrupt-blob drop, re-extract from a good blob, temp cleanup; fetcher https-only, low-disk refusal without touching the network, single-flight (5 callers → 1 request); upgrader disabled touches nothing, sources-disagree refuses without fetching, agreed-hash-but-bad-bytes refuses, replay, channel, downgrade/rollback; selector floor rules and the image path (both-below-floor → NeedFetch; cache lifts → Override; probe failure → no override);DecideImageOverridetable incl. the exact 2026-11-10 shape;ImageOverrideengage/clear + gauges; deps.json parse/probe; detector verdicts, threshold-once alert, half-open probe reopen/close, metrics.Not unit-testable here:
Runtime.ProbeImageRunner(needs containerd — belongs intest/e2e), the actual overlay-over-/home/runnerjob run (e2e on a Linux node: start a job onrunner-ci-linuxwith the override engaged and assertRunner.Listenerversion from_diag), the real embedded archive path (TestEmbeddedArchiveMatchesFindTarballruns its full assertion only aftermage download, like the existingTestFindTarball_PicksHighestVersion), and a real GitHubrunners/downloadscall (stubbed via the struct go-github returns).Integration points for Phase 1 (not done here)
cmd/ephemerd/main.gopassesrm.Dir()asRunnerDirat ~L357 (worker) and ~L677 (host). Those becomesel.Dir()withsel := &runnerversion.Selector{Embedded: rm, Store: store}; it returns exactlyrm.Dir()when there is no better verified tree.runtime.Config.RunnerOverride = imageOverride(*runnerversion.ImageOverride) in the sameruntime.Newcall — nil today.sel.ProbeImage = func() (TreeInfo, error) { return rt.ProbeImageRunner(ctx, defaultLinuxImage) }and the boot/poll handler that turnsBootReport.ImageintoEngage/Clear/ fetch-then-engage.Selectorwraps*runner.Managerand does not changefindTarball's own choice among embedded archives. If Phase 1 changes howrunner.Versionor the embed dir is populated,Selector.Select()/BootVerifyare the only consumers ofrunner.Version/EmbeddedArchive()to reconcile.Remaining wiring (next increment):
Detector.Registered/JobStarted/ExitedfromtrackRunning/handleInProgress/ the two wait-goroutines (killedByUs = jobCtx.Err() != nil || orphan sweep || drain),Detector.Allow(platform)inadmitDispatch, the manifest poll loop + alert routing, the auto-PR, and the Windows-host VM blob push.Open questions for review
ephpm/ephemerdreleases (runner-manifest/<n>tag) the right place to publish the manifest?image_overridebe allowed to mount the embedded runner over the image withoutauto_upgrade(no download involved, just a newer tree we already pinned)? The code allows it whenimage_override=true; the doc describes it that way.ci/runner-bump-workflow's editor rather than a second one.https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d