Repository navigation
feat: LAN GitHub Actions cache backed by a writable registry (off by default) - #217
Merged
Merged
Conversation
…LTS_URL The runner exports ACTIONS_RESULTS_URL to every step from the job message, overwriting the container's environment, so the LAN Actions cache cannot be reached with an env var alone. PatchResultsURL renames the one UTF-16LE copy of that name in Runner.Worker.dll to a same-length unused name; the runner then exports GitHub's address under it and the container's value survives. PatchResultsURLFile writes the patched copy into a job's writable tree only, never the shared runner other jobs mount, and reports false (cache stays off) when the string is absent. Verified against runner 2.337.0 linux-x64 and win-x64: exactly one occurrence, in Runner.Worker.dll only. An opt-in test (EPHEMERD_RUNNER_WORKER_DLL) re-checks a real runner on each bump.
The per-job c2c membership already records which job every runner and dind sibling container belongs to. JobForIP exposes the reverse lookup so a host-side service can attribute a request to a job by source address alone, which holds on the bridge because job containers lack CAP_NET_RAW. Linux only; Windows and macOS return "".
A cache server for actions/cache and everything built on it, storing entries in a writable OCI registry (registry:2): - twirp CacheService: CreateCacheEntry, FinalizeCacheEntryUpload, GetCacheEntryDownloadURL, with field names checked against the toolkit's generated client (snake_case requests, int64 as strings). - Azure-style upload (Put Blob, Put Block, Put Block List): each block is streamed into its own registry blob, so concurrent out-of-order blocks are never buffered or reassembled; the manifest lists them in block-list order. - Download streams the layers back with Content-Length and Range support. - GitHub's matching rules: per readable ref, exact key then restore-key prefixes, newest wins, version must match. Entries are immutable. - Requests are attributed by source address (networking.JobForIP); the client discards any path in ACTIONS_RESULTS_URL, so a URL secret cannot carry identity. A job is unbound until the scheduler binds its scope; until then every lookup misses and every save is refused. - Every failure is ok=false (a miss / refused save), never a 5xx the client would retry and log as an error. - Per-repo LRU eviction by deleting manifests; registry GC is left to the host's timer. - Anything that is not the cache (artifacts) is relayed to GitHub unchanged, for tracked jobs only.
CacheScoper (optional) asks the provider which cache scope a job gets. The GitHub implementation reads the run (event, head branch, head repository, PR number, default branch): branch events save under their branch, pull requests under their PR (fork PRs without a listed PR under the run itself), and every other event is read-only. A fork whose branch is named "main" can never write refs/heads/main.
…efault) - config: [actions_cache] (enabled, port 8089, registry, username, password or password_env, max_size_gb_per_repo, max_entry_gb, upstream_results_url). Linux hosts only. - scheduler: a job gets the cache only while the registry is healthy and its provider can resolve scopes. Its runner is tracked at create and bound on in_progress to the job that ACTUALLY took it (JIT runners are fungible and org-level, so the dispatched job proves nothing), then forgotten at teardown. - runtime: sets ACTIONS_RESULTS_URL and patches the job's runner. An official runner image is patched inside the container's own snapshot (its runner is the image's, not ours); a mounted runner in the job's overlay upper. A failed patch leaves the job on GitHub's cache. - dind: container steps receive only the step env, where a patched runner exports GitHub's address under the renamed variable; dind restores ACTIONS_RESULTS_URL there so `container:` jobs (and their artifact uploads) keep working. With [actions_cache] absent nothing about a job changes.
The daemon starts the cache server before any job container has brought
the ephemerd0 bridge up, so the gateway address is not on any interface
yet and net.Listen("10.88.0.1:8089") failed with "cannot assign requested
address" on every fresh start, leaving every job on GitHub's cache. Found
on the first live run of the feature.
Use proxies.Listen like the package caches: try the gateway, fall back to
the wildcard on the same port. Requests are still attributed to jobs by
source IP and refused otherwise, and the advertised URL stays the gateway.
Polling listed only runs in the queued state and emitted only "queued"
events. Two consequences surfaced on a live run:
- The LAN Actions cache binds a runner's scope on the in_progress event
naming the job that took it. Polling never sent one, so in polling mode
every save was refused ("no scope for this job yet").
- A run turns in_progress when its first job starts, so a `needs:` job
still queued inside it was never discovered at all.
Also list in_progress runs (deduplicated), emit queued events for queued
self-hosted jobs in them and in_progress events for running self-hosted
jobs with a runner name. Polling repeats in_progress every poll, so
handleInProgress now binds only on the first report for a runner/job.
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.
Part of #215. This is the ephemerd half. The mayfly half (a second writable
registry:2, a bigger cache-VM disk, rendering[actions_cache], and a GC timer) is a separate PR.Off by default. With
[actions_cache]absent, nothing about a job changes.What it does
Serves GitHub Actions' cache service v2 (what
actions/cache,Swatinem/rust-cacheand setup-* caching talk to) from the node. Entries are stored in a writable OCI registry, so Rust jobs stop moving 0.4–1.6 GB archives over the WAN. The baseline in #215: 26% of Rust job time goes to cache transfer.Design changes from the #215 write-up (found while building it)
runner.Extract()would not have reached most jobs. The fleet's default Linux image (runner-ci-linux, built onactions-runner) counts as an official image: the job runs the image's own runner at/home/runner, not the embedded one (isOfficialRunnerImage). So the patch is now per job:patchSnapshotRunner);The shared runner tree and the image are never modified, and the patch always matches the runner version that will execute.
new URL('/twirp/…', baseUrl)(cacheTwirpClient.ts:66), which discards the path. Requests are attributed by source address instead.networking.JobForIPreuses the existing per-job c2c membership; the runner and every dind sibling join under the runner's name, and job containers lack CAP_NET_RAW. The signed upload and download URLs are used verbatim, so they additionally carry an unguessable token.mainpush can be handed a fork PR. A runner is tracked at create (every lookup misses, every save is refused) and bound onin_progress, to the job GitHub says actually took it. The scope is resolved from GitHub's record of that job's run (CacheScoper/RunCacheFacts).container:jobs. Container steps get only the step environment throughdocker exec, where a patched runner exports GitHub's address under the renamed variable. dind restoresACTIONS_RESULTS_URLon container create and exec. Without that, container steps would see noACTIONS_RESULTS_URLat all, andupload-artifactwould fail.Scope policy (
cacheScopeFor)refs/heads/<branch>refs/pull/N/mergerefs/pull/N/merge, orrun/<id>when GitHub omits the PRNothing a PR saves is ever visible to a branch. A fork whose branch is named
maincannot writerefs/heads/main; there's a test for exactly that.Failure is always a miss
ok=false. The client treats that as a miss or a refused save and warns. The server never sends a 5xx, which the client would retry five times and then log as an error.ACTIONS_RESULTS_URLis harmless on its own, because an unpatched runner overwrites it with GitHub's address. The job just uses GitHub's cache.Storage
actions-cache/<owner>/<repo>-<hash>/s<hash(ref)>, taggedk<hash(version, key)>, with the key, version, ref, created time and size in annotations. Each uploaded Azure block becomes its own blob, so concurrent out-of-orderPut Blocks stream straight through without being buffered or reassembled. The manifest lists them in block-list order.Content-Lengthand single-range support.Verified
Runner.Worker.dll(linux-x64 tarball, and the win-x64 zip we embed) contains exactly one UTF-16LEACTIONS_RESULTS_URL;Runner.Common.dllandRunner.Sdk.dllcontain none.TestPatchResultsURL_RealRunnerpatched both real DLLs with the size unchanged. It's opt-in viaEPHEMERD_RUNNER_WORKER_DLL, so re-run it on every runner bump.generated/results/api/v1/cache.ts,cache.twirp-client.ts,cachemetadata.ts,cachescope.ts), none guessed:useProtoFieldName(key,version,restore_keys, andsize_bytesas a string);ok,signed_upload_url,entry_id,signed_download_url,matched_keyandmessage.uploadCacheArchiveSDK(BlockBlobClient.uploadFile, 128 MiB single-shot).downloadCacheHttpClient, used for every non-*.blob.core.windows.nethost, with a Content-Length check.registry:2that, like the real one, refuses manifests whose blobs are missing. It covers single-shot and reversed-order block uploads, ranges, restore-key prefix/newest/version rules, PR-reads-main-but-cannot-write-it, repo isolation, unbound jobs, signed-URL binding, conflicting re-bind, immutability, size mismatch and limit, registry down, LRU eviction, Forget, and artifact relay;JobForIP, dind env translation, scheduler tracking and binding, and config.go build ./...passes;go vet ./...passes on Windows, and withCGO_ENABLED=0 GOOS=linux;go testpasses for every touched package on Windows;Not verified yet
@actions/cacheclient against this server. The Go tests drive the same wire format, but I haven't run the Node client or the Azure SDK against it. The first on-metal job is that test. Things to watch:Put Block/Put Block Listresponse headers;http://10.88.0.1:8089/...signed URL as IP-style.registry:2's acceptance of the custom config media type. The fake enforces blob existence, not media types.Turning it on (after the mayfly PR provisions the registry)
Roll it out to one node first, and compare against #215's baseline: restore p50 47 s, save p90 4 min, 12 of 19 jobs hitting.