Skip to content

feat: self-healing runner version wired and on by default (v0.3.0) - #222

Merged
luthermonson merged 10 commits into
mainfrom
feat/self-heal-wiring
Nov 11, 2026
Merged

luthermonson merged 10 commits into
mainfrom
feat/self-heal-wiring

Conversation

@luthermonson

@luthermonson luthermonson commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Turns the self-healing runner version subsystem from #220 (library code, flag-off, no callers) into a first-class, default-on product behavior for v0.3.0. Eight commits, in dependency order: config → key → manifest pipeline → controller/runtime → scheduler → daemon wiring → e2e/CI → docs.

What is now wired and on by default

A node with no [runner.self_heal] table at all gets, on both the host scheduler daemon and the in-VM --containerd-only worker:

  • Boot-verify (runnerversion.Controller, started from serve right after runtime + providers): loads the signed manifest (keeps the last verified one across fetch failures, ignores other channels), re-hashes the embedded runner, verifies the cache, probes the official Linux image's /home/runner, and re-runs hourly and on every breaker trip.
  • Zero-work detector + circuit breaker (scheduler.Config.RunnerHealth): Registered from trackRunning (only for providers that report runner names), JobStarted from the in_progress event and from any Busy busy-probe answer (so a webhook outage does not trip it on runners that are working), Exited from the three wait-goroutines with killedByUs = jobCtx.Err() != nil, Allow(platform) as the last gate in admitDispatch. A refused job goes on the retry ladder, not the 30-minute reconcile.
  • Mount-over-stale-official-image: runtime.Config.RunnerOverride is populated with one value implementing both seams — ImageOverride for official images and the new MountedRunnerSource (verified-cache runner on custom-image/Windows paths on the next job, no restart). Engage resets the linux breaker.
  • Break-glass two-source fetch: on the host through the first GitHub provider's authenticated client (runners/downloads cross-check); in the VM worker when the staged config carries a PAT. Any missing dependency disables the fetch (logged once) and the override falls back to the embedded tree.
  • Kill-switch: [runner.self_heal] enabled = false leaves RunnerOverride and RunnerHealth nil — byte-for-byte the pre-0.3.0 daemon. enabled/auto_upgrade/image_override are *bool (nil → true; sub-switches false whenever the kill-switch is), the same convention as [vm.linux] enabled. Validate() is now called from config.Load and fails fast; manifest_url has a built-in default so an empty table validates.

Signed-manifest pipeline

  • pkg/runnerversion/keys/runner-manifest.pub is a real key now (id CCFDB74C12C7F433, pinned by TestEmbeddedPublicKeyIsReal). Generated with the new mage manifest:keygen (pure-Go minisign keypair; unencrypted secret-key layout compatible with minisign -G -W).
  • mage manifest:build composes dist/runner-manifest.json from mage/download's pinned table (RunnerVersion, new RunnerFloor = 2.338.0, pinnedSHA256 via RunnerArtifacts()) — hashes live in exactly one place. manifest:sign signs with RUNNER_MANIFEST_MINISIGN_KEY; manifest:verify checks the pair against the compiled-in key (the CI gate that the secret matches the trust root).
  • .github/workflows/runner-manifest.yml (release: published + workflow_dispatch with floor/channel/rollback inputs) publishes both files to a rolling prerelease with a fixed tag (runner-manifest, canary: runner-manifest-canary), make_latest: false so it can never take the releases/latest slot ephemerd upgrade follows. That URL is the config default.

