Repository navigation
feat: self-healing runner version wired and on by default (v0.3.0) - #222
Merged
Merged
Conversation
…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
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.
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-onlyworker:runnerversion.Controller, started fromserveright 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.scheduler.Config.RunnerHealth):RegisteredfromtrackRunning(only for providers that report runner names),JobStartedfrom thein_progressevent and from any Busy busy-probe answer (so a webhook outage does not trip it on runners that are working),Exitedfrom the three wait-goroutines withkilledByUs = jobCtx.Err() != nil,Allow(platform)as the last gate inadmitDispatch. A refused job goes on the retry ladder, not the 30-minute reconcile.runtime.Config.RunnerOverrideis populated with one value implementing both seams —ImageOverridefor official images and the newMountedRunnerSource(verified-cache runner on custom-image/Windows paths on the next job, no restart). Engage resets the linux breaker.runners/downloadscross-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.[runner.self_heal] enabled = falseleavesRunnerOverrideandRunnerHealthnil — byte-for-byte the pre-0.3.0 daemon.enabled/auto_upgrade/image_overrideare*bool(nil → true; sub-switches false whenever the kill-switch is), the same convention as[vm.linux] enabled.Validate()is now called fromconfig.Loadand fails fast;manifest_urlhas a built-in default so an empty table validates.Signed-manifest pipeline
pkg/runnerversion/keys/runner-manifest.pubis a real key now (idCCFDB74C12C7F433, pinned byTestEmbeddedPublicKeyIsReal). Generated with the newmage manifest:keygen(pure-Go minisign keypair; unencrypted secret-key layout compatible withminisign -G -W).mage manifest:buildcomposesdist/runner-manifest.jsonfrommage/download's pinned table (RunnerVersion, newRunnerFloor = 2.338.0,pinnedSHA256viaRunnerArtifacts()) — hashes live in exactly one place.manifest:signsigns withRUNNER_MANIFEST_MINISIGN_KEY;manifest:verifychecks 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_dispatchwithfloor/channel/rollbackinputs) publishes both files to a rolling prerelease with a fixed tag (runner-manifest, canary:runner-manifest-canary),make_latest: falseso it can never take thereleases/latestslotephemerd upgradefollows. That URL is the config default.Fixes found along the way
ProbeImageRunnernever 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, andSweepRunnerProbeViewsremoves leakedephemerd-runner-probe-*views at boot.prepareJobRunnerTreealways creates, as root). Under the override the official image's uid-1001 runner could not have written.runner/_work.matchTreeOwnernow 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_KEYonephpm/ephemerd: the contents of therunner-manifest.keyfile 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 inminisign_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
test/e2e/runner_override_test.go: job onghcr.io/actions/actions-runner:2.336.0reports 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 gatedlinux && e2e && privilegedlike 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. Runmage e2eallon a privileged box for the on-metal proof.mage/manifesttests with a throwaway key.make_latest/overwrite_filesbehavior ofsoftprops/action-gh-release@v2is 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/healthzand 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. thee2e,privilegedtags); golangci-lint 2.11.4 on every touched package: 0 issues;go teston 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.Runrefused withErrSourcesDisagree(GitHub ahead of the signed pin) and the fleet sat protected-but-stuck.Upgrader.breakGlassEligible/breakGlassis now the ONE path where GitHub's REST API is the sole authority for a runner binary, gated hard: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;ErrBreakGlassCooldown);auto_upgrade,max_archive_mb,min_free_gb, SHA pin (GitHub'ssha256_checksumover 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 everySelect, so it survives restarts);ephemerd_runner_breakglass_activegauge +_totalcounter; fixed WARNself-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 makesalreadyCurrentrecord 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,TestDecideImageOverridebreak-glass cases.Poll demoted to a 6h backstop (
a4979fc)DefaultSelfHealPollInterval1h → 6h (poll_intervalstill configurable). The breaker'sOnDegraded → Kickis 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