Fixes found along the way

  • The sticky breaker caveat from the feat(runner): self-healing runner version subsystem (foundation, flag-off) #220 review: a verified manifest is authoritative and clears an override engaged on the breaker alone; no manifest never clears (so an outage cannot flap it and sacrifice three runners per poll). Breaker open + manifest-says-healthy is not an override; the log says to republish with a higher floor.
  • ProbeImageRunner never applied the containerd namespace to its context (latent feat(runner): self-healing runner version subsystem (foundation, flag-off) #220 bug); it now does, holds a lease over the view so GC cannot remove it mid-probe, and SweepRunnerProbeViews removes leaked ephemerd-runner-probe-* views at boot.
  • Overlay ownership: overlayfs takes the merged root's owner from the upperdir root (which prepareJobRunnerTree always creates, as root). Under the override the official image's uid-1001 runner could not have written .runner/_work. matchTreeOwner now chowns the per-job upper root to the lowerdir's owner (no-op for the root-owned custom-image path). The e2e asserts the write as uid 1001.

Required manual step (one time)

Add the repository secret RUNNER_MANIFEST_MINISIGN_KEY on ephpm/ephemerd: the contents of the runner-manifest.key file that pairs with the compiled-in public key. It was generated off-repo and is not in this PR or the tree. (If it is ever lost: mage manifest:keygen, commit the new .pub, update the pinned id in minisign_test.go, replace the secret.)

Before nodes can boot-verify

The manifest must be published once: run the Runner manifest workflow (workflow_dispatch, defaults) or publish any ephemerd release (which triggers it). Until then every node logs "manifest unavailable; no verified manifest yet (floor unknown)" hourly at warn, and self-heals on the breaker path only (override from the embedded tree; no fetch, since nothing verified says what to fetch).

Not fully proven locally

  • The containerd e2e (test/e2e/runner_override_test.go: job on ghcr.io/actions/actions-runner:2.336.0 reports the image's runner without the override and the host's newer runner with it, writes as uid 1001 into the per-job upperdir, never into the shared tree; Clear restores; probe leaks no view) is gated linux && e2e && privileged like the rest of the suite. It compiles under those tags (vetted cross-OS) and ci.yml runs it verbosely so its PASS or SKIP is on the record — on the current unprivileged CI runner that will be a SKIP, as for the other privileged e2e. Run mage e2eall on a privileged box for the on-metal proof.
  • The publish workflow has not run (needs the secret). Its three mage targets are covered end-to-end by mage/manifest tests with a throwaway key.
  • make_latest/overwrite_files behavior of softprops/action-gh-release@v2 is from its documented inputs, not exercised here.

Deferred (documented in the arch doc)

Auto-PR for the pin; ephemerd_runner_pin_age_seconds; override/breaker state on /healthz and the Status RPC; host→VM blob push for Windows/macOS hosts (the VM fetches for itself when it has a token); an explicit cordon on below-floor-with-fetch-unavailable (the breaker stops the thrash instead).

Verification

go build ./...; go vet (windows + CGO_ENABLED=0 GOOS=linux, incl. the e2e,privileged tags); golangci-lint 2.11.4 on every touched package: 0 issues; go test on config, runnerversion (incl. 10 new controller tests), runtime, scheduler (x2), metrics, github, providers/github, cmd/ephemerd, mage/download, mage/manifest: all green.

Follow-up (approved additions)

Break-glass fetch of GitHub's latest (2393284)

The reactive loop could not heal the case it exists for: GitHub ships a new runner and raises its floor before anyone re-signs the manifest — Upgrader.Run refused with ErrSourcesDisagree (GitHub ahead of the signed pin) and the fleet sat protected-but-stuck. Upgrader.breakGlassEligible/breakGlass is now the ONE path where GitHub's REST API is the sole authority for a runner binary, gated hard:

  • the zero-work breaker for this runner's platform class is OPEN (BreakGlassOpen, wired by the controller; nil on the VM worker) — sources disagreeing with the breaker closed still refuse and alert; steady state is not loosened;
  • roll-forward only: GitHub strictly newer than the signed manifest and than the runner the node already has (cache or embedded). GitHub equal-with-other-hash or behind the pin is refused as spoof/regression;
  • one shot per breaker cooldown (ErrBreakGlassCooldown);
  • auto_upgrade, max_archive_mb, min_free_gb, SHA pin (GitHub's sha256_checksum over the whole stream before extraction) unchanged.

Audit trail: sidecar Sources = ["github-runners-downloads"] only (Sidecar.IsBreakGlass); manifest not recorded as applied; ephemerd_runner_version_info{source="github-breakglass"} (selector reads the sidecar on every Select, so it survives restarts); ephemerd_runner_breakglass_active gauge + _total counter; fixed WARN self-heal fetched un-manifest-signed runner from GitHub to clear an open breaker; re-sign the manifest (and rebuild the image) to make this permanent, repeated each reconcile. On the image path the break-glass tree is mounted over the image (DecideImageOverride: breaker open AND node holds a tree newer than the manifest's version) and kept until a manifest covers it. Revert to signed: a re-signed manifest naming a version ≥ the break-glass one makes alreadyCurrent record the manifest as a second source on the same blob (no download), label → verified-cache, gauge → 0; a newer manifest is a normal two-source upgrade. Decision-matrix tests: pkg/runnerversion/breakglass_test.go (a–e), TestControllerBreakGlassThenManifestCatchUp, TestDecideImageOverride break-glass cases.

Poll demoted to a 6h backstop (a4979fc)

DefaultSelfHealPollInterval 1h → 6h (poll_interval still configurable). The breaker's OnDegraded → Kick is the primary trigger; the poll only propagates a known-good pin to not-yet-failing nodes and catches a dropped kick.


https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d

…date at load

[runner.self_heal] is part of the product in 0.3.0, not a feature flag.
enabled, auto_upgrade and image_override become *bool so that "not set"
is told apart from "explicitly false" (a plain bool's TOML zero value
would make the product default off); ResolvedEnabled/AutoUpgrade/
ImageOverride resolve nil to true, and the two sub-switches are false
whenever the kill-switch is. Same convention as [vm.linux] enabled and
[dind] allow_privileged.

manifest_url gets a built-in default (the stable runner-manifest release
asset on ephpm/ephemerd), so a node with no table at all validates and
runs the full subsystem. Validate() finally has a caller: config.Load,
so a malformed table fails the daemon at start instead of at the first
poll an hour in. enabled = false still skips the tuning checks so a
stray key cannot keep an opted-out node from starting.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
… root

Unencrypted minisign secret keys (the minisign -G -W layout: Ed, kdf
none, B2 checksum, 158 bytes) can now be generated, encoded, parsed and
used to sign in pure Go, so CI signs the manifest without a minisign
binary and keypairs come from the same code that verifies them. A
password-protected key is rejected with a clear error rather than
half-supported.

keys/runner-manifest.pub is no longer the placeholder: it is the public
half of a keypair generated with mage manifest:keygen (key id
CCFDB74C12C7F433). The secret half is held only as the
RUNNER_MANIFEST_MINISIGN_KEY repository secret; it is not in the tree.
TestEmbeddedPublicKeyIsReal pins the key id so a rotation is a
deliberate change to both files.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…kflow

mage manifest:build composes dist/runner-manifest.json from the pinned
runner table in mage/download (RunnerVersion, the new RunnerFloor, and
pinnedSHA256 via RunnerArtifacts()) so a runner hash lives in exactly
one place, and round-trips it through the daemon's parser. manifest:sign
signs it with RUNNER_MANIFEST_MINISIGN_KEY (or _FILE); manifest:verify
checks the pair against the key compiled into ephemerd, which is the CI
gate proving the secret matches the trust root; manifest:keygen makes a
fresh keypair for rotation.

.github/workflows/runner-manifest.yml runs the three on every published
release and on workflow_dispatch (floor override, channel, rollback) and
uploads both files to a rolling prerelease with a fixed tag
(runner-manifest / runner-manifest-canary), marked not-latest so it can
never take the releases/latest slot ephemerd upgrade follows. That is the
stable URL nodes carry by default.

RunnerFloor is the lowest version GitHub still accepts as far as we know
(2.337.0 was refused fleet-wide on 2026-11-09); it is published as the
manifest floor.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
… hardening

runnerversion.Controller is the daemon-side policy: load the signed
manifest (keep the last verified one across failures, ignore other
channels), run BootVerify at boot, on every poll and on every breaker
trip, fetch for the mounted paths when below floor, and turn the image
decision into PrepareOverrideTree+Engage / Clear / fetch-then-engage.
Engaging resets the linux breaker so the next runner is admitted at once.

The #220 review caveat is addressed: the "no manifest + breaker open"
override was sticky and would have alerted IMAGE RUNNER IS STALE for the
length of a webhook outage. Now a VERIFIED manifest is authoritative (it
clears an engaged override when it says the image is at/above the floor)
while no manifest never clears (an outage must not flap the override and
sacrifice three runners per poll to re-learn the same thing).

Runtime seams: MountedRunnerSource lets the verified-cache runner take
effect on the custom-image and Windows paths on the next job without a
restart (Selector.MountedRunnerDir; the per-job tree stays under the
configured runners dir). ProbeImageRunner now applies the containerd
namespace to its context (it never did), holds a lease over the view so
a GC pass cannot remove it mid-probe, and SweepRunnerProbeViews removes
views a crashed daemon left behind. prepareJobRunnerTree chowns the
per-job upperdir root to the lowerdir's owner: overlayfs takes the
merged root's owner from the upper, so without this the official image's
uid-1001 runner could not write .runner/_work under the override.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…ch gate

Config.RunnerHealth observes runner lifecycles for the detector:
Registered from trackRunning with the dispatch class (only for providers
that report runner names, like the orphan sweep); JobStarted from the
in_progress event naming the runner and from any Busy busy-probe answer,
so a webhook outage does not trip the breaker on runners that are
working; Exited from the three wait-goroutines with killedByUs derived
from the job context. Allow(platform) is the last gate in admitDispatch,
after the cordon and satisfied/gone checks, and a refused job goes on the
retry ladder rather than waiting for the 30-minute reconcile sweep.

Nil RunnerHealth is the historical scheduler, byte for byte.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
cmd/ephemerd/selfheal.go assembles the subsystem for both daemons: the
host scheduler (boot-verify, detector + breaker, image probe/override on
Linux hosts, two-source fetch through the first GitHub provider's client)
and the --containerd-only worker in the sidecar VM (boot-verify, image
probe/override, fetch when the staged config carries a PAT). The runtime
gets one RunnerOverride value implementing both seams; the scheduler gets
RunnerHealth. With [runner.self_heal] enabled = false both stay nil and
the daemon is exactly the pre-0.3.0 one. An unparsable public_key
override fails fast at start.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…cial image

TestE2E_RunnerOverride_MountsNewerRunnerOverStaleOfficialImage runs a job
on ghcr.io/actions/actions-runner:2.336.0 (the exact version GitHub
refused on 2026-11-10) through runtime.Create: without the override the
container reports the image's runner; with ImageOverride engaged on the
embedded tree it reports the host's newer runner, can write at the top of
/home/runner as the image's uid 1001, the write lands in the per-job
upperdir under the runners dir and never in the shared tree, Clear
restores the image's runner, and the probe leaks no view snapshot. Gated
linux && e2e && privileged like the rest of the suite; ci.yml runs it
verbosely so its PASS or SKIP is on the record.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
Status, configuration (default-on *bool switches, the kill-switch,
Validate at load), signing pipeline and rotation, which hosts run what,
the controller's lifecycle around the image decision (verified manifest
clears; no manifest never clears), the corrected overlay ownership
explanation, failure modes for the outage cases, an Operating-it section
with the two one-time manual steps, and what 0.3.0 wires vs. what is
still open. config.example.toml documents the table.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
…eaker is open

The reactive loop (breaker opens -> kick -> reconcile) could not heal the
case it exists for: GitHub ships a new runner and raises its floor before
anyone re-signs the manifest. Upgrader.Run refused with
ErrSourcesDisagree because GitHub was ahead of the signed pin, and the
fleet sat protected but stuck until a human ran the manifest workflow.

Upgrader.breakGlassEligible/breakGlass is the ONE path where GitHub's
REST API is the sole authority for a runner binary, and every gate is
hard: the zero-work breaker for this runner's platform class must be OPEN
(BreakGlassOpen, wired by the controller; nil on the VM worker); GitHub's
version must be strictly newer than the signed manifest's AND than the
runner the node already has (roll-forward only - a GitHub that is equal
with another hash or behind the pin is a spoof or regression and stays a
refusal); one attempt per breaker cooldown (ErrBreakGlassCooldown);
auto_upgrade, max_archive_mb, min_free_gb and the SHA pin (GitHub's
sha256_checksum over the whole stream before extraction) unchanged.
Sources disagreeing with the breaker CLOSED still refuse and alert: the
steady-state path is not loosened.

The audit trail is loud: the sidecar lists github-runners-downloads as the
only source (Sidecar.IsBreakGlass), the manifest is NOT recorded as
applied (so the re-signed one is not a replay), version_info carries
source="github-breakglass" (the selector reads the sidecar on every
Select, so it survives restarts), ephemerd_runner_breakglass_active=1,
ephemerd_runner_breakglass_total, and a fixed WARN line repeated by every
reconcile while the node is on that runner.

Reverting to signed: when a re-signed manifest names a version >= the
break-glass one, Upgrader.alreadyCurrent records the manifest as a second
source on the same blob (same bytes by hash, no download), the label
reverts to verified-cache and the gauge clears; a newer manifest is a
normal two-source upgrade. The controller triggers the catch-up itself,
attempts the upgrade whenever the host breaker is open, and on the image
path mounts a break-glass tree over the image (DecideImageOverride: breaker
open AND the node holds a tree newer than the manifest's version) and
keeps it until a manifest covers it. The replay guard moved after the
agreement check so an applied manifest cannot block the evaluation, and
the post-swap breaker reset now happens only where a swap alone puts the
runner in front of jobs (mounted paths) - on the image path Engage does it.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
Failure is the primary trigger: the zero-work breaker's OnDegraded kicks
an immediate reconcile. The poll's only jobs are to propagate a known-good
pin to nodes that have not failed yet and to catch a dropped kick, so
DefaultSelfHealPollInterval (and runnerversion.DefaultPollInterval) move
from 1h to 6h. poll_interval stays configurable.

Claude-Session: https://claude.ai/code/session_01CYdPQrMwFwZJrsnAswyH9d
@luthermonson
luthermonson marked this pull request as ready for review November 11, 2026 15:21
@luthermonson
luthermonson merged commit 2b0c262 into main Nov 11, 2026
4 checks passed
@luthermonson
luthermonson deleted the feat/self-heal-wiring branch November 11, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant