detached 49c3309

feat(cache): GitHub Actions cache service v1 on the runner (#24)

commit
49c3309d652dd9b18ef619dfb6ddcd282ed016b9
author
brice · · 2026-10-14 21:05 UTC
committer
bnema · · 2026-10-14 21:05 UTC
parent
43088fd
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
34 files · +3947 -25
M README.md +17 -1
@@ -58,7 +58,7 @@ Reaping removes every process, device, firewall rule and Docker object the runne
5858
5959 ### Network policy (both modes)
6060
61-One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address (plus one TCP port when the opt-in guest forge access is on, below), where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.
61+One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address (plus one TCP port when the opt-in guest forge access is on, below, and the cache service port, see "Cache"), where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.
6262
6363 **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes).
6464
@@ -75,6 +75,20 @@ The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the sc
7575
7676 **The runner does not proxy.** The traffic reaches the host's own listener on that port, so a service must listen on it on an address the gateway reaches (`*`, as Gordon does; a listener bound to `127.0.0.1` receives nothing). A shared reverse proxy on that port routes by `Host` header, so **a job can reach every app that proxy serves on that port**, not only the forge, by sending another `Host` header: use this on staging hosts only, with a proxy whose other apps you accept to expose to job code. That traffic comes from a private address (`10.200.x.x`), so anything behind the proxy that trusts private sources (admin pages, metrics, rate-limit exemptions) trusts job code too. Jobs also see the forge over plain HTTP in that setup.
7777
78+### Cache (GitHub Actions cache, API v1)
79+
80+The runner serves the cache of `actions/cache`, `setup-go`/`setup-node` with `cache:` and everything else built on `@actions/cache`. The toolkit uses **cache API v1** for any `GITHUB_SERVER_URL` that is not `github.com`, `*.ghe.com` or `*.localhost` (`toolkit/packages/cache/src/internal/config.ts:1-20`), so that is what is served: `GET _apis/artifactcache/cache?keys=&version=`, `POST caches` (reserve), `PATCH caches/{id}` (chunks with `Content-Range`, in any order), `POST caches/{id}` (commit), and the archive by a plain `GET` of the `archiveLocation`. `ACTIONS_CACHE_SERVICE_V2` is never set.
81+
82+- **Where.** One service per runner, listening on the gateway address of each sandbox on TCP port 8089, in both modes (the container driver and the VM driver share `netsetup`). The nftables input chain accepts that port from the sandbox's own address and interface only, and drops it from every other interface, like port 53 (`internal/netpolicy/testdata/cache.nft`). Node and docker actions get `ACTIONS_CACHE_URL=http://<gateway>:8089/` (the toolkit appends `_apis/…`), `ACTIONS_RUNTIME_URL` (same value) and `ACTIONS_RUNTIME_TOKEN`; `run:` steps get none, as on the official runner. The token is masked. Job containers and docker actions run inside the sandbox and reach the same gateway address through the sandbox's own NAT (the source stays the sandbox's address); this path is covered by the policy tests but has not been exercised in a live sandbox yet.
83+- **Storage.** `<state dir>/cache/data/<id>` plus `index.json` (mode 0700/0600), written atomically (temporary file, fsync, rename, directory fsync). Total size is capped by `ERMITE_RUNNER_CACHE_MAX` (default 10G; `500M`, `20G`…): the least recently used entries are evicted first, both on commit and when a reservation needs room. A reservation is dropped after an hour without activity, and in any case at the expiry of the token that made it or an hour after it was made, whichever comes first (one with a chunk in flight is reclaimed when that chunk ends; every transfer's deadline is capped at the token's expiry). A restart keeps the entries and drops unfinished uploads.
84+- **Scopes.** Entries belong to (repository, ref, key, version). A job writes to its write ref only and reads its read refs, in priority order; keys are tried in the order the toolkit sends them in each ref (the primary key exactly, restore keys as prefixes, newest first). Another repository's entries are never visible. A second reservation of the same (repository, ref, key, version) while one exists or is in flight is a `409`.
85+- **Tokens.** `ACTIONS_RUNTIME_TOKEN` is `base64url(claims).base64url(HMAC-SHA256)` over the job id, repository, write ref, read refs and expiry (job timeout plus 15 min), with a random key made at start and kept in memory only (a restart ends every job anyway). The comparison is constant time; everything wrong (no token, bad MAC, expired, ids of another job or ref) is a bare `403`. The token is not an ermite credential and ermite refuses it. The archive URL carries its own HMAC (entry, repository, expiry) because the toolkit downloads without the bearer token.
86+- **Off.** `ERMITE_RUNNER_CACHE=0`.
87+
88+**Scopes from the server.** The server sends `Job.cache {repository, write_ref, read_refs}` (feature `ci.cache`). The runner checks it (`internal/host/cache.go`): the repository must be the job's own, and the scope always carries the job's canonical `owner/name`; refs are full `refs/…` names, empty ones are dropped, at most 8 read refs; an empty `write_ref` makes the job read-only (reserve answers `403 cache write denied:`). A scope that fails the checks disables the cache for that job with a log line; the job runs. **Old server fallback:** with no `Job.cache` the scope is `github.repository` and `Job.ref`, for reading and writing, with no base or default branch fallback; a job without either gets no cache.
89+
90+**Limits.** Every entry and reservation is charged at least 4 KiB, so tiny entries cannot pile up; uploads that give no `cacheSize` are charged for the bytes they write, before the bytes are accepted. At most 256 uploads in progress (32 per job); an entry being downloaded is never evicted (its bytes stay charged), and a reservation that cannot be given room answers `503`. At most 16 connections per sandbox and 64 in all; an upload or download that makes no progress for 60 s is cut.
91+
7892 ### Cancellation
7993
8094 The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor.
@@ -328,6 +342,8 @@ the moment it starts.
328342 | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit |
329343 | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox |
330344 | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the host and port of `ERMITE_RUNNER_URL` on the host (staging only, see "Guest forge access") |
345+| `ERMITE_RUNNER_CACHE` | `1` | `0` turns the cache service off (see "Cache") |
346+| `ERMITE_RUNNER_CACHE_MAX` | `10G` | size cap of the cache, LRU eviction |
331347 | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") |
332348 | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
333349 | `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables |
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27.1
44
55 require (
66 bnema.dev/ermite-actions v0.2.0
7- bnema.dev/ermite-api v0.2.0
7+ bnema.dev/ermite-api v0.2.1-0.20261014200206-5c335b855824
88 connectrpc.com/connect v1.21.0
99 github.com/bnema/zerowrap v1.4.1
1010 github.com/containerd/errdefs v1.0.0
M go.sum +2 -2 generated file: collapsed
M host_cmd.go +41 -0
@@ -9,12 +9,15 @@ import (
99 "fmt"
1010 "net"
1111 "os"
12+ "path/filepath"
13+ "strings"
1214 "time"
1315
1416 "github.com/bnema/zerowrap"
1517
1618 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1719
20+ "bnema.dev/ermite-runner/internal/cachesvc"
1821 "bnema.dev/ermite-runner/internal/externals"
1922 "bnema.dev/ermite-runner/internal/host"
2023 "bnema.dev/ermite-runner/internal/sandbox"
@@ -33,6 +36,35 @@ func loadHostConfig() (sandbox.Config, host.Config, error) {
3336 return scfg, hcfg, err
3437 }
3538
39+// setupCache starts the cache service of the runner (README, "Cache") and hands
40+// it to the sandbox drivers through scfg. ERMITE_RUNNER_CACHE=0 turns it off;
41+// ERMITE_RUNNER_CACHE_MAX is the size cap (default 10 GiB).
42+func setupCache(ctx context.Context, scfg *sandbox.Config) (*cachesvc.Service, error) {
43+ log := zerowrap.FromCtx(ctx)
44+ switch strings.ToLower(strings.TrimSpace(os.Getenv(envCache))) {
45+ case "", "1", "true", "yes":
46+ case "0", "false", "no":
47+ log.Info().Msg("the cache service is off (" + envCache + "=0)")
48+ return nil, nil
49+ default:
50+ return nil, fmt.Errorf("%s: %q is not 0 or 1", envCache, os.Getenv(envCache))
51+ }
52+ max, err := cachesvc.ParseMax(os.Getenv(cachesvc.EnvMax))
53+ if err != nil {
54+ return nil, err
55+ }
56+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(scfg.StateDir, "cache"), Max: max, Log: log})
57+ if err != nil {
58+ return nil, err
59+ }
60+ scfg.Cache, scfg.CachePort = svc, cachesvc.DefaultPort
61+ log.Info().Int64("max_bytes", max).Uint16("port", scfg.CachePort).Msg("cache service on")
62+ return svc, nil
63+}
64+
65+// envCache=0 turns the cache service off.
66+const envCache = "ERMITE_RUNNER_CACHE"
67+
3668 // externalsWait is how long the start of the runner waits for the Node.js
3769 // downloads. A runner with a working network is then ready for its first job; one
3870 // without goes on after the first failure (nothing waits for a dead network), and
@@ -144,6 +176,14 @@ func runCmd(ctx context.Context) int {
144176 hcfg.GuestForgeOrigin = gf.Origin
145177 }
146178 setupExternals(ctx, &scfg)
179+ cache, err := setupCache(ctx, &scfg)
180+ if err != nil {
181+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
182+ return 2
183+ }
184+ if cache != nil {
185+ defer func() { _ = cache.CloseAll() }()
186+ }
147187 drv, err := buildDriver(ctx, scfg)
148188 if err != nil {
149189 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
@@ -160,6 +200,7 @@ func runCmd(ctx context.Context) int {
160200 return hcfg.Client(url, tok)
161201 },
162202 Features: hcfg.ServerFeaturesFunc(url),
203+ Cache: cache, CachePort: scfg.CachePort,
163204 }
164205 r.Cfg.URL = url
165206 if err := r.Run(ctx); err != nil {
A internal/cachesvc/handler.go +427 -0
@@ -0,0 +1,427 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/url"
12+ "os"
13+ "strconv"
14+ "strings"
15+ "time"
16+)
17+
18+// APIPath is the prefix of the cache API: the toolkit appends "_apis/
19+// artifactcache/<resource>" to ACTIONS_CACHE_URL
20+// (toolkit/packages/cache/src/internal/cacheHttpClient.ts:47).
21+const APIPath = "/_apis/artifactcache/"
22+
23+// Limits of the requests, from the toolkit (cache.ts:97-115: a key is at most
24+// 512 characters and has no comma; restore keys are at most 10) and of this
25+// service (the version is a sha256 hex string for the toolkit).
26+const (
27+ maxKeyLen = 512
28+ maxVersionLen = 128
29+ maxKeys = 11
30+ maxJSONBody = 1 << 20
31+)
32+
33+// Messages the toolkit looks for (cache.ts:50, constants.ts:45): a denial that
34+// starts with them is logged as a warning, not as a failure.
35+const (
36+ writeDenied = "cache write denied: "
37+ readDenied = "cache read denied: "
38+)
39+
40+// Handler returns the HTTP handler of the cache service (API v1).
41+func (s *Service) Handler() http.Handler {
42+ mux := http.NewServeMux()
43+ // The routes are those of the toolkit's v1 client (cacheHttpClient.ts):
44+ // GET cache?keys=k1,k2&version=v :89 lookup
45+ // GET caches?key=k :132 diagnostics (core.isDebug())
46+ // POST caches {key,version,cacheSize} :190 reserve -> {cacheId}
47+ // PATCH caches/{id} Content-Range :230 chunk upload
48+ // POST caches/{id} {size} :330 commit
49+ // GET <archiveLocation> downloadUtils.ts:168 (no bearer token)
50+ mux.HandleFunc("GET "+APIPath+"cache", s.auth(s.lookup))
51+ mux.HandleFunc("GET "+APIPath+"caches", s.auth(s.listCaches))
52+ mux.HandleFunc("POST "+APIPath+"caches", s.auth(s.reserve))
53+ mux.HandleFunc("PATCH "+APIPath+"caches/{id}", s.auth(s.upload))
54+ mux.HandleFunc("POST "+APIPath+"caches/{id}", s.auth(s.commit))
55+ mux.HandleFunc("GET "+APIPath+"artifacts/{id}", s.download)
56+ mux.HandleFunc("HEAD "+APIPath+"artifacts/{id}", s.download)
57+ // net/http resets the write deadline per request only when the server has a
58+ // WriteTimeout, which would cut long uploads and downloads: set a fresh
59+ // progress deadline for each request here, so that a deadline left by the
60+ // previous request of a kept-alive connection never applies to this one.
61+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+ rc := http.NewResponseController(w)
63+ d := time.Now().Add(s.progress)
64+ _ = rc.SetReadDeadline(d)
65+ _ = rc.SetWriteDeadline(d)
66+ mux.ServeHTTP(w, r)
67+ })
68+}
69+
70+type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims)
71+
72+// auth checks the bearer token (ACTIONS_RUNTIME_TOKEN). Every refusal is a bare
73+// 403 whatever the reason.
74+func (s *Service) auth(h handlerFunc) http.HandlerFunc {
75+ return func(w http.ResponseWriter, r *http.Request) {
76+ const p = "Bearer "
77+ a := r.Header.Get("Authorization")
78+ if len(a) <= len(p) || !strings.EqualFold(a[:len(p)], p) {
79+ reply(w, http.StatusForbidden, "forbidden")
80+ return
81+ }
82+ c, err := s.sign.verify(strings.TrimSpace(a[len(p):]), s.now())
83+ if err != nil {
84+ reply(w, http.StatusForbidden, "forbidden")
85+ return
86+ }
87+ // Every body, JSON or chunk, must keep moving: a client that stalls
88+ // loses its connection (and an upload its in-flight range).
89+ pr := newProgressReader(r.Body, http.NewResponseController(w), s.progress, s.remaining(c.Expiry))
90+ defer pr.done()
91+ r.Body = readCloser{pr, r.Body}
92+ h(w, r, c)
93+ }
94+}
95+
96+type readCloser struct {
97+ io.Reader
98+ io.Closer
99+}
100+
101+// reply sends the {"message": …} error body that @actions/http-client turns into
102+// the error message.
103+func reply(w http.ResponseWriter, code int, msg string) {
104+ w.Header().Set("Content-Type", "application/json")
105+ w.WriteHeader(code)
106+ _ = json.NewEncoder(w).Encode(map[string]string{"message": msg})
107+}
108+
109+func replyJSON(w http.ResponseWriter, code int, v any) {
110+ w.Header().Set("Content-Type", "application/json")
111+ w.WriteHeader(code)
112+ _ = json.NewEncoder(w).Encode(v)
113+}
114+
115+// ArtifactCacheEntry is the lookup answer (toolkit contracts.ts:10-16).
116+type artifactCacheEntry struct {
117+ CacheKey string `json:"cacheKey"`
118+ Scope string `json:"scope"`
119+ CacheVersion string `json:"cacheVersion"`
120+ CreationTime string `json:"creationTime"`
121+ ArchiveLocation string `json:"archiveLocation"`
122+}
123+
124+func (s *Service) entryJSON(r *http.Request, c Claims, e Entry) artifactCacheEntry {
125+ exp := c.Expiry
126+ q := url.Values{"exp": {strconv.FormatInt(exp, 10)}, "sig": {s.sign.archiveSig(e.ID, e.Repository, exp)}}
127+ host := r.Host
128+ return artifactCacheEntry{
129+ CacheKey: e.Key, Scope: e.Ref, CacheVersion: e.Version,
130+ CreationTime: e.Created.UTC().Format("2006-01-02T15:04:05Z"),
131+ ArchiveLocation: "http://" + host + APIPath + "artifacts/" + strconv.FormatInt(e.ID, 10) + "?" + q.Encode(),
132+ }
133+}
134+
135+func validKey(k string) bool {
136+ return k != "" && len(k) <= maxKeyLen && !strings.Contains(k, ",")
137+}
138+
139+func validVersion(v string) bool { return v != "" && len(v) <= maxVersionLen }
140+
141+func (s *Service) lookup(w http.ResponseWriter, r *http.Request, c Claims) {
142+ if len(c.ReadRefs) == 0 {
143+ reply(w, http.StatusForbidden, readDenied+"this job may not read the cache")
144+ return
145+ }
146+ q := r.URL.Query()
147+ keys := strings.Split(q.Get("keys"), ",")
148+ version := q.Get("version")
149+ if len(keys) > maxKeys || !validVersion(version) {
150+ reply(w, http.StatusBadRequest, "bad keys or version")
151+ return
152+ }
153+ for _, k := range keys {
154+ if !validKey(k) {
155+ reply(w, http.StatusBadRequest, "bad keys or version")
156+ return
157+ }
158+ }
159+ e, ok := s.store.find(c.Repository, c.ReadRefs, keys, version)
160+ if !ok {
161+ w.WriteHeader(http.StatusNoContent)
162+ return
163+ }
164+ replyJSON(w, http.StatusOK, s.entryJSON(r, c, e))
165+}
166+
167+func (s *Service) listCaches(w http.ResponseWriter, r *http.Request, c Claims) {
168+ if len(c.ReadRefs) == 0 {
169+ reply(w, http.StatusForbidden, readDenied+"this job may not read the cache")
170+ return
171+ }
172+ list := s.store.list(c.Repository, c.ReadRefs, r.URL.Query().Get("key"))
173+ out := struct {
174+ TotalCount int `json:"totalCount"`
175+ ArtifactCaches []artifactCacheEntry `json:"artifactCaches"`
176+ }{TotalCount: len(list), ArtifactCaches: []artifactCacheEntry{}}
177+ for _, e := range list {
178+ out.ArtifactCaches = append(out.ArtifactCaches, s.entryJSON(r, c, e))
179+ }
180+ replyJSON(w, http.StatusOK, out)
181+}
182+
183+func (s *Service) reserve(w http.ResponseWriter, r *http.Request, c Claims) {
184+ if c.WriteRef == "" {
185+ reply(w, http.StatusForbidden, writeDenied+"this job may not write the cache")
186+ return
187+ }
188+ var req struct {
189+ Key string `json:"key"`
190+ Version string `json:"version"`
191+ CacheSize int64 `json:"cacheSize"`
192+ }
193+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBody)).Decode(&req); err != nil || !validKey(req.Key) || !validVersion(req.Version) || req.CacheSize < 0 {
194+ reply(w, http.StatusBadRequest, "bad reservation")
195+ return
196+ }
197+ id, err := s.store.reserve(c.JobID, c.Repository, c.WriteRef, req.Key, req.Version, req.CacheSize, time.Unix(c.Expiry, 0))
198+ switch {
199+ case errors.Is(err, errConflict):
200+ reply(w, http.StatusConflict, err.Error())
201+ case errors.Is(err, errTooBig):
202+ reply(w, http.StatusBadRequest, err.Error())
203+ case errors.Is(err, errNoRoom), errors.Is(err, errTooMany):
204+ reply(w, http.StatusServiceUnavailable, err.Error())
205+ case err != nil:
206+ s.log.Error().Err(err).Msg("cache reserve")
207+ reply(w, http.StatusInternalServerError, "cache service error")
208+ default:
209+ replyJSON(w, http.StatusCreated, map[string]int64{"cacheId": id})
210+ }
211+}
212+
213+func parseID(r *http.Request) (int64, bool) {
214+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
215+ return id, err == nil && id > 0
216+}
217+
218+// parseRange reads "bytes a-b/*" (cacheHttpClient.ts:206-212): both ends
219+// inclusive; the total is a star or a positive size that covers the range.
220+func parseRange(h string) (lo, hi int64, ok bool) {
221+ rest, found := strings.CutPrefix(h, "bytes ")
222+ if !found {
223+ return 0, 0, false
224+ }
225+ span, total, found := strings.Cut(rest, "/")
226+ if !found {
227+ return 0, 0, false
228+ }
229+ a, b, found := strings.Cut(span, "-")
230+ if !found {
231+ return 0, 0, false
232+ }
233+ lo, err1 := strconv.ParseInt(a, 10, 64)
234+ last, err2 := strconv.ParseInt(b, 10, 64)
235+ if err1 != nil || err2 != nil || lo < 0 || last < lo || last >= 1<<62 {
236+ return 0, 0, false
237+ }
238+ if total != "*" {
239+ n, err := strconv.ParseInt(total, 10, 64)
240+ if err != nil || n <= 0 || n < last+1 {
241+ return 0, 0, false
242+ }
243+ }
244+ return lo, last + 1, true
245+}
246+
247+func (s *Service) upload(w http.ResponseWriter, r *http.Request, c Claims) {
248+ id, ok := parseID(r)
249+ lo, hi, ok2 := parseRange(r.Header.Get("Content-Range"))
250+ if !ok || !ok2 {
251+ reply(w, http.StatusBadRequest, "bad cache id or Content-Range")
252+ return
253+ }
254+ path, err := s.store.beginChunk(id, c, lo, hi)
255+ if err != nil {
256+ s.storeError(w, err)
257+ return
258+ }
259+ // The range counts as received only when the whole body was written and
260+ // checked; on any failure its coverage stays removed.
261+ written := false
262+ defer func() { s.store.endChunk(id, lo, hi, written) }()
263+ if s.hookChunk != nil {
264+ s.hookChunk(id)
265+ }
266+ f, err := os.OpenFile(path, os.O_WRONLY, 0)
267+ if err != nil {
268+ s.log.Error().Err(err).Msg("cache chunk")
269+ reply(w, http.StatusInternalServerError, "cache service error")
270+ return
271+ }
272+ defer func() { _ = f.Close() }()
273+ // Exactly the announced bytes: fewer or more is a broken client or a
274+ // truncated connection.
275+ n, err := io.Copy(io.NewOffsetWriter(f, lo), io.LimitReader(r.Body, hi-lo))
276+ if err != nil || n != hi-lo {
277+ reply(w, http.StatusBadRequest, fmt.Sprintf("the body is not the %d bytes of the Content-Range", hi-lo))
278+ return
279+ }
280+ if extra, _ := io.CopyN(io.Discard, r.Body, 1); extra > 0 {
281+ reply(w, http.StatusBadRequest, "the body is longer than the Content-Range")
282+ return
283+ }
284+ if err := f.Close(); err != nil {
285+ s.log.Error().Err(err).Msg("cache chunk")
286+ reply(w, http.StatusInternalServerError, "cache service error")
287+ return
288+ }
289+ written = true
290+ w.WriteHeader(http.StatusNoContent)
291+}
292+
293+// progressReader extends the read deadline of the connection before each read:
294+// a client that sends nothing for the progress timeout loses its upload
295+// (slowloris on bodies), a slow one that keeps sending does not.
296+type progressReader struct {
297+ r io.Reader
298+ rc *http.ResponseController
299+ idle time.Duration
300+ remaining func() time.Duration
301+}
302+
303+// remaining returns how long a token that expires at the Unix time exp (or a
304+// signed archive URL) is still valid, by the service clock.
305+func (s *Service) remaining(exp int64) func() time.Duration {
306+ return func() time.Duration { return time.Unix(exp, 0).Sub(s.now()) }
307+}
308+
309+// deadline is the next deadline of a transfer: the progress period from now, but
310+// never later than the end of the credential that admitted it (a request
311+// admitted before the expiry ends at the expiry). The clock of the credential is
312+// the service's, so the remaining time is turned into a real-time deadline.
313+func deadline(idle time.Duration, remaining func() time.Duration) time.Time {
314+ d := idle
315+ if remaining != nil {
316+ d = max(0, min(d, remaining()))
317+ }
318+ return time.Now().Add(d)
319+}
320+
321+func newProgressReader(r io.Reader, rc *http.ResponseController, idle time.Duration, remaining func() time.Duration) *progressReader {
322+ return &progressReader{r: r, rc: rc, idle: idle, remaining: remaining}
323+}
324+
325+// set moves the deadlines. The reply to a body that stalled still gets one more
326+// progress period to be written, but not past the credential's end.
327+func (p *progressReader) set() {
328+ _ = p.rc.SetReadDeadline(deadline(p.idle, p.remaining))
329+ _ = p.rc.SetWriteDeadline(deadline(2*p.idle, p.remaining))
330+}
331+
332+// Read: reading is progress, so the reply can wait for the body as long as the
333+// body keeps coming.
334+func (p *progressReader) Read(b []byte) (int, error) {
335+ p.set()
336+ return p.r.Read(b)
337+}
338+
339+// done leaves a deadline in place, never none: after the handler the server
340+// still drains what is left of the body, and that must not wait for ever either.
341+// The server resets the deadlines for the next request of the connection.
342+func (p *progressReader) done() { p.set() }
343+
344+// progressWriter is the same for downloads: the write deadline moves with
345+// every write.
346+type progressWriter struct {
347+ http.ResponseWriter
348+ rc *http.ResponseController
349+ idle time.Duration
350+ remaining func() time.Duration
351+}
352+
353+func (p *progressWriter) Write(b []byte) (int, error) {
354+ _ = p.rc.SetWriteDeadline(deadline(p.idle, p.remaining))
355+ return p.ResponseWriter.Write(b)
356+}
357+
358+// Unwrap lets http.ResponseController reach the real writer.
359+func (p *progressWriter) Unwrap() http.ResponseWriter { return p.ResponseWriter }
360+
361+func (s *Service) commit(w http.ResponseWriter, r *http.Request, c Claims) {
362+ id, ok := parseID(r)
363+ var req struct {
364+ Size int64 `json:"size"`
365+ }
366+ if !ok || json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBody)).Decode(&req) != nil {
367+ reply(w, http.StatusBadRequest, "bad cache id or size")
368+ return
369+ }
370+ if err := s.store.commit(id, c, req.Size); err != nil {
371+ s.storeError(w, err)
372+ return
373+ }
374+ w.WriteHeader(http.StatusNoContent)
375+}
376+
377+// storeError maps the errors of the store. A reservation of another job, ref or
378+// repository is the same 403 as a bad token; an unknown or expired one is 404.
379+func (s *Service) storeError(w http.ResponseWriter, err error) {
380+ switch {
381+ case errors.Is(err, errDenied):
382+ reply(w, http.StatusForbidden, "forbidden")
383+ case errors.Is(err, errNotFound):
384+ reply(w, http.StatusNotFound, err.Error())
385+ case errors.Is(err, errTooBig):
386+ reply(w, http.StatusRequestEntityTooLarge, err.Error())
387+ case errors.Is(err, errBadSize), errors.Is(err, errState):
388+ reply(w, http.StatusBadRequest, err.Error())
389+ case errors.Is(err, errOverlap):
390+ reply(w, http.StatusConflict, err.Error())
391+ case errors.Is(err, errNoRoom), errors.Is(err, errTooMany):
392+ reply(w, http.StatusServiceUnavailable, err.Error())
393+ default:
394+ s.log.Error().Err(err).Msg("cache")
395+ reply(w, http.StatusInternalServerError, "cache service error")
396+ }
397+}
398+
399+// download serves an archive at the location given by a lookup. The client
400+// fetches it without the bearer token (downloadUtils.ts:168), so the URL
401+// carries the proof: an HMAC over the entry, its repository and an expiry.
402+func (s *Service) download(w http.ResponseWriter, r *http.Request) {
403+ id, ok := parseID(r)
404+ exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
405+ if !ok || err != nil {
406+ reply(w, http.StatusForbidden, "forbidden")
407+ return
408+ }
409+ e, found := s.store.get(id)
410+ if !found || !s.sign.archiveOK(id, e.Repository, exp, r.URL.Query().Get("sig"), s.now()) {
411+ reply(w, http.StatusForbidden, "forbidden")
412+ return
413+ }
414+ f, e, release, err := s.store.open(id, e.Repository)
415+ if err != nil {
416+ reply(w, http.StatusNotFound, "no such cache")
417+ return
418+ }
419+ // The entry cannot be evicted (its bytes stay charged) while it is read.
420+ defer release()
421+ defer func() { _ = f.Close() }()
422+ rc := http.NewResponseController(w)
423+ remaining := s.remaining(exp)
424+ defer func() { _ = rc.SetWriteDeadline(deadline(s.progress, remaining)) }()
425+ w.Header().Set("Content-Type", "application/octet-stream")
426+ http.ServeContent(&progressWriter{ResponseWriter: w, rc: rc, idle: s.progress, remaining: remaining}, r, "", e.Created, f)
427+}
A internal/cachesvc/limit.go +65 -0
@@ -0,0 +1,65 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "net"
7+ "sync"
8+)
9+
10+// limitListener bounds the open connections of a listener (local) and of the
11+// service (global, shared by all listeners). A slot is taken only once a
12+// connection has been accepted, so an idle listener holds none; a connection
13+// that finds no slot is closed at once (never queued or waited for), which makes
14+// the client retry. Idle and stalled connections are cut by the server's
15+// timeouts and the progress deadlines of the handlers, which frees their slots.
16+type limitListener struct {
17+ net.Listener
18+ local chan struct{}
19+ global chan struct{}
20+}
21+
22+func newLimitListener(l net.Listener, perListener int, global chan struct{}) *limitListener {
23+ return &limitListener{Listener: l, local: make(chan struct{}, perListener), global: global}
24+}
25+
26+// take takes a slot of c without waiting.
27+func take(c chan struct{}) bool {
28+ select {
29+ case c <- struct{}{}:
30+ return true
31+ default:
32+ return false
33+ }
34+}
35+
36+func (l *limitListener) Accept() (net.Conn, error) {
37+ for {
38+ c, err := l.Listener.Accept()
39+ if err != nil {
40+ return nil, err
41+ }
42+ if !take(l.local) {
43+ _ = c.Close()
44+ continue
45+ }
46+ if !take(l.global) {
47+ <-l.local
48+ _ = c.Close()
49+ continue
50+ }
51+ return &limitConn{Conn: c, release: func() { <-l.global; <-l.local }}, nil
52+ }
53+}
54+
55+type limitConn struct {
56+ net.Conn
57+ once sync.Once
58+ release func()
59+}
60+
61+func (c *limitConn) Close() error {
62+ err := c.Conn.Close()
63+ c.once.Do(c.release)
64+ return err
65+}
A internal/cachesvc/limits_test.go +732 -0
@@ -0,0 +1,732 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "fmt"
9+ "github.com/bnema/zerowrap"
10+ "io"
11+ "net"
12+ "net/http"
13+ "net/http/httptest"
14+ "os"
15+ "path/filepath"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+)
21+
22+// chunkReq sends a PATCH whose body the test feeds through the returned writer;
23+// the status arrives on the channel. The body is streamed, so the server has
24+// the Content-Range and a body that stays open until the test closes it.
25+func (j *job) chunkReq(id int64, lo, hi int) (w *io.PipeWriter, done <-chan int) {
26+ pr, pw := io.Pipe()
27+ res := make(chan int, 1)
28+ req, _ := http.NewRequest("PATCH", j.r.srv.URL+APIPath+fmt.Sprintf("caches/%d", id), pr)
29+ req.Header.Set("Authorization", "Bearer "+j.token)
30+ req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/*", lo, hi-1))
31+ go func() {
32+ resp, err := http.DefaultClient.Do(req)
33+ if err != nil {
34+ res <- -1
35+ return
36+ }
37+ _ = resp.Body.Close()
38+ res <- resp.StatusCode
39+ }()
40+ return pw, res
41+}
42+
43+// wait receives from c; the timeout only bounds a failing test.
44+func wait[T any](t *testing.T, c <-chan T, what string) T {
45+ t.Helper()
46+ select {
47+ case v := <-c:
48+ return v
49+ case <-time.After(30 * time.Second):
50+ t.Fatalf("timeout waiting for %s", what)
51+ panic("unreachable")
52+ }
53+}
54+
55+func dataFiles(t *testing.T, r *rig) int {
56+ t.Helper()
57+ des, err := os.ReadDir(filepath.Join(r.dir, "data"))
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ return len(des)
62+}
63+
64+// A6: a retry of a range that fails halfway must not leave the earlier bytes
65+// counted as valid.
66+func TestFailedRetryRemovesCoverage(t *testing.T) {
67+ r := newRig(t, 1<<20)
68+ j := r.job("j1", repoA, main_, main_)
69+ _, id := j.reserve("k", v1, 4)
70+ if j.chunk(id, []byte("abcd"), 0) != 204 {
71+ t.Fatal("first chunk")
72+ }
73+ // The same range again, with a body one byte short of its Content-Range.
74+ code, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("x"), map[string]string{"Content-Range": "bytes 0-3/*"})
75+ if code != 400 {
76+ t.Fatalf("short retry: %d", code)
77+ }
78+ if c := j.commit(id, 4); c != 400 {
79+ t.Fatalf("commit after a failed retry must fail, got %d", c)
80+ }
81+ if j.chunk(id, []byte("wxyz"), 0) != 204 || j.commit(id, 4) != 204 {
82+ t.Fatal("a good retry works")
83+ }
84+ if _, data, _ := j.restore(v1, "k"); string(data) != "wxyz" {
85+ t.Fatalf("content %q", data)
86+ }
87+}
88+
89+// B4: with a body held open by the test (no sleeps) commit is refused, an
90+// overlapping range is refused, a disjoint one is accepted, an expiry pass does
91+// not drop the upload, and the upload then completes and restores.
92+func TestConcurrentChunks(t *testing.T) {
93+ admitted := make(chan int64, 4)
94+ r := newRigWith(t, Config{Max: 1 << 20, MinCharge: 1, ReservationMaxLife: 24 * time.Hour, hookChunk: func(id int64) { admitted <- id }})
95+ j := r.job("j1", repoA, main_, main_)
96+ _, id := j.reserve("k", v1, 8)
97+
98+ pw, done := j.chunkReq(id, 0, 4)
99+ wait(t, admitted, "the first chunk to be admitted")
100+
101+ if c := j.commit(id, 8); c != 400 {
102+ t.Errorf("commit with a chunk in flight: %d", c)
103+ }
104+ if c := j.chunk(id, []byte("cdef"), 2); c != http.StatusConflict {
105+ t.Errorf("overlapping range: %d", c)
106+ }
107+ if c := j.chunk(id, []byte("efgh"), 4); c != 204 {
108+ t.Errorf("disjoint range: %d", c)
109+ }
110+ wait(t, admitted, "the second chunk to be admitted")
111+
112+ // Far past the idle ttl (but inside the absolute life): the next reserve runs
113+ // the expiry, which must keep an upload with a chunk in flight.
114+ r.clk.add(2 * time.Hour)
115+ if code, _ := j.reserve("other", v1, 4); code != http.StatusCreated {
116+ t.Fatalf("reserve: %d", code)
117+ }
118+ if n := r.svc.store.pendingCount(); n != 2 {
119+ t.Fatalf("pending reservations %d, want the upload and the new one", n)
120+ }
121+
122+ if _, err := pw.Write([]byte("abcd")); err != nil {
123+ t.Fatal(err)
124+ }
125+ _ = pw.Close()
126+ if code := wait(t, done, "the first chunk"); code != 204 {
127+ t.Fatalf("first chunk: %d", code)
128+ }
129+ if c := j.commit(id, 8); c != 204 {
130+ t.Fatalf("commit: %d", c)
131+ }
132+ if _, data, _ := j.restore(v1, "k"); string(data) != "abcdefgh" {
133+ t.Fatalf("content %q", data)
134+ }
135+}
136+
137+// A body that stalls loses its range: the progress deadline cuts it, the range
138+// is free again and is not counted as received.
139+func TestStalledUploadIsCut(t *testing.T) {
140+ r := newRigWith(t, Config{Max: 1 << 20, MinCharge: 1, ProgressTimeout: 100 * time.Millisecond})
141+ j := r.job("j1", repoA, main_, main_)
142+ _, id := j.reserve("k", v1, 8)
143+
144+ conn, err := net.Dial("tcp", strings.TrimPrefix(r.srv.URL, "http://"))
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ defer func() { _ = conn.Close() }()
149+ // Eight bytes announced, four sent, then silence.
150+ _, err = fmt.Fprintf(conn, "PATCH %scaches/%d HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer %s\r\nContent-Range: bytes 0-7/*\r\nContent-Length: 8\r\n\r\nabcd", APIPath, id, j.token)
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
155+ if err != nil {
156+ t.Fatalf("no answer: %v", err)
157+ }
158+ _ = resp.Body.Close()
159+ if resp.StatusCode != 400 {
160+ t.Fatalf("stalled upload: %d", resp.StatusCode)
161+ }
162+ if c := j.commit(id, 8); c != 400 {
163+ t.Fatalf("commit: %d", c)
164+ }
165+ // Not in flight any more: the same range can be sent again.
166+ if j.chunk(id, []byte("abcdefgh"), 0) != 204 || j.commit(id, 8) != 204 {
167+ t.Fatal("the range is free again")
168+ }
169+}
170+
171+// The write deadline of a download moves with every write.
172+type deadlineWriter struct {
173+ httptest.ResponseRecorder
174+ deadlines []time.Time
175+}
176+
177+func (d *deadlineWriter) SetWriteDeadline(t time.Time) error {
178+ d.deadlines = append(d.deadlines, t)
179+ return nil
180+}
181+
182+func TestProgressWriterExtendsDeadline(t *testing.T) {
183+ d := &deadlineWriter{ResponseRecorder: *httptest.NewRecorder()}
184+ pw := &progressWriter{ResponseWriter: d, rc: http.NewResponseController(d), idle: time.Minute}
185+ for i := 0; i < 3; i++ {
186+ if _, err := pw.Write([]byte("x")); err != nil {
187+ t.Fatal(err)
188+ }
189+ }
190+ if len(d.deadlines) != 3 || d.deadlines[0].IsZero() {
191+ t.Fatalf("deadlines %v", d.deadlines)
192+ }
193+}
194+
195+// GO-010: slots are taken by accepted connections only. Idle listeners hold
196+// none, so many sandboxes cannot starve one another; a connection over a cap is
197+// closed at once, and a slot comes back when a connection closes.
198+func TestLimitListener(t *testing.T) {
199+ const listeners, global = 70, 64
200+ slots := make(chan struct{}, global)
201+ var ls []*limitListener
202+ for i := 0; i < listeners; i++ {
203+ ln := listenLoopbackT(t)
204+ l := newLimitListener(ln, 2, slots)
205+ t.Cleanup(func() { _ = l.Close() })
206+ ls = append(ls, l)
207+ }
208+ if len(slots) != 0 {
209+ t.Fatalf("idle listeners hold %d global slots", len(slots))
210+ }
211+ dial := func(l *limitListener) net.Conn {
212+ c, err := net.Dial("tcp", l.Addr().String())
213+ if err != nil {
214+ t.Fatal(err)
215+ }
216+ t.Cleanup(func() { _ = c.Close() })
217+ return c
218+ }
219+ // Every accepted connection is closed at cleanup, whatever happens (Close is
220+ // idempotent and releases its slots once).
221+ accepted := func(c net.Conn) net.Conn {
222+ t.Cleanup(func() { _ = c.Close() })
223+ return c
224+ }
225+ // Each of the first 64 listeners still accepts its connection.
226+ var held []net.Conn
227+ for _, l := range ls[:global] {
228+ dial(l)
229+ c, err := l.Accept()
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ held = append(held, accepted(c))
234+ }
235+ if len(slots) != global {
236+ t.Fatalf("global slots %d", len(slots))
237+ }
238+ // The 65th is over the global cap: closed at once. Its Accept goes on to the
239+ // next connection, so a later one proves the loop is alive.
240+ over := dial(ls[global])
241+ next := make(chan net.Conn, 1)
242+ go func() {
243+ if c, err := ls[global].Accept(); err == nil {
244+ next <- accepted(c)
245+ }
246+ }()
247+ _ = over.SetReadDeadline(time.Now().Add(30 * time.Second))
248+ if _, err := over.Read(make([]byte, 1)); err == nil || strings.Contains(err.Error(), "timeout") {
249+ t.Fatalf("over-cap connection not closed by the server: %v", err)
250+ }
251+ // Free one slot: the next connection is served.
252+ _ = held[0].Close()
253+ _ = held[0].Close() // idempotent: no slot is released twice
254+ if len(slots) != global-1 {
255+ t.Fatalf("after one close: %d slots", len(slots))
256+ }
257+ dial(ls[global])
258+ c := wait(t, next, "a connection once a slot is free")
259+ held = append(held[1:], c)
260+
261+ // The local cap: two on one listener, the third is closed.
262+ for _, c := range held[:10] {
263+ _ = c.Close()
264+ }
265+ l := ls[0]
266+ var mine []net.Conn
267+ for i := 0; i < 2; i++ {
268+ dial(l)
269+ c, err := l.Accept()
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ mine = append(mine, accepted(c))
274+ }
275+ third := dial(l)
276+ go func() {
277+ if c, err := l.Accept(); err == nil {
278+ _ = accepted(c).Close()
279+ }
280+ }()
281+ _ = third.SetReadDeadline(time.Now().Add(30 * time.Second))
282+ if _, err := third.Read(make([]byte, 1)); err == nil || strings.Contains(err.Error(), "timeout") {
283+ t.Fatalf("connection over the listener's share not closed: %v", err)
284+ }
285+ for _, c := range append(held[10:], mine...) {
286+ _ = c.Close()
287+ }
288+ if len(slots) != 0 || len(l.local) != 0 {
289+ t.Fatalf("slots left: global %d local %d", len(slots), len(l.local))
290+ }
291+}
292+
293+func listenLoopbackT(t *testing.T) net.Listener {
294+ t.Helper()
295+ ln, err := net.Listen("tcp", "127.0.0.1:0")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ return ln
300+}
301+
302+// A2: a reservation without cacheSize is charged for the bytes it writes, before
303+// they are accepted, and keeps the charge until commit.
304+func TestSizelessUploadIsCharged(t *testing.T) {
305+ r := newRig(t, 100)
306+ a, b := r.job("ja", repoA, main_, main_), r.job("jb", repoA, feat, feat)
307+ _, ida := a.reserve("a", v1, 0)
308+ _, idb := b.reserve("b", v1, 0)
309+ if c := a.chunk(ida, make([]byte, 60), 0); c != 204 {
310+ t.Fatalf("first: %d", c)
311+ }
312+ if _, _, held := r.svc.store.stats(); held != 61 { // 60 + the 1 of b
313+ t.Fatalf("held %d", held)
314+ }
315+ if c := b.chunk(idb, make([]byte, 50), 0); c != http.StatusServiceUnavailable {
316+ t.Fatalf("over the budget: %d", c)
317+ }
318+ // Refused before the bytes were taken: the file is empty, nothing is covered.
319+ if st, _ := os.Stat(r.svc.store.tmp(idb)); st.Size() != 0 {
320+ t.Fatalf("refused chunk wrote %d bytes", st.Size())
321+ }
322+ if c := b.chunk(idb, make([]byte, 39), 0); c != 204 {
323+ t.Fatalf("what fits: %d", c)
324+ }
325+ // Retrying a smaller or equal range later costs nothing more.
326+ if c := b.chunk(idb, make([]byte, 39), 0); c != 204 {
327+ t.Fatalf("retry: %d", c)
328+ }
329+ if _, _, held := r.svc.store.stats(); held != 99 {
330+ t.Fatalf("held %d", held)
331+ }
332+ if c := a.commit(ida, 60); c != 204 {
333+ t.Fatalf("commit: %d", c)
334+ }
335+ if n, used, held := r.svc.store.stats(); n != 1 || used != 60 || held != 39 {
336+ t.Fatalf("after commit: entries %d used %d held %d", n, used, held)
337+ }
338+ // Above the whole limit.
339+ _, idc := a.reserve("c", v1, 0)
340+ if c := a.chunk(idc, make([]byte, 101), 0); c != http.StatusRequestEntityTooLarge {
341+ t.Fatalf("over the limit: %d", c)
342+ }
343+}
344+
345+// A size-less upload makes room by evicting the least recently used entries.
346+func TestSizelessUploadEvicts(t *testing.T) {
347+ r := newRig(t, 100)
348+ j := r.job("j", repoA, main_, main_)
349+ for _, k := range []string{"old", "new"} {
350+ if c := j.save(k, v1, bytes.Repeat([]byte(k[:1]), 40)); c != 204 {
351+ t.Fatalf("%s: %d", k, c)
352+ }
353+ r.clk.add(time.Minute)
354+ }
355+ _, id := j.reserve("big", v1, 0)
356+ if c := j.chunk(id, make([]byte, 50), 0); c != 204 {
357+ t.Fatalf("chunk: %d", c)
358+ }
359+ if code, _ := j.lookup(v1, "old"); code != 204 {
360+ t.Fatalf("old must be evicted: %d", code)
361+ }
362+ if code, _ := j.lookup(v1, "new"); code != 200 {
363+ t.Fatalf("new must stay: %d", code)
364+ }
365+}
366+
367+// A3/B1: uploads in progress are bounded in all and per job, before any file is
368+// created; idle ones are reclaimed with the clock.
369+func TestPendingReservationLimits(t *testing.T) {
370+ r := newRigWith(t, Config{Max: 1 << 20, MinCharge: 1, MaxPending: 3, MaxPendingPerJob: 2})
371+ j1, j2, j3 := r.job("j1", repoA, main_, main_), r.job("j2", repoA, main_, main_), r.job("j3", repoA, main_, main_)
372+ for _, k := range []string{"a", "b"} {
373+ if code, _ := j1.reserve(k, v1, 1); code != http.StatusCreated {
374+ t.Fatalf("%s: %d", k, code)
375+ }
376+ }
377+ if code, _ := j1.reserve("c", v1, 1); code != http.StatusServiceUnavailable {
378+ t.Fatalf("per job: %d", code)
379+ }
380+ if code, _ := j2.reserve("d", v1, 1); code != http.StatusCreated {
381+ t.Fatalf("other job: %d", code)
382+ }
383+ if code, _ := j3.reserve("e", v1, 1); code != http.StatusServiceUnavailable {
384+ t.Fatalf("global: %d", code)
385+ }
386+ if n := dataFiles(t, r); n != 3 {
387+ t.Fatalf("%d files for 3 reservations: refused ones must create none", n)
388+ }
389+ // Idle reservations are reclaimed once the ttl passes.
390+ r.clk.add(2 * time.Hour)
391+ if code, _ := j3.reserve("e", v1, 1); code != http.StatusCreated {
392+ t.Fatalf("after expiry: %d", code)
393+ }
394+ if n, f := r.svc.store.pendingCount(), dataFiles(t, r); n != 1 || f != 1 {
395+ t.Fatalf("pending %d files %d", n, f)
396+ }
397+}
398+
399+// A3: every entry and reservation costs at least the minimum charge, so tiny
400+// entries cannot pile up without bound.
401+func TestMinimumCharge(t *testing.T) {
402+ r := newRigWith(t, Config{Max: 10, MinCharge: 4})
403+ j := r.job("j", repoA, main_, main_)
404+ for _, k := range []string{"a", "b", "c"} {
405+ if c := j.save(k, v1, []byte("x")); c != 204 {
406+ t.Fatalf("%s: %d", k, c)
407+ }
408+ r.clk.add(time.Minute)
409+ }
410+ if n, used, _ := r.svc.store.stats(); n != 2 || used != 8 {
411+ t.Fatalf("entries %d used %d: 10 bytes hold two entries of 4", n, used)
412+ }
413+ // Reservations are charged too, and nothing is evictable under them.
414+ r2 := newRigWith(t, Config{Max: 8, MinCharge: 4})
415+ j2 := r2.job("j", repoA, main_, main_)
416+ for _, k := range []string{"a", "b"} {
417+ if code, _ := j2.reserve(k, v1, 1); code != http.StatusCreated {
418+ t.Fatalf("%s: %d", k, code)
419+ }
420+ }
421+ if code, _ := j2.reserve("c", v1, 1); code != http.StatusServiceUnavailable {
422+ t.Fatalf("third reservation: %d", code)
423+ }
424+}
425+
426+// A4: an entry being downloaded is not evicted; with nothing else to reclaim a
427+// reservation fails, and succeeds when the reader is done.
428+func TestPinnedEntryIsNotEvicted(t *testing.T) {
429+ r := newRig(t, 30)
430+ j := r.job("j", repoA, main_, main_)
431+ for _, k := range []string{"a", "b", "c"} {
432+ if c := j.save(k, v1, bytes.Repeat([]byte(k), 10)); c != 204 {
433+ t.Fatalf("%s: %d", k, c)
434+ }
435+ r.clk.add(time.Minute)
436+ }
437+ e, _ := r.svc.store.find(repoA, []string{main_}, []string{"a"}, v1) // "a" is now the most recent
438+ r.clk.add(time.Minute)
439+ f, _, release, err := r.svc.store.open(e.ID, repoA)
440+ if err != nil {
441+ t.Fatal(err)
442+ }
443+ // Whatever happens: the file first, then the pin (both are idempotent).
444+ t.Cleanup(func() { _ = f.Close(); release() })
445+ r.clk.add(time.Minute)
446+ // "b" is the least recently used unpinned entry; "a" survives although it is
447+ // the one a plain LRU would pick after more use.
448+ for _, k := range []string{"b", "c"} {
449+ r.clk.add(time.Minute)
450+ kf, _, rel, err := r.svc.store.open(idOf(t, r, k), repoA)
451+ if err != nil {
452+ t.Fatal(err)
453+ }
454+ _ = kf.Close() // files before pins
455+ rel()
456+ }
457+ // Everything but "a" is more recent than "a" now: LRU order puts "a" first.
458+ if c := j.save("d", v1, bytes.Repeat([]byte("d"), 10)); c != 204 {
459+ t.Fatalf("d: %d", c)
460+ }
461+ if _, err := os.Stat(r.svc.store.file(e.ID)); err != nil {
462+ t.Fatalf("the pinned entry was evicted: %v", err)
463+ }
464+ if got, _ := io.ReadAll(f); string(got) != strings.Repeat("a", 10) {
465+ t.Fatalf("reader got %q", got)
466+ }
467+ // Reclaiming everything else is not enough for 25 bytes while "a" is pinned.
468+ if code, _ := j.reserve("big", v1, 25); code != http.StatusServiceUnavailable {
469+ t.Fatalf("reserve with nothing reclaimable: %d", code)
470+ }
471+ _ = f.Close()
472+ release()
473+ release() // idempotent
474+ if code, _ := j.reserve("big", v1, 25); code != http.StatusCreated {
475+ t.Fatalf("after the reader is done: %d", code)
476+ }
477+ r.svc.store.mu.Lock()
478+ pins := len(r.svc.store.pins)
479+ r.svc.store.mu.Unlock()
480+ if pins != 0 {
481+ t.Fatalf("%d pins left", pins)
482+ }
483+}
484+
485+func idOf(t *testing.T, r *rig, key string) int64 {
486+ t.Helper()
487+ e, ok := r.svc.store.find(repoA, []string{main_}, []string{key}, v1)
488+ if !ok {
489+ t.Fatalf("no entry %q", key)
490+ }
491+ return e.ID
492+}
493+
494+// A downloaded archive is unpinned when the response is done.
495+func TestDownloadReleasesPin(t *testing.T) {
496+ r := newRig(t, 100)
497+ j := r.job("j", repoA, main_, main_)
498+ if c := j.save("k", v1, []byte("data")); c != 204 {
499+ t.Fatal(c)
500+ }
501+ if _, data, ok := j.restore(v1, "k"); !ok || string(data) != "data" {
502+ t.Fatalf("%v %q", ok, data)
503+ }
504+ r.svc.store.mu.Lock()
505+ pins := len(r.svc.store.pins)
506+ r.svc.store.mu.Unlock()
507+ if pins != 0 {
508+ t.Fatalf("%d pins after the download", pins)
509+ }
510+}
511+
512+// B2: a reserve writes the index only when it evicted something; usage times
513+// wait for the housekeeping.
514+func TestReserveWritesIndexOnlyOnEviction(t *testing.T) {
515+ r := newRig(t, 10)
516+ j := r.job("j", repoA, main_, main_)
517+ if c := j.save("k", v1, bytes.Repeat([]byte("x"), 8)); c != 204 {
518+ t.Fatal(c)
519+ }
520+ idx := filepath.Join(r.dir, indexName)
521+ if err := os.Remove(idx); err != nil {
522+ t.Fatal(err)
523+ }
524+ if code, _ := j.lookup(v1, "k"); code != 200 { // dirties the usage time
525+ t.Fatal(code)
526+ }
527+ if code, _ := j.reserve("small", v1, 2); code != http.StatusCreated { // fits: no eviction
528+ t.Fatal(code)
529+ }
530+ if _, err := os.Stat(idx); !os.IsNotExist(err) {
531+ t.Fatalf("the index was written by a reserve that evicted nothing: %v", err)
532+ }
533+ if code, _ := j.reserve("big", v1, 8); code != http.StatusCreated { // evicts "k"
534+ t.Fatal(code)
535+ }
536+ if _, err := os.Stat(idx); err != nil {
537+ t.Fatalf("an eviction must be persisted: %v", err)
538+ }
539+ // And the housekeeping flush still writes timestamps.
540+ if err := r.svc.store.flush(); err != nil {
541+ t.Fatal(err)
542+ }
543+}
544+
545+// A8: the total of a Content-Range is "*" or a positive size that covers the
546+// range.
547+func TestParseRange(t *testing.T) {
548+ for h, want := range map[string]bool{
549+ "bytes 0-3/*": true, "bytes 0-3/4": true, "bytes 0-3/100": true, "bytes 5-5/*": true,
550+ "bytes 0-3/": false, "bytes 0-3/0": false, "bytes 0-3/-1": false, "bytes 0-3/3": false, "bytes 0-3/x": false,
551+ "bytes 0-3": false, "bytes 3-0/*": false, "bytes -1-3/*": false, "bytes a-b/*": false, "items 0-1/*": false, "": false,
552+ "bytes 0-4611686018427387904/*": false,
553+ } {
554+ if _, _, ok := parseRange(h); ok != want {
555+ t.Errorf("parseRange(%q) = %v", h, ok)
556+ }
557+ }
558+ if lo, hi, _ := parseRange("bytes 4-7/*"); lo != 4 || hi != 8 {
559+ t.Errorf("bounds %d %d", lo, hi)
560+ }
561+}
562+
563+// syncBuf is a log sink that handler goroutines and the test may use together.
564+type syncBuf struct {
565+ mu sync.Mutex
566+ b bytes.Buffer
567+}
568+
569+func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
570+func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
571+
572+// GO-012: an eviction marks the index dirty before it is saved; a failed save
573+// leaves it dirty, logs, and the housekeeping flush writes it later.
574+func TestFailedIndexSaveStaysDirtyAndIsLogged(t *testing.T) {
575+ var logs syncBuf
576+ r := newRigWith(t, Config{Max: 10, MinCharge: 1, Log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"})})
577+ j := r.job("j", repoA, main_, main_)
578+ if c := j.save("k", v1, bytes.Repeat([]byte("x"), 8)); c != 204 {
579+ t.Fatal(c)
580+ }
581+ // A directory where the temporary index goes: every save fails.
582+ blocker := filepath.Join(r.dir, indexName+".tmp")
583+ if err := os.Mkdir(blocker, 0o700); err != nil {
584+ t.Fatal(err)
585+ }
586+ if code, _ := j.reserve("big", v1, 8); code != http.StatusCreated { // evicts "k"
587+ t.Fatalf("reserve: %d", code)
588+ }
589+ r.svc.store.mu.Lock()
590+ dirty := r.svc.store.dirty
591+ r.svc.store.mu.Unlock()
592+ if !dirty {
593+ t.Fatal("a failed save must leave the index dirty")
594+ }
595+ if !strings.Contains(logs.String(), "cache index") {
596+ t.Fatalf("the failure was not logged: %q", logs.String())
597+ }
598+ if err := r.svc.store.flush(); err == nil {
599+ t.Fatal("flush must report the failure")
600+ }
601+ if err := os.Remove(blocker); err != nil {
602+ t.Fatal(err)
603+ }
604+ if err := r.svc.store.flush(); err != nil {
605+ t.Fatalf("retry: %v", err)
606+ }
607+ r.svc.store.mu.Lock()
608+ dirty = r.svc.store.dirty
609+ r.svc.store.mu.Unlock()
610+ if dirty {
611+ t.Fatal("dirty after a successful flush")
612+ }
613+ b, _ := os.ReadFile(filepath.Join(r.dir, indexName))
614+ if strings.Contains(string(b), `"key":"k"`) || !strings.HasPrefix(string(b), "[") {
615+ t.Fatalf("the evicted entry is still in the index: %s", b)
616+ }
617+}
618+
619+// GO-011: a reservation also ends at an absolute time (the token's expiry, or an
620+// hour after it was made), however recently it was touched.
621+func TestReservationAbsoluteExpiry(t *testing.T) {
622+ r := newRig(t, 1<<20)
623+ // Touched every 31 minutes, which keeps it from idling out, but it is over
624+ // one hour after its creation.
625+ j := r.job("j", repoA, main_, main_)
626+ _, id := j.reserve("k", v1, 8)
627+ r.clk.add(31 * time.Minute)
628+ if c := j.chunk(id, []byte("abcd"), 0); c != 204 {
629+ t.Fatalf("chunk: %d", c)
630+ }
631+ r.clk.add(31 * time.Minute)
632+ if c := j.chunk(id, []byte("efgh"), 4); c != http.StatusNotFound {
633+ t.Fatalf("chunk past the absolute end: %d", c)
634+ }
635+ if code, _ := j.reserve("other", v1, 1); code != http.StatusCreated {
636+ t.Fatal(code)
637+ }
638+ if n, f := r.svc.store.pendingCount(), dataFiles(t, r); n != 1 || f != 1 {
639+ t.Fatalf("pending %d files %d: the old reservation must be reclaimed", n, f)
640+ }
641+
642+ // A token that ends sooner ends the reservation sooner.
643+ short := &job{r: r, token: r.svc.Token("js", Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, 10*time.Minute)}
644+ _, ids := short.reserve("s", v1, 4)
645+ r.svc.store.mu.Lock()
646+ end := r.svc.store.pending[ids].expires
647+ r.svc.store.mu.Unlock()
648+ if want := r.clk.now().Add(10 * time.Minute); end.Sub(want) > time.Second || want.Sub(end) > time.Second {
649+ t.Fatalf("expires %v, want about %v", end, want)
650+ }
651+}
652+
653+// GO-011: a reservation with a chunk in flight when it runs out is not dropped
654+// under the writer; it is reclaimed when the chunk ends, and cannot be committed.
655+func TestExpiredReservationIsReclaimedAfterActiveWrite(t *testing.T) {
656+ admitted := make(chan int64, 2)
657+ r := newRigWith(t, Config{Max: 1 << 20, MinCharge: 1, hookChunk: func(id int64) { admitted <- id }})
658+ j := r.job("j", repoA, main_, main_)
659+ _, id := j.reserve("k", v1, 8)
660+ pw, done := j.chunkReq(id, 0, 8)
661+ wait(t, admitted, "the chunk to be admitted")
662+
663+ r.clk.add(2 * time.Hour)
664+ if code, _ := j.reserve("other", v1, 1); code != http.StatusCreated { // runs the expiry
665+ t.Fatal(code)
666+ }
667+ if n := r.svc.store.pendingCount(); n != 2 {
668+ t.Fatalf("pending %d: the reservation with a write in flight must stay", n)
669+ }
670+ if _, err := pw.Write([]byte("abcdefgh")); err != nil {
671+ t.Fatal(err)
672+ }
673+ _ = pw.Close()
674+ wait(t, done, "the chunk to end")
675+ if n, f := r.svc.store.pendingCount(), dataFiles(t, r); n != 1 || f != 1 {
676+ t.Fatalf("pending %d files %d: reclaimed once the write is over", n, f)
677+ }
678+ if c := j.commit(id, 8); c != http.StatusNotFound {
679+ t.Fatalf("commit of a reclaimed reservation: %d", c)
680+ }
681+ if _, _, held := r.svc.store.stats(); held != 1 {
682+ t.Fatalf("held %d", held)
683+ }
684+}
685+
686+// GO-011: the deadline of a transfer never goes past the credential's end.
687+func TestTransferDeadlineIsCappedAtExpiry(t *testing.T) {
688+ near := func(d time.Time, want time.Duration) bool {
689+ got := time.Until(d)
690+ return got <= want && got > want-5*time.Second
691+ }
692+ left := 7 * time.Second
693+ rem := func() time.Duration { return left }
694+ if d := deadline(time.Minute, rem); !near(d, 7*time.Second) {
695+ t.Errorf("capped deadline in %v", time.Until(d))
696+ }
697+ if d := deadline(3*time.Second, rem); !near(d, 3*time.Second) {
698+ t.Errorf("progress deadline in %v", time.Until(d))
699+ }
700+ left = -time.Hour
701+ if d := deadline(time.Minute, rem); time.Until(d) > time.Second {
702+ t.Errorf("an expired credential gets %v", time.Until(d))
703+ }
704+ if d := deadline(time.Minute, nil); !near(d, time.Minute) {
705+ t.Errorf("no credential: %v", time.Until(d))
706+ }
707+
708+ // The writer of a download uses it on every write.
709+ d := &deadlineWriter{ResponseRecorder: *httptest.NewRecorder()}
710+ pw := &progressWriter{ResponseWriter: d, rc: http.NewResponseController(d), idle: time.Minute, remaining: func() time.Duration { return 2 * time.Second }}
711+ if _, err := pw.Write([]byte("x")); err != nil {
712+ t.Fatal(err)
713+ }
714+ if len(d.deadlines) != 1 || time.Until(d.deadlines[0]) > 2*time.Second {
715+ t.Fatalf("deadlines %v", d.deadlines)
716+ }
717+}
718+
719+// A token expiry also bounds the transfer through the service: the remaining
720+// time comes from the service clock.
721+func TestServiceRemaining(t *testing.T) {
722+ r := newRig(t, 1<<20)
723+ exp := r.clk.now().Add(90 * time.Second).Unix()
724+ rem := r.svc.remaining(exp)
725+ if got := rem(); got != 90*time.Second {
726+ t.Fatalf("remaining %v", got)
727+ }
728+ r.clk.add(time.Minute)
729+ if got := rem(); got != 30*time.Second {
730+ t.Fatalf("remaining %v", got)
731+ }
732+}
A internal/cachesvc/service.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package cachesvc is the GitHub Actions cache service (API v1) of the runner
4+// (plan CI-3, D8). Actions that use @actions/cache (setup-go, setup-node,
5+// actions/cache…) talk to it through ACTIONS_CACHE_URL: the toolkit takes the v1
6+// protocol for any server URL that is not github.com, *.ghe.com or *.localhost
7+// (toolkit/packages/cache/src/internal/config.ts:1-20).
8+//
9+// One Service per runner host. Entries live under <state>/cache/ (files plus a
10+// JSON index, atomic finalisation, LRU eviction by total size) and are scoped by
11+// (repository, ref, key, version). A job reaches the service with a bearer token
12+// (ACTIONS_RUNTIME_TOKEN) that is an HMAC, made with a key that exists only in
13+// the memory of the runner, over its job id, repository, write ref, read refs
14+// and expiry. The service listens on the gateway address of each sandbox, on one
15+// port that netpolicy opens to that sandbox only.
16+package cachesvc
17+
18+import (
19+ "context"
20+ "errors"
21+ "fmt"
22+ "net"
23+ "net/http"
24+ "net/netip"
25+ "strconv"
26+ "sync"
27+ "syscall"
28+ "time"
29+
30+ "github.com/bnema/zerowrap"
31+ "golang.org/x/sys/unix"
32+)
33+
34+// Defaults.
35+const (
36+ // DefaultMax is the size cap, GitHub's limit per repository.
37+ DefaultMax = 10 << 30
38+ // DefaultPort is the TCP port on the gateway addresses.
39+ DefaultPort = 8089
40+ // EnvMax is ERMITE_RUNNER_CACHE_MAX, the size cap (1G, 500M…).
41+ EnvMax = "ERMITE_RUNNER_CACHE_MAX"
42+ // DefaultReservationTTL is how long an upload may stay uncommitted.
43+ DefaultReservationTTL = time.Hour
44+ flushEvery = 30 * time.Second
45+ // DefaultProgressTimeout is how long an upload or a download may make no
46+ // progress before its connection is cut.
47+ DefaultProgressTimeout = 60 * time.Second
48+ // DefaultConnsPerListener and DefaultConns bound the open connections of one
49+ // sandbox's listener and of the whole service.
50+ DefaultConnsPerListener = 16
51+ DefaultConns = 64
52+)
53+
54+// Config configures a Service.
55+type Config struct {
56+ // Dir is the cache directory (<state>/cache).
57+ Dir string
58+ // Max is the size cap in bytes; 0 means DefaultMax.
59+ Max int64
60+ // ReservationTTL is the life of an uncommitted reservation; 0 means
61+ // DefaultReservationTTL.
62+ ReservationTTL time.Duration
63+ // ReservationMaxLife is the absolute life of a reservation from its creation
64+ // (the token's expiry still ends it sooner); 0 means DefaultReservationTTL.
65+ ReservationMaxLife time.Duration
66+ // Now is the clock; nil means time.Now.
67+ Now func() time.Time
68+ Log zerowrap.Logger
69+ // The limits below take the Default… values (or those of the store) when
70+ // zero; tests set them.
71+ ProgressTimeout time.Duration
72+ ConnsPerListener, Conns int
73+ MinCharge int64
74+ MaxPending, MaxPendingPerJob int
75+ // hookChunk, when set, is called by an upload once its range is admitted and
76+ // before its body is read (tests: a barrier with a chunk in flight).
77+ hookChunk func(id int64)
78+}
79+
80+// Service is the cache service.
81+type Service struct {
82+ store *store
83+ sign *signer
84+ now func() time.Time
85+ log zerowrap.Logger
86+
87+ mu sync.Mutex
88+ listeners map[netip.Addr]*listener
89+ stop chan struct{}
90+ wg sync.WaitGroup
91+ closed bool
92+
93+ hookChunk func(id int64)
94+ progress time.Duration
95+ perListener int
96+ connsGlobal chan struct{}
97+}
98+
99+type listener struct {
100+ srv *http.Server
101+ done chan struct{}
102+}
103+
104+// New opens the store and starts the housekeeping goroutine; Close stops it.
105+func New(cfg Config) (*Service, error) {
106+ if cfg.Max <= 0 {
107+ cfg.Max = DefaultMax
108+ }
109+ if cfg.ReservationTTL <= 0 {
110+ cfg.ReservationTTL = DefaultReservationTTL
111+ }
112+ if cfg.Now == nil {
113+ cfg.Now = time.Now
114+ }
115+ sg, err := newSigner()
116+ if err != nil {
117+ return nil, err
118+ }
119+ st, err := openStore(storeConfig{dir: cfg.Dir, max: cfg.Max, ttl: cfg.ReservationTTL, now: cfg.Now,
120+ minCharge: cfg.MinCharge, maxPending: cfg.MaxPending, maxPerJob: cfg.MaxPendingPerJob, maxLife: cfg.ReservationMaxLife, log: cfg.Log})
121+ if err != nil {
122+ return nil, err
123+ }
124+ if cfg.ProgressTimeout <= 0 {
125+ cfg.ProgressTimeout = DefaultProgressTimeout
126+ }
127+ if cfg.ConnsPerListener <= 0 {
128+ cfg.ConnsPerListener = DefaultConnsPerListener
129+ }
130+ if cfg.Conns <= 0 {
131+ cfg.Conns = DefaultConns
132+ }
133+ s := &Service{store: st, sign: sg, now: cfg.Now, log: cfg.Log, listeners: map[netip.Addr]*listener{}, stop: make(chan struct{}),
134+ hookChunk: cfg.hookChunk, progress: cfg.ProgressTimeout, perListener: cfg.ConnsPerListener, connsGlobal: make(chan struct{}, cfg.Conns)}
135+ s.wg.Add(1)
136+ go s.housekeeping()
137+ return s, nil
138+}
139+
140+// housekeeping writes the usage times of the index and drops stale reservations.
141+func (s *Service) housekeeping() {
142+ defer s.wg.Done()
143+ t := time.NewTicker(flushEvery)
144+ defer t.Stop()
145+ for {
146+ select {
147+ case <-s.stop:
148+ return
149+ case <-t.C:
150+ s.store.expire()
151+ if err := s.store.flush(); err != nil {
152+ s.log.Warn().Err(err).Msg("cache index")
153+ }
154+ }
155+ }
156+}
157+
158+// Token returns the ACTIONS_RUNTIME_TOKEN of a job. It is valid for ttl, which
159+// should be the job timeout plus a margin.
160+func (s *Service) Token(jobID string, sc Scope, ttl time.Duration) string {
161+ return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: s.now().Add(ttl).Unix()})
162+}
163+
164+// Listen serves the cache on addr:port, an address the caller has configured
165+// (the gateway of a sandbox). freeBind lets the bind precede the address
166+// (podman configures the bridge address late, like dnsfwd.Forwarder.FreeBind).
167+func (s *Service) Listen(ctx context.Context, addr netip.Addr, port uint16, freeBind bool) error {
168+ s.mu.Lock()
169+ defer s.mu.Unlock()
170+ if s.closed {
171+ return errors.New("cache: service closed")
172+ }
173+ if _, ok := s.listeners[addr]; ok {
174+ return fmt.Errorf("cache: already listening on %s", addr)
175+ }
176+ var lc net.ListenConfig
177+ if freeBind {
178+ lc.Control = func(_, _ string, c syscall.RawConn) error {
179+ var serr error
180+ if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil {
181+ return err
182+ }
183+ return serr
184+ }
185+ }
186+ ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port))))
187+ if err != nil {
188+ return fmt.Errorf("cache: listen %s: %w", addr, err)
189+ }
190+ return s.serveLocked(addr, ln)
191+}
192+
193+func (s *Service) serveLocked(addr netip.Addr, ln net.Listener) error {
194+ ln = newLimitListener(ln, s.perListener, s.connsGlobal)
195+ l := &listener{
196+ srv: &http.Server{
197+ Handler: s.Handler(),
198+ ReadHeaderTimeout: 30 * time.Second,
199+ // Bodies are chunks of up to tens of MiB and archives of GiB: only
200+ // idle connections time out.
201+ IdleTimeout: 2 * time.Minute,
202+ },
203+ done: make(chan struct{}),
204+ }
205+ s.listeners[addr] = l
206+ go func() {
207+ defer close(l.done)
208+ if err := l.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
209+ s.log.Error().Err(err).Str("addr", addr.String()).Msg("cache listener")
210+ }
211+ }()
212+ return nil
213+}
214+
215+// Close stops listening on addr. Unknown addresses are ignored.
216+func (s *Service) Close(addr netip.Addr) {
217+ s.mu.Lock()
218+ l := s.listeners[addr]
219+ delete(s.listeners, addr)
220+ s.mu.Unlock()
221+ if l != nil {
222+ _ = l.srv.Close()
223+ <-l.done
224+ }
225+}
226+
227+// CloseAll stops every listener and the housekeeping, and writes the index.
228+func (s *Service) CloseAll() error {
229+ s.mu.Lock()
230+ var addrs []netip.Addr
231+ for a := range s.listeners {
232+ addrs = append(addrs, a)
233+ }
234+ already := s.closed
235+ s.closed = true
236+ s.mu.Unlock()
237+ for _, a := range addrs {
238+ s.Close(a)
239+ }
240+ if already {
241+ return nil
242+ }
243+ close(s.stop)
244+ s.wg.Wait()
245+ return s.store.flush()
246+}
247+
248+// ParseMax reads ERMITE_RUNNER_CACHE_MAX: a number of bytes or a size with a K,
249+// M, G or T suffix (optionally followed by iB or B). Empty means DefaultMax.
250+func ParseMax(v string) (int64, error) {
251+ if v == "" {
252+ return DefaultMax, nil
253+ }
254+ s := []byte(v)
255+ for len(s) > 0 && (s[len(s)-1] == 'B' || s[len(s)-1] == 'b' || s[len(s)-1] == 'i') {
256+ s = s[:len(s)-1]
257+ }
258+ mult := int64(1)
259+ if n := len(s); n > 0 {
260+ switch s[n-1] {
261+ case 'k', 'K':
262+ mult, s = 1<<10, s[:n-1]
263+ case 'm', 'M':
264+ mult, s = 1<<20, s[:n-1]
265+ case 'g', 'G':
266+ mult, s = 1<<30, s[:n-1]
267+ case 't', 'T':
268+ mult, s = 1<<40, s[:n-1]
269+ }
270+ }
271+ n, err := strconv.ParseInt(string(s), 10, 64)
272+ if err != nil || n <= 0 || n > (1<<62)/mult {
273+ return 0, fmt.Errorf("%s: %q is not a size (examples: 10G, 500M)", EnvMax, v)
274+ }
275+ return n * mult, nil
276+}
A internal/cachesvc/service_test.go +623 -0
@@ -0,0 +1,623 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/http/httptest"
12+ "net/url"
13+ "os"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+)
21+
22+type clock struct {
23+ mu sync.Mutex
24+ t time.Time
25+}
26+
27+func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
28+func (c *clock) add(d time.Duration) {
29+ c.mu.Lock()
30+ c.t = c.t.Add(d)
31+ c.mu.Unlock()
32+}
33+
34+type rig struct {
35+ t *testing.T
36+ svc *Service
37+ srv *httptest.Server
38+ clk *clock
39+ dir string
40+}
41+
42+func newRig(t *testing.T, max int64) *rig {
43+ t.Helper()
44+ return newRigWith(t, Config{Max: max, MinCharge: 1})
45+}
46+
47+// newRigWith starts a service with cfg on top of the test clock and directory.
48+func newRigWith(t *testing.T, cfg Config) *rig {
49+ t.Helper()
50+ clk := &clock{t: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
51+ dir := filepath.Join(t.TempDir(), "cache")
52+ cfg.Dir, cfg.Now, cfg.ReservationTTL = dir, clk.now, time.Hour
53+ svc, err := New(cfg)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ srv := httptest.NewServer(svc.Handler())
58+ t.Cleanup(func() { srv.Close(); _ = svc.CloseAll() })
59+ return &rig{t: t, svc: svc, srv: srv, clk: clk, dir: dir}
60+}
61+
62+// job is a client with the token of one job, in the shapes of the toolkit's v1
63+// client (cacheHttpClient.ts).
64+type job struct {
65+ r *rig
66+ token string
67+}
68+
69+func (r *rig) job(id, repo, write string, reads ...string) *job {
70+ return &job{r: r, token: r.svc.Token(id, Scope{Repository: repo, WriteRef: write, ReadRefs: reads}, 6*time.Hour)}
71+}
72+
73+func (j *job) do(method, path string, body []byte, hdr map[string]string) (int, []byte, http.Header) {
74+ j.r.t.Helper()
75+ req, err := http.NewRequest(method, j.r.srv.URL+APIPath+path, bytes.NewReader(body))
76+ if err != nil {
77+ j.r.t.Fatal(err)
78+ }
79+ // getRequestOptions / createHttpClient: cacheHttpClient.ts:56-75.
80+ req.Header.Set("Accept", "application/json;api-version=6.0-preview.1")
81+ if j.token != "" {
82+ req.Header.Set("Authorization", "Bearer "+j.token)
83+ }
84+ for k, v := range hdr {
85+ req.Header.Set(k, v)
86+ }
87+ resp, err := http.DefaultClient.Do(req)
88+ if err != nil {
89+ j.r.t.Fatal(err)
90+ }
91+ defer func() { _ = resp.Body.Close() }()
92+ b, _ := io.ReadAll(resp.Body)
93+ return resp.StatusCode, b, resp.Header
94+}
95+
96+// reserve is reserveCache (:190): POST caches {key, version, cacheSize}.
97+func (j *job) reserve(key, version string, size int) (int, int64) {
98+ b, _ := json.Marshal(map[string]any{"key": key, "version": version, "cacheSize": size})
99+ code, body, _ := j.do("POST", "caches", b, map[string]string{"Content-Type": "application/json"})
100+ var out struct {
101+ CacheID int64 `json:"cacheId"`
102+ }
103+ _ = json.Unmarshal(body, &out)
104+ return code, out.CacheID
105+}
106+
107+// chunk is uploadChunk (:206-250).
108+func (j *job) chunk(id int64, data []byte, start int) int {
109+ end := start + len(data) - 1
110+ code, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), data, map[string]string{
111+ "Content-Type": "application/octet-stream", "Content-Range": fmt.Sprintf("bytes %d-%d/*", start, end)})
112+ return code
113+}
114+
115+func (j *job) commit(id int64, size int) int {
116+ b, _ := json.Marshal(map[string]int{"size": size})
117+ code, _, _ := j.do("POST", fmt.Sprintf("caches/%d", id), b, map[string]string{"Content-Type": "application/json"})
118+ return code
119+}
120+
121+// save does reserve, chunks of 4 bytes uploaded in reverse order, commit.
122+func (j *job) save(key, version string, data []byte) int {
123+ j.r.t.Helper()
124+ code, id := j.reserve(key, version, len(data))
125+ if code != http.StatusCreated {
126+ return code
127+ }
128+ for start := (len(data) - 1) / 4 * 4; start >= 0; start -= 4 {
129+ if c := j.chunk(id, data[start:min(start+4, len(data))], start); c != http.StatusNoContent {
130+ return c
131+ }
132+ }
133+ return j.commit(id, len(data))
134+}
135+
136+type lookupResult struct {
137+ Key, Scope, Version, Location string
138+ Time string
139+}
140+
141+// lookup is getCacheEntry (:77-125): GET cache?keys=&version=.
142+func (j *job) lookup(version string, keys ...string) (int, lookupResult) {
143+ j.r.t.Helper()
144+ code, body, _ := j.do("GET", "cache?keys="+url.QueryEscape(strings.Join(keys, ","))+"&version="+version, nil, nil)
145+ var e struct {
146+ CacheKey, Scope, CacheVersion, CreationTime, ArchiveLocation string
147+ }
148+ if code == 200 {
149+ if err := json.Unmarshal(body, &e); err != nil {
150+ j.r.t.Fatalf("lookup body %q: %v", body, err)
151+ }
152+ }
153+ return code, lookupResult{e.CacheKey, e.Scope, e.CacheVersion, e.ArchiveLocation, e.CreationTime}
154+}
155+
156+// fetch downloads the archive like downloadCacheHttpClient (downloadUtils.ts:168):
157+// a bare GET, no bearer token.
158+func (r *rig) fetch(loc string) (int, []byte) {
159+ r.t.Helper()
160+ resp, err := http.Get(loc)
161+ if err != nil {
162+ r.t.Fatal(err)
163+ }
164+ defer func() { _ = resp.Body.Close() }()
165+ b, _ := io.ReadAll(resp.Body)
166+ return resp.StatusCode, b
167+}
168+
169+func (j *job) restore(version string, keys ...string) (string, []byte, bool) {
170+ j.r.t.Helper()
171+ code, e := j.lookup(version, keys...)
172+ if code == 204 {
173+ return "", nil, false
174+ }
175+ if code != 200 {
176+ j.r.t.Fatalf("lookup: %d", code)
177+ }
178+ c, data := j.r.fetch(e.Location)
179+ if c != 200 {
180+ j.r.t.Fatalf("archive: %d", c)
181+ }
182+ return e.Key, data, true
183+}
184+
185+const (
186+ repoA = "bnema/app"
187+ repoB = "bnema/other"
188+ main_ = "refs/heads/main"
189+ feat = "refs/heads/feat"
190+ v1 = "0123abcd"
191+)
192+
193+func TestSaveAndRestoreByKeyAndPrefix(t *testing.T) {
194+ r := newRig(t, 1<<20)
195+ w := r.job("j1", repoA, main_, main_)
196+ if code := w.save("go-linux-abc", v1, []byte("0123456789")); code != http.StatusNoContent {
197+ t.Fatalf("save: %d", code)
198+ }
199+ rd := r.job("j2", repoA, "", main_)
200+
201+ // A miss is 204 with no body (the toolkit treats it as "not found").
202+ if code, _ := rd.lookup(v1, "go-linux-zzz"); code != http.StatusNoContent {
203+ t.Fatalf("miss: %d", code)
204+ }
205+ // Exact primary key.
206+ key, data, ok := rd.restore(v1, "go-linux-abc")
207+ if !ok || key != "go-linux-abc" || string(data) != "0123456789" {
208+ t.Fatalf("exact: %v %q %q", ok, key, data)
209+ }
210+ // The primary key is never a prefix: only restore keys are.
211+ if code, _ := rd.lookup(v1, "go-linux-a"); code != http.StatusNoContent {
212+ t.Fatalf("primary key matched as prefix: %d", code)
213+ }
214+ key, _, ok = rd.restore(v1, "go-linux-zzz", "go-linux-")
215+ if !ok || key != "go-linux-abc" {
216+ t.Fatalf("restore key: %v %q", ok, key)
217+ }
218+ // Another version (paths, compression) is another cache.
219+ if code, _ := rd.lookup("ffff", "go-linux-abc"); code != http.StatusNoContent {
220+ t.Fatalf("other version: %d", code)
221+ }
222+ code, e := rd.lookup(v1, "go-linux-abc")
223+ if code != 200 || e.Scope != main_ || e.Version != v1 || e.Time == "" {
224+ t.Fatalf("entry %+v", e)
225+ }
226+}
227+
228+func TestRestoreKeyPrefersNewestAndExact(t *testing.T) {
229+ r := newRig(t, 1<<20)
230+ w := r.job("j1", repoA, main_, main_)
231+ for _, k := range []string{"deps-1", "deps-2", "deps-3"} {
232+ if c := w.save(k, v1, []byte(k)); c != http.StatusNoContent {
233+ t.Fatal(c)
234+ }
235+ r.clk.add(time.Minute)
236+ }
237+ if k, _, _ := w.restore(v1, "nomatch", "deps-"); k != "deps-3" {
238+ t.Fatalf("newest prefix match: %q", k)
239+ }
240+ if k, _, _ := w.restore(v1, "nomatch", "deps-1"); k != "deps-1" {
241+ t.Fatalf("exact restore key: %q", k)
242+ }
243+}
244+
245+func TestReadScopePriority(t *testing.T) {
246+ r := newRig(t, 1<<20)
247+ // The default branch built "k" and "pre-1"; the feature ref has its own "k".
248+ if c := r.job("j0", repoA, main_, main_).save("k", v1, []byte("from-main")); c != 204 {
249+ t.Fatal(c)
250+ }
251+ if c := r.job("j0b", repoA, main_, main_).save("pre-1", v1, []byte("main-pre")); c != 204 {
252+ t.Fatal(c)
253+ }
254+ if c := r.job("j1", repoA, feat, feat, main_).save("k", v1, []byte("from-feat")); c != 204 {
255+ t.Fatal(c)
256+ }
257+ pr := r.job("j2", repoA, feat, feat, main_)
258+ if _, data, _ := pr.restore(v1, "k"); string(data) != "from-feat" {
259+ t.Fatalf("own ref first: %q", data)
260+ }
261+ // Not in the own ref: the next read ref answers.
262+ if k, data, ok := pr.restore(v1, "zzz", "pre-"); !ok || k != "pre-1" || string(data) != "main-pre" {
263+ t.Fatalf("fallback ref: %v %q %q", ok, k, data)
264+ }
265+ // A job whose scopes do not include feat never sees its entry.
266+ other := r.job("j3", repoA, "refs/heads/other", "refs/heads/other", main_)
267+ if _, data, _ := other.restore(v1, "k"); string(data) != "from-main" {
268+ t.Fatalf("other ref must read main's: %q", data)
269+ }
270+ if code, _ := r.job("j4", repoA, "", "refs/heads/other").lookup(v1, "k"); code != 204 {
271+ t.Fatalf("read scope leak: %d", code)
272+ }
273+}
274+
275+func TestReserveConflict(t *testing.T) {
276+ r := newRig(t, 1<<20)
277+ a := r.job("j1", repoA, feat, feat)
278+ code, id := a.reserve("k", v1, 4)
279+ if code != http.StatusCreated || id == 0 {
280+ t.Fatalf("%d %d", code, id)
281+ }
282+ // Reserved but not committed: a second reservation is a 409 (what the toolkit
283+ // reports as "another job may be creating this cache").
284+ if code, _ := r.job("j2", repoA, feat, feat).reserve("k", v1, 4); code != http.StatusConflict {
285+ t.Fatalf("reserved: %d", code)
286+ }
287+ // Another version, another ref, another repository do not conflict.
288+ if code, _ := a.reserve("k", "other", 4); code != http.StatusCreated {
289+ t.Fatalf("other version: %d", code)
290+ }
291+ if code, _ := r.job("j3", repoA, main_, main_).reserve("k", v1, 4); code != http.StatusCreated {
292+ t.Fatalf("other ref: %d", code)
293+ }
294+ if code, _ := r.job("j4", repoB, feat, feat).reserve("k", v1, 4); code != http.StatusCreated {
295+ t.Fatalf("other repo: %d", code)
296+ }
297+ if a.chunk(id, []byte("abcd"), 0) != 204 || a.commit(id, 4) != 204 {
298+ t.Fatal("upload")
299+ }
300+ // Committed: still a conflict.
301+ if code, _ := r.job("j5", repoA, feat, feat).reserve("k", v1, 4); code != http.StatusConflict {
302+ t.Fatalf("exists: %d", code)
303+ }
304+}
305+
306+func TestWriteOutsideRefRefused(t *testing.T) {
307+ r := newRig(t, 1<<20)
308+ ro := r.job("j1", repoA, "", main_)
309+ b, _ := json.Marshal(map[string]any{"key": "k", "version": v1, "cacheSize": 4})
310+ code, body, _ := ro.do("POST", "caches", b, nil)
311+ if code != http.StatusForbidden || !strings.HasPrefix(string(body), `{"message":"cache write denied: `) {
312+ t.Fatalf("read-only job: %d %s", code, body)
313+ }
314+ // A reservation is bound to its job's write ref: the token of a job with
315+ // another ref (or another job) cannot write into it, even with the id.
316+ w := r.job("j2", repoA, feat, feat)
317+ _, id := w.reserve("k", v1, 4)
318+ for name, o := range map[string]*job{
319+ "other ref": r.job("j3", repoA, main_, main_),
320+ "other job": r.job("j4", repoA, feat, feat),
321+ "other repo": r.job("j2", repoB, feat, feat),
322+ "read only": r.job("j2", repoA, "", feat),
323+ } {
324+ if c := o.chunk(id, []byte("abcd"), 0); c != http.StatusForbidden {
325+ t.Errorf("%s: chunk %d", name, c)
326+ }
327+ if c := o.commit(id, 4); c != http.StatusForbidden {
328+ t.Errorf("%s: commit %d", name, c)
329+ }
330+ }
331+ if n, _, _ := r.svc.store.stats(); n != 0 {
332+ t.Fatalf("entries: %d", n)
333+ }
334+ if w.chunk(id, []byte("abcd"), 0) != 204 || w.commit(id, 4) != 204 {
335+ t.Fatal("the owner can still upload")
336+ }
337+}
338+
339+func TestOtherRepositoryNeverRestored(t *testing.T) {
340+ r := newRig(t, 1<<20)
341+ if c := r.job("j1", repoA, main_, main_).save("k", v1, []byte("secret-a")); c != 204 {
342+ t.Fatal(c)
343+ }
344+ b := r.job("j2", repoB, main_, main_)
345+ if code, _ := b.lookup(v1, "k"); code != 204 {
346+ t.Fatalf("lookup across repositories: %d", code)
347+ }
348+ if code, _ := b.lookup(v1, "x", "k"); code != 204 {
349+ t.Fatalf("restore key across repositories: %d", code)
350+ }
351+ // The signed URL of A's entry is not a way in for B either: the signature is
352+ // bound to the repository. Control: the genuine URL works; the same id and
353+ // expiry signed for repoB, and nothing else changed, is refused.
354+ _, e := r.job("j3", repoA, "", main_).lookup(v1, "k")
355+ u, _ := url.Parse(e.Location)
356+ idStr := strings.TrimPrefix(u.Path, APIPath+"artifacts/")
357+ id, _ := strconv.ParseInt(idStr, 10, 64)
358+ exp, _ := strconv.ParseInt(u.Query().Get("exp"), 10, 64)
359+ if code, data := r.fetch(e.Location); code != 200 || string(data) != "secret-a" {
360+ t.Fatalf("control: %d %q", code, data)
361+ }
362+ forged := url.Values{"exp": {u.Query().Get("exp")}, "sig": {r.svc.sign.archiveSig(id, repoB, exp)}}
363+ if code, _ := r.fetch(r.srv.URL + APIPath + "artifacts/" + idStr + "?" + forged.Encode()); code != 403 {
364+ t.Fatalf("signature for the other repository: %d", code)
365+ }
366+}
367+
368+func TestBadTokens(t *testing.T) {
369+ r := newRig(t, 1<<20)
370+ good := r.job("j1", repoA, main_, main_)
371+ other := newRig(t, 1<<20) // another runner: another key
372+ expired := r.svc.sign.token(Claims{JobID: "j", Scope: Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, Expiry: r.clk.now().Add(-time.Second).Unix()})
373+ tampered := func() string {
374+ p, m, _ := strings.Cut(good.token, ".")
375+ payload, _ := b64.DecodeString(p)
376+ payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1)
377+ return b64.EncodeToString(payload) + "." + m
378+ }()
379+ for name, tok := range map[string]string{
380+ "none": "", "garbage": "abc", "dots": "a.b", "other key": other.job("j1", repoA, main_, main_).token,
381+ "expired": expired, "tampered claims": tampered, "no mac": strings.Split(good.token, ".")[0] + ".",
382+ } {
383+ j := &job{r: r, token: tok}
384+ for _, req := range []struct{ m, p string }{{"GET", "cache?keys=k&version=v"}, {"POST", "caches"}, {"PATCH", "caches/1"}, {"POST", "caches/1"}, {"GET", "caches?key=k"}} {
385+ if code, _, _ := j.do(req.m, req.p, []byte("{}"), nil); code != http.StatusForbidden {
386+ t.Errorf("%s %s %s: %d", name, req.m, req.p, code)
387+ }
388+ }
389+ }
390+ // A bare "Authorization: <token>" without the scheme is refused too.
391+ req, _ := http.NewRequest("GET", r.srv.URL+APIPath+"cache?keys=k&version=v", nil)
392+ req.Header.Set("Authorization", good.token)
393+ resp, err := http.DefaultClient.Do(req)
394+ if err != nil {
395+ t.Fatal(err)
396+ }
397+ _ = resp.Body.Close()
398+ if resp.StatusCode != 403 {
399+ t.Fatalf("no scheme: %d", resp.StatusCode)
400+ }
401+ // A token is not an archive signature, nor the other way round.
402+ if r.svc.sign.archiveOK(1, repoA, 1<<40, good.token, r.clk.now()) {
403+ t.Fatal("token accepted as archive signature")
404+ }
405+ if code, _ := r.fetch(r.srv.URL + APIPath + "artifacts/1?exp=99999999999&sig=x"); code != 403 {
406+ t.Fatalf("archive without signature: %d", code)
407+ }
408+}
409+
410+func TestChunkRulesAndCommitVerifiesSize(t *testing.T) {
411+ r := newRig(t, 1<<20)
412+ j := r.job("j1", repoA, main_, main_)
413+ _, id := j.reserve("k", v1, 8)
414+
415+ if c := j.chunk(id, []byte("0123456789"), 0); c != http.StatusRequestEntityTooLarge {
416+ t.Fatalf("beyond cacheSize: %d", c)
417+ }
418+ // Content-Range that disagrees with the body, either way.
419+ code, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("abc"), map[string]string{"Content-Range": "bytes 0-6/*"})
420+ if code != 400 {
421+ t.Fatalf("short body: %d", code)
422+ }
423+ code, _, _ = j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("abcdefg"), map[string]string{"Content-Range": "bytes 0-2/*"})
424+ if code != 400 {
425+ t.Fatalf("long body: %d", code)
426+ }
427+ for _, h := range []string{"", "bytes 5-2/*", "bytes a-b/*", "items 0-1/*", "bytes 0-1"} {
428+ if c, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("ab"), map[string]string{"Content-Range": h}); c != 400 {
429+ t.Errorf("Content-Range %q: %d", h, c)
430+ }
431+ }
432+ // Second half first, a hole at the start: commit refuses.
433+ if j.chunk(id, []byte("4567"), 4) != 204 {
434+ t.Fatal("chunk")
435+ }
436+ if c := j.commit(id, 8); c != 400 {
437+ t.Fatalf("commit with a hole: %d", c)
438+ }
439+ if j.chunk(id, []byte("0123"), 0) != 204 {
440+ t.Fatal("chunk")
441+ }
442+ if j.chunk(id, []byte("0123"), 0) != 204 { // a retry of the same range
443+ t.Fatal("chunk, repeated")
444+ }
445+ // The size of the commit must be the declared one.
446+ if c := j.commit(id, 7); c != 400 {
447+ t.Fatalf("commit with a wrong size: %d", c)
448+ }
449+ if c := j.commit(id, 8); c != 204 {
450+ t.Fatalf("commit: %d", c)
451+ }
452+ if c := j.commit(id, 8); c != 404 {
453+ t.Fatalf("second commit: %d", c)
454+ }
455+ if _, data, _ := j.restore(v1, "k"); string(data) != "01234567" {
456+ t.Fatalf("content %q", data)
457+ }
458+ // No temporary file is left.
459+ des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
460+ for _, de := range des {
461+ if strings.HasSuffix(de.Name(), ".tmp") {
462+ t.Errorf("left over %s", de.Name())
463+ }
464+ }
465+}
466+
467+func TestReservationWithoutSizeIsBoundedByTheLimit(t *testing.T) {
468+ r := newRig(t, 16)
469+ j := r.job("j1", repoA, main_, main_)
470+ code, id := j.reserve("k", v1, 0)
471+ if code != http.StatusCreated {
472+ t.Fatal(code)
473+ }
474+ if c := j.chunk(id, make([]byte, 17), 0); c != http.StatusRequestEntityTooLarge {
475+ t.Fatalf("over the limit: %d", c)
476+ }
477+ if code, _ := j.reserve("big", v1, 17); code != http.StatusBadRequest {
478+ t.Fatalf("cacheSize over the limit: %d", code)
479+ }
480+}
481+
482+func TestUncommittedReservationsExpire(t *testing.T) {
483+ r := newRig(t, 100)
484+ j := r.job("j1", repoA, main_, main_)
485+ _, id := j.reserve("k", v1, 60)
486+ // The space is held: a second 60-byte cache has no room (nothing to evict).
487+ if code, _ := j.reserve("k2", v1, 60); code != http.StatusServiceUnavailable {
488+ t.Fatalf("held space: %d", code)
489+ }
490+ r.clk.add(2 * time.Hour)
491+ if c := j.chunk(id, []byte("x"), 0); c != http.StatusNotFound {
492+ t.Fatalf("chunk after expiry: %d", c)
493+ }
494+ if code, _ := j.reserve("k2", v1, 60); code != http.StatusCreated {
495+ t.Fatalf("after expiry: %d", code)
496+ }
497+ // The key is free again and the temporary file is gone.
498+ if code, _ := j.reserve("k", v1, 10); code != http.StatusCreated {
499+ t.Fatalf("key after expiry: %d", code)
500+ }
501+ if _, _, held := r.svc.store.stats(); held != 70 {
502+ t.Fatalf("held %d", held)
503+ }
504+ if _, err := os.Stat(r.svc.store.tmp(id)); !os.IsNotExist(err) {
505+ t.Fatalf("temp file: %v", err)
506+ }
507+}
508+
509+func TestLRUEviction(t *testing.T) {
510+ r := newRig(t, 30)
511+ j := r.job("j1", repoA, main_, main_)
512+ save := func(k string) {
513+ t.Helper()
514+ if c := j.save(k, v1, bytes.Repeat([]byte{k[0]}, 10)); c != 204 {
515+ t.Fatalf("save %s: %d", k, c)
516+ }
517+ r.clk.add(time.Minute)
518+ }
519+ save("a")
520+ save("b")
521+ save("c")
522+ // Using "a" makes "b" the least recently used.
523+ if _, _, ok := j.restore(v1, "a"); !ok {
524+ t.Fatal("a")
525+ }
526+ r.clk.add(time.Minute)
527+ save("d")
528+ if code, _ := j.lookup(v1, "b"); code != 204 {
529+ t.Fatalf("b must be evicted: %d", code)
530+ }
531+ for _, k := range []string{"a", "c", "d"} {
532+ if code, _ := j.lookup(v1, k); code != 200 {
533+ t.Errorf("%s evicted: %d", k, code)
534+ }
535+ }
536+ if n, used, _ := r.svc.store.stats(); n != 3 || used != 30 {
537+ t.Fatalf("entries %d used %d", n, used)
538+ }
539+ des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
540+ if len(des) != 3 {
541+ t.Fatalf("files on disk: %d", len(des))
542+ }
543+ // An entry in flight is not evicted, and committing never evicts itself.
544+ if c := j.save("e", v1, bytes.Repeat([]byte("e"), 25)); c != 204 {
545+ t.Fatalf("large entry: %d", c)
546+ }
547+ if n, used, _ := r.svc.store.stats(); n != 1 || used != 25 {
548+ t.Fatalf("after large entry: entries %d used %d", n, used)
549+ }
550+}
551+
552+func TestIndexPersists(t *testing.T) {
553+ r := newRig(t, 1<<20)
554+ if c := r.job("j1", repoA, main_, main_).save("k", v1, []byte("persist")); c != 204 {
555+ t.Fatal(c)
556+ }
557+ // An abandoned reservation and a stray file are cleaned at restart.
558+ r.job("j2", repoA, feat, feat).reserve("half", v1, 5)
559+ if err := os.WriteFile(filepath.Join(r.dir, "data", "stray"), []byte("x"), 0o600); err != nil {
560+ t.Fatal(err)
561+ }
562+ if err := r.svc.CloseAll(); err != nil {
563+ t.Fatal(err)
564+ }
565+ svc2, err := New(Config{Dir: r.dir, Now: r.clk.now})
566+ if err != nil {
567+ t.Fatal(err)
568+ }
569+ defer func() { _ = svc2.CloseAll() }()
570+ e, ok := svc2.store.find(repoA, []string{main_}, []string{"k"}, v1)
571+ if !ok || e.Size != 7 {
572+ t.Fatalf("after restart: %v %+v", ok, e)
573+ }
574+ des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
575+ if len(des) != 1 {
576+ t.Fatalf("files after restart: %d", len(des))
577+ }
578+ // Tokens do not survive a restart: the key is in memory only.
579+ old := r.job("j1", repoA, main_, main_).token
580+ if _, err := svc2.sign.verify(old, r.clk.now()); err == nil {
581+ t.Fatal("token from the previous run accepted")
582+ }
583+}
584+
585+func TestListDiagnostics(t *testing.T) {
586+ r := newRig(t, 1<<20)
587+ w := r.job("j1", repoA, main_, main_)
588+ w.save("go-1", v1, []byte("x"))
589+ code, body, _ := w.do("GET", "caches?key=go-", nil, nil)
590+ var out struct {
591+ TotalCount int
592+ ArtifactCaches []struct{ CacheKey, Scope, CacheVersion string }
593+ }
594+ if code != 200 || json.Unmarshal(body, &out) != nil || out.TotalCount != 1 || out.ArtifactCaches[0].CacheKey != "go-1" {
595+ t.Fatalf("%d %s", code, body)
596+ }
597+}
598+
599+func TestParseMax(t *testing.T) {
600+ for in, want := range map[string]int64{"": DefaultMax, "10G": 10 << 30, "500m": 500 << 20, "1GiB": 1 << 30, "2048": 2048, "3TB": 3 << 40} {
601+ if got, err := ParseMax(in); err != nil || got != want {
602+ t.Errorf("%q: %d %v", in, got, err)
603+ }
604+ }
605+ for _, in := range []string{"0", "-1", "G", "ten", "1.5G", "99999999999T"} {
606+ if _, err := ParseMax(in); err == nil {
607+ t.Errorf("%q accepted", in)
608+ }
609+ }
610+}
611+
612+func TestAddSpan(t *testing.T) {
613+ var l []span
614+ for _, s := range []span{{8, 10}, {0, 4}, {4, 6}, {6, 8}} {
615+ l = addSpan(l, s)
616+ }
617+ if !complete(l, 10) || len(l) != 1 {
618+ t.Fatalf("%v", l)
619+ }
620+ if complete(addSpan(nil, span{1, 5}), 5) {
621+ t.Fatal("hole at the start")
622+ }
623+}
A internal/cachesvc/store.go +693 -0
@@ -0,0 +1,693 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "crypto/rand"
7+ "encoding/binary"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "sort"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+)
21+
22+// Entry is a committed cache entry. Reservations are never persisted: a restart
23+// ends every job, so an unfinished upload is dropped.
24+type Entry struct {
25+ ID int64 `json:"id"`
26+ Repository string `json:"repo"`
27+ Ref string `json:"ref"`
28+ Key string `json:"key"`
29+ Version string `json:"version"`
30+ Size int64 `json:"size"`
31+ Created time.Time `json:"created"`
32+ LastUsed time.Time `json:"last_used"`
33+}
34+
35+// reservation is an upload in progress (POST caches, PATCH, POST caches/{id}).
36+type reservation struct {
37+ id int64
38+ job string
39+ repo, ref string
40+ key, ver string
41+ declared int64 // cacheSize of the reservation; 0 when the client gave none
42+ // charged is what the reservation holds of the budget: the declared size
43+ // (up front), or for a client that gave none the high-water mark of the
44+ // ranges it has started to write; never less than the minimum charge. It is
45+ // kept until the reservation is committed or dropped.
46+ charged int64
47+ bound int64 // largest size accepted: declared, or the cache limit
48+ touched time.Time
49+ // expires is the absolute end of the reservation: the expiry of the token
50+ // that made it, or an hour after it was made, whichever comes first. It is
51+ // enforced besides the idle ttl; a reservation with a chunk in flight is
52+ // reclaimed once that chunk ends (the transfer deadlines are capped at the
53+ // token's expiry, so it ends).
54+ expires time.Time
55+ ranges []span // validated byte ranges: fully written, body checked
56+ active []span // ranges being written now; they never overlap each other
57+ // committing is set while the commit renames the file.
58+ committing bool
59+}
60+
61+// span is the half-open byte range [lo, hi).
62+type span struct{ lo, hi int64 }
63+
64+// add merges s into the sorted, disjoint list.
65+func addSpan(list []span, s span) []span {
66+ out := make([]span, 0, len(list)+1)
67+ placed := false
68+ for _, x := range list {
69+ switch {
70+ case x.hi < s.lo:
71+ out = append(out, x)
72+ case s.hi < x.lo:
73+ if !placed {
74+ out, placed = append(out, s), true
75+ }
76+ out = append(out, x)
77+ default: // overlap or touch
78+ s = span{min(s.lo, x.lo), max(s.hi, x.hi)}
79+ }
80+ }
81+ if !placed {
82+ out = append(out, s)
83+ }
84+ return out
85+}
86+
87+// subSpan removes s from the sorted, disjoint list.
88+func subSpan(list []span, s span) []span {
89+ var out []span
90+ for _, x := range list {
91+ if x.hi <= s.lo || s.hi <= x.lo {
92+ out = append(out, x)
93+ continue
94+ }
95+ if x.lo < s.lo {
96+ out = append(out, span{x.lo, s.lo})
97+ }
98+ if s.hi < x.hi {
99+ out = append(out, span{s.hi, x.hi})
100+ }
101+ }
102+ return out
103+}
104+
105+func overlaps(list []span, s span) bool {
106+ for _, x := range list {
107+ if x.lo < s.hi && s.lo < x.hi {
108+ return true
109+ }
110+ }
111+ return false
112+}
113+
114+// complete reports whether list covers exactly [0, size).
115+func complete(list []span, size int64) bool {
116+ return len(list) == 1 && list[0].lo == 0 && list[0].hi == size
117+}
118+
119+// Sentinel errors of the store, mapped to HTTP statuses by the handler.
120+var (
121+ errConflict = errors.New("a cache entry or reservation with this key and version exists in this ref")
122+ errTooBig = errors.New("the cache is larger than the limit of the cache service")
123+ errNoRoom = errors.New("no room in the cache: other uploads hold the space")
124+ errNotFound = errors.New("no such cache reservation")
125+ errDenied = errForbidden
126+ errBadSize = errors.New("the uploaded data does not match the size")
127+ errState = errors.New("the cache reservation is already being committed")
128+ errOverlap = errors.New("this range is being uploaded already")
129+ errTooMany = errors.New("too many cache uploads in progress")
130+)
131+
132+// Limits of the store (storeConfig zero values take these).
133+const (
134+ // DefaultMinCharge is charged against the budget for every committed entry
135+ // and reservation however small: a million one-byte entries are not free
136+ // (index, inodes). With the 10 GiB default it caps the entries at ~2.6M.
137+ DefaultMinCharge = 4 << 10
138+ // DefaultMaxPending bounds the uploads in progress, in all and per job.
139+ DefaultMaxPending = 256
140+ DefaultMaxPendingPerJob = 32
141+)
142+
143+type storeConfig struct {
144+ dir string
145+ max int64
146+ ttl time.Duration // idle life of a reservation that is never committed
147+ now func() time.Time
148+ minCharge int64
149+ maxPending int
150+ maxPerJob int
151+ // maxLife bounds the life of a reservation from its creation.
152+ maxLife time.Duration
153+ log zerowrap.Logger
154+}
155+
156+// store keeps the index and the files under dir.
157+//
158+// Budget: used (the charge of the committed entries) + held (the charge of the
159+// reservations) never exceeds max once admitted. Committing moves a
160+// reservation's charge to its entry, so it needs no room of its own. Entries
161+// with a download in progress (pins) are not evicted: their bytes cannot be
162+// reclaimed until the last reader is done, and reservations fail with errNoRoom
163+// meanwhile.
164+type store struct {
165+ storeConfig
166+
167+ mu sync.Mutex
168+ entries map[int64]*Entry
169+ pending map[int64]*reservation
170+ pins map[int64]int // open downloads per entry
171+ used int64
172+ held int64
173+ dirty bool // lastUsed changed since the index was written
174+}
175+
176+func (s *store) chargeOf(size int64) int64 { return max(size, s.minCharge) }
177+
178+const indexName = "index.json"
179+
180+func openStore(c storeConfig) (*store, error) {
181+ if c.minCharge <= 0 {
182+ c.minCharge = DefaultMinCharge
183+ }
184+ if c.maxPending <= 0 {
185+ c.maxPending = DefaultMaxPending
186+ }
187+ if c.maxPerJob <= 0 {
188+ c.maxPerJob = DefaultMaxPendingPerJob
189+ }
190+ if c.maxLife <= 0 {
191+ c.maxLife = DefaultReservationTTL
192+ }
193+ dir := c.dir
194+ if err := os.MkdirAll(filepath.Join(dir, "data"), 0o700); err != nil {
195+ return nil, fmt.Errorf("cache: %w", err)
196+ }
197+ s := &store{storeConfig: c, entries: map[int64]*Entry{}, pending: map[int64]*reservation{}, pins: map[int64]int{}}
198+ b, err := os.ReadFile(filepath.Join(dir, indexName))
199+ if err == nil {
200+ var list []*Entry
201+ if err := json.Unmarshal(b, &list); err != nil {
202+ // A corrupt index is not worth refusing to start for: the cache is a
203+ // cache. Start empty; the orphan files are removed below.
204+ list = nil
205+ }
206+ for _, e := range list {
207+ if st, err := os.Stat(s.file(e.ID)); err == nil && st.Mode().IsRegular() && st.Size() == e.Size && s.entries[e.ID] == nil {
208+ s.entries[e.ID] = e
209+ s.used += s.chargeOf(e.Size)
210+ }
211+ }
212+ } else if !errors.Is(err, os.ErrNotExist) {
213+ return nil, fmt.Errorf("cache: %w", err)
214+ }
215+ // Anything in data/ that the index does not know (temporary files of
216+ // reservations, files of entries lost from the index) goes.
217+ des, err := os.ReadDir(filepath.Join(dir, "data"))
218+ if err != nil {
219+ return nil, fmt.Errorf("cache: %w", err)
220+ }
221+ for _, de := range des {
222+ id, perr := strconv.ParseInt(strings.TrimSuffix(de.Name(), ".tmp"), 16, 64)
223+ if perr != nil || strings.HasSuffix(de.Name(), ".tmp") || s.entries[id] == nil {
224+ _ = os.Remove(filepath.Join(dir, "data", de.Name()))
225+ }
226+ }
227+ s.evictLocked(0)
228+ if err := s.saveLocked(); err != nil {
229+ return nil, err
230+ }
231+ return s, nil
232+}
233+
234+func (s *store) file(id int64) string {
235+ return filepath.Join(s.dir, "data", strconv.FormatInt(id, 16))
236+}
237+func (s *store) tmp(id int64) string { return s.file(id) + ".tmp" }
238+
239+// saveLocked writes the index atomically and durably: the temporary file is
240+// synced before the rename and the directory after it.
241+func (s *store) saveLocked() error {
242+ list := make([]*Entry, 0, len(s.entries))
243+ for _, e := range s.entries {
244+ list = append(list, e)
245+ }
246+ sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
247+ b, err := json.Marshal(list)
248+ if err != nil {
249+ return err
250+ }
251+ tmp := filepath.Join(s.dir, indexName+".tmp")
252+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
253+ if err != nil {
254+ return fmt.Errorf("cache index: %w", err)
255+ }
256+ _, werr := f.Write(b)
257+ if werr == nil {
258+ werr = f.Sync()
259+ }
260+ if cerr := f.Close(); werr == nil {
261+ werr = cerr
262+ }
263+ if werr != nil {
264+ _ = os.Remove(tmp)
265+ return fmt.Errorf("cache index: %w", werr)
266+ }
267+ if err := os.Rename(tmp, filepath.Join(s.dir, indexName)); err != nil {
268+ return fmt.Errorf("cache index: %w", err)
269+ }
270+ if err := syncDir(s.dir); err != nil {
271+ return fmt.Errorf("cache index: %w", err)
272+ }
273+ s.dirty = false
274+ return nil
275+}
276+
277+func syncDir(path string) error {
278+ d, err := os.Open(path)
279+ if err != nil {
280+ return err
281+ }
282+ err = d.Sync()
283+ if cerr := d.Close(); err == nil {
284+ err = cerr
285+ }
286+ return err
287+}
288+
289+// flush writes the index when only usage times changed.
290+func (s *store) flush() error {
291+ s.mu.Lock()
292+ defer s.mu.Unlock()
293+ if !s.dirty {
294+ return nil
295+ }
296+ return s.saveLocked()
297+}
298+
299+// newID is a random positive integer below 2^53: the toolkit reads cacheId as a
300+// JavaScript number, and ids that cannot be guessed keep one job from writing
301+// into another job's reservation.
302+func (s *store) newID() int64 {
303+ for {
304+ var b [8]byte
305+ _, _ = rand.Read(b[:])
306+ id := int64(binary.BigEndian.Uint64(b[:]) & (1<<53 - 1))
307+ if id == 0 || s.entries[id] != nil || s.pending[id] != nil {
308+ continue
309+ }
310+ return id
311+ }
312+}
313+
314+// expireLocked drops reservations that saw no activity for the ttl and have no
315+// chunk in flight.
316+func (s *store) expireLocked() {
317+ now := s.now()
318+ for _, r := range s.pending {
319+ if !r.committing && len(r.active) == 0 && (now.Sub(r.touched) > s.ttl || !now.Before(r.expires)) {
320+ s.dropLocked(r)
321+ }
322+ }
323+}
324+
325+func (s *store) dropLocked(r *reservation) {
326+ s.held -= r.charged
327+ delete(s.pending, r.id)
328+ _ = os.Remove(s.tmp(r.id))
329+}
330+
331+// evictLocked removes least recently used committed entries (not those being
332+// downloaded) until need more bytes fit next to what is used and held. It
333+// reports whether they fit and how many entries it removed.
334+func (s *store) evictLocked(need int64) (fits bool, evicted int) {
335+ if s.used+s.held+need <= s.max {
336+ return true, 0
337+ }
338+ list := make([]*Entry, 0, len(s.entries))
339+ for _, e := range s.entries {
340+ if s.pins[e.ID] == 0 {
341+ list = append(list, e)
342+ }
343+ }
344+ sort.Slice(list, func(i, j int) bool {
345+ if !list[i].LastUsed.Equal(list[j].LastUsed) {
346+ return list[i].LastUsed.Before(list[j].LastUsed)
347+ }
348+ return list[i].ID < list[j].ID
349+ })
350+ for _, e := range list {
351+ if s.used+s.held+need <= s.max {
352+ break
353+ }
354+ delete(s.entries, e.ID)
355+ s.used -= s.chargeOf(e.Size)
356+ _ = os.Remove(s.file(e.ID))
357+ evicted++
358+ // The index still names it until it is saved; a failed save leaves this
359+ // set so that the housekeeping tries again.
360+ s.dirty = true
361+ }
362+ return s.used+s.held+need <= s.max, evicted
363+}
364+
365+// expire drops the reservations that were idle for too long.
366+func (s *store) expire() {
367+ s.mu.Lock()
368+ defer s.mu.Unlock()
369+ s.expireLocked()
370+}
371+
372+// roomLocked makes need more bytes fit, evicting if it must, and writes the index when
373+// it evicted (the files are gone). Timestamps alone wait for the housekeeping.
374+func (s *store) roomLocked(need int64) bool {
375+ fits, n := s.evictLocked(need)
376+ if n > 0 {
377+ if err := s.saveLocked(); err != nil {
378+ s.log.Error().Err(err).Msg("cache index (after evicting); will retry")
379+ }
380+ }
381+ return fits
382+}
383+
384+// reserve starts an upload for (repo, ref, key, version). declared is the
385+// cacheSize of the client, 0 if none.
386+func (s *store) reserve(job, repo, ref, key, version string, declared int64, tokenExp time.Time) (int64, error) {
387+ s.mu.Lock()
388+ defer s.mu.Unlock()
389+ s.expireLocked()
390+ if declared > s.max {
391+ return 0, errTooBig
392+ }
393+ for _, e := range s.entries {
394+ if e.Repository == repo && e.Ref == ref && e.Key == key && e.Version == version {
395+ return 0, errConflict
396+ }
397+ }
398+ perJob := 0
399+ for _, r := range s.pending {
400+ if r.repo == repo && r.ref == ref && r.key == key && r.ver == version {
401+ return 0, errConflict
402+ }
403+ if r.job == job {
404+ perJob++
405+ }
406+ }
407+ // Before anything is created.
408+ if len(s.pending) >= s.maxPending || perJob >= s.maxPerJob {
409+ return 0, errTooMany
410+ }
411+ // A client that announces its size is charged for it now; one that does not
412+ // is charged as its chunks arrive (beginChunk).
413+ bound, charge := declared, s.chargeOf(declared)
414+ if declared == 0 {
415+ bound = s.max
416+ }
417+ if !s.roomLocked(charge) {
418+ return 0, errNoRoom
419+ }
420+ id := s.newID()
421+ f, err := os.OpenFile(s.tmp(id), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
422+ if err != nil {
423+ return 0, fmt.Errorf("cache: %w", err)
424+ }
425+ _ = f.Close()
426+ s.pending[id] = &reservation{id: id, job: job, repo: repo, ref: ref, key: key, ver: version, declared: declared, charged: charge, bound: bound, touched: s.now(), expires: s.reservationEnd(tokenExp)}
427+ s.held += charge
428+ return id, nil
429+}
430+
431+// reservationEnd is the absolute end of a reservation made now with a token that
432+// expires at tokenExp.
433+func (s *store) reservationEnd(tokenExp time.Time) time.Time {
434+ end := s.now().Add(s.maxLife)
435+ if tokenExp.Before(end) {
436+ end = tokenExp
437+ }
438+ return end
439+}
440+
441+// owned returns the reservation id if it belongs to this job: same job, same
442+// repository, and the ref is the job's write ref.
443+func (s *store) ownedLocked(id int64, c Claims) (*reservation, error) {
444+ r := s.pending[id]
445+ if r == nil {
446+ return nil, errNotFound
447+ }
448+ if r.job != c.JobID || r.repo != c.Repository || c.WriteRef == "" || r.ref != c.WriteRef {
449+ return nil, errDenied
450+ }
451+ if !s.now().Before(r.expires) {
452+ // Over: nothing more is accepted. It is dropped as soon as no chunk is in
453+ // flight (expireLocked).
454+ return nil, errNotFound
455+ }
456+ return r, nil
457+}
458+
459+// beginChunk admits a chunk [lo, hi) of reservation id and returns the path to
460+// write it to. The chunk is charged first: a reservation without a declared size
461+// is charged up to hi (evicting to make room, or failing with errNoRoom). The
462+// range must not be in flight already, and any coverage it had is removed: a
463+// retry that fails halfway must not leave the old bytes counted as valid.
464+// endChunk must follow.
465+func (s *store) beginChunk(id int64, c Claims, lo, hi int64) (string, error) {
466+ s.mu.Lock()
467+ defer s.mu.Unlock()
468+ s.expireLocked()
469+ r, err := s.ownedLocked(id, c)
470+ if err != nil {
471+ return "", err
472+ }
473+ if r.committing {
474+ return "", errState
475+ }
476+ if hi > r.bound {
477+ return "", errTooBig
478+ }
479+ sp := span{lo, hi}
480+ if overlaps(r.active, sp) {
481+ return "", errOverlap
482+ }
483+ if extra := s.chargeOf(hi) - r.charged; extra > 0 {
484+ if !s.roomLocked(extra) {
485+ return "", errNoRoom
486+ }
487+ r.charged += extra
488+ s.held += extra
489+ }
490+ r.ranges = subSpan(r.ranges, sp)
491+ r.active = append(r.active, sp)
492+ r.touched = s.now()
493+ return s.tmp(id), nil
494+}
495+
496+// endChunk ends the chunk begun by beginChunk; only when the whole body was
497+// written and checked does [lo, hi) count as received.
498+func (s *store) endChunk(id int64, lo, hi int64, written bool) {
499+ s.mu.Lock()
500+ defer s.mu.Unlock()
501+ r := s.pending[id]
502+ if r == nil {
503+ return
504+ }
505+ r.active = subSpan(r.active, span{lo, hi})
506+ r.touched = s.now()
507+ if written {
508+ r.ranges = addSpan(r.ranges, span{lo, hi})
509+ }
510+ // A reservation that ran out while this chunk was in flight is reclaimed now
511+ // that its last write is over.
512+ s.expireLocked()
513+}
514+
515+// commit makes reservation id an entry once exactly [0, size) was received.
516+func (s *store) commit(id int64, c Claims, size int64) error {
517+ s.mu.Lock()
518+ s.expireLocked()
519+ r, err := s.ownedLocked(id, c)
520+ if err != nil {
521+ s.mu.Unlock()
522+ return err
523+ }
524+ if r.committing || len(r.active) > 0 {
525+ s.mu.Unlock()
526+ return errState
527+ }
528+ if size <= 0 || (r.declared != 0 && size != r.declared) || !complete(r.ranges, size) {
529+ s.mu.Unlock()
530+ return errBadSize
531+ }
532+ r.committing = true
533+ s.mu.Unlock()
534+
535+ // The data is synced and the file is the right length before it takes its
536+ // final name, and the directory is synced before the index names the file:
537+ // readers only ever see whole entries, and the index never points at a file
538+ // that a crash can lose.
539+ err = syncTruncate(s.tmp(id), size)
540+ if err == nil {
541+ err = os.Rename(s.tmp(id), s.file(id))
542+ }
543+ if err == nil {
544+ err = syncDir(filepath.Join(s.dir, "data"))
545+ }
546+
547+ s.mu.Lock()
548+ defer s.mu.Unlock()
549+ s.dropPendingKeepFileLocked(r)
550+ if err != nil {
551+ _ = os.Remove(s.tmp(id))
552+ _ = os.Remove(s.file(id))
553+ return fmt.Errorf("cache: %w", err)
554+ }
555+ now := s.now()
556+ s.entries[id] = &Entry{ID: id, Repository: r.repo, Ref: r.ref, Key: r.key, Version: r.ver, Size: size, Created: now, LastUsed: now}
557+ // The reservation's charge becomes the entry's: size == hwm of the chunks.
558+ s.used += s.chargeOf(size)
559+ s.dirty = true // stays set if the save fails: the housekeeping retries
560+ return s.saveLocked()
561+}
562+
563+// dropPendingKeepFileLocked releases a reservation without touching its file.
564+func (s *store) dropPendingKeepFileLocked(r *reservation) {
565+ s.held -= r.charged
566+ delete(s.pending, r.id)
567+}
568+
569+func syncTruncate(path string, size int64) error {
570+ f, err := os.OpenFile(path, os.O_WRONLY, 0)
571+ if err != nil {
572+ return err
573+ }
574+ if err := f.Truncate(size); err != nil {
575+ _ = f.Close()
576+ return err
577+ }
578+ if err := f.Sync(); err != nil {
579+ _ = f.Close()
580+ return err
581+ }
582+ return f.Close()
583+}
584+
585+// find is the cache lookup, as GitHub documents it ("Matching a cache key"):
586+// the read refs are searched in priority order, and in each ref the keys in
587+// order: the first (primary) key exactly, the restore keys as an exact match or
588+// else the newest entry whose key starts with them. The match counts as a use.
589+func (s *store) find(repo string, refs []string, keys []string, version string) (Entry, bool) {
590+ s.mu.Lock()
591+ defer s.mu.Unlock()
592+ for _, ref := range refs {
593+ for i, k := range keys {
594+ var best *Entry
595+ for _, e := range s.entries {
596+ if e.Repository != repo || e.Ref != ref || e.Version != version {
597+ continue
598+ }
599+ if e.Key == k {
600+ best = e
601+ break
602+ }
603+ if i == 0 || !strings.HasPrefix(e.Key, k) {
604+ continue
605+ }
606+ if best == nil || e.Created.After(best.Created) || (e.Created.Equal(best.Created) && e.ID > best.ID) {
607+ best = e
608+ }
609+ }
610+ if best != nil {
611+ best.LastUsed = s.now()
612+ s.dirty = true
613+ return *best, true
614+ }
615+ }
616+ }
617+ return Entry{}, false
618+}
619+
620+// list returns the entries of the repository in the refs whose key starts with
621+// prefix, newest first.
622+func (s *store) list(repo string, refs []string, prefix string) []Entry {
623+ s.mu.Lock()
624+ defer s.mu.Unlock()
625+ var out []Entry
626+ for _, e := range s.entries {
627+ if e.Repository == repo && strings.HasPrefix(e.Key, prefix) && contains(refs, e.Ref) {
628+ out = append(out, *e)
629+ }
630+ }
631+ sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
632+ return out
633+}
634+
635+func contains(l []string, v string) bool {
636+ for _, x := range l {
637+ if x == v {
638+ return true
639+ }
640+ }
641+ return false
642+}
643+
644+// open returns the committed entry id for reading, counts it as a use and pins
645+// it until release is called: a pinned entry is not evicted.
646+func (s *store) open(id int64, repo string) (f *os.File, e Entry, release func(), err error) {
647+ s.mu.Lock()
648+ defer s.mu.Unlock()
649+ ent := s.entries[id]
650+ if ent == nil || ent.Repository != repo {
651+ return nil, Entry{}, nil, errNotFound
652+ }
653+ f, err = os.Open(s.file(id))
654+ if err != nil {
655+ return nil, Entry{}, nil, errNotFound
656+ }
657+ ent.LastUsed = s.now()
658+ s.dirty = true
659+ s.pins[id]++
660+ var once sync.Once
661+ return f, *ent, func() {
662+ once.Do(func() {
663+ s.mu.Lock()
664+ if s.pins[id]--; s.pins[id] <= 0 {
665+ delete(s.pins, id)
666+ }
667+ s.mu.Unlock()
668+ })
669+ }, nil
670+}
671+
672+// get returns a committed entry without counting a use.
673+func (s *store) get(id int64) (Entry, bool) {
674+ s.mu.Lock()
675+ defer s.mu.Unlock()
676+ if e := s.entries[id]; e != nil {
677+ return *e, true
678+ }
679+ return Entry{}, false
680+}
681+
682+// stats are for tests and logs.
683+func (s *store) stats() (entries int, used, held int64) {
684+ s.mu.Lock()
685+ defer s.mu.Unlock()
686+ return len(s.entries), s.used, s.held
687+}
688+
689+func (s *store) pendingCount() int {
690+ s.mu.Lock()
691+ defer s.mu.Unlock()
692+ return len(s.pending)
693+}
A internal/cachesvc/token.go +117 -0
@@ -0,0 +1,117 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "crypto/hmac"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "strconv"
14+ "strings"
15+ "time"
16+)
17+
18+// Scope is what a job may do in the cache, as GitHub scopes it: entries belong
19+// to a repository and a ref. A job writes to one ref only and reads from a
20+// list of refs in priority order (its own ref, then the base branch of a pull
21+// request, then the default branch).
22+//
23+// The scope of a job comes from Job.cache (see internal/host/cache.go, which
24+// checks it). An empty WriteRef makes the job read-only; no ReadRefs, write-only.
25+type Scope struct {
26+ Repository string `json:"r"`
27+ WriteRef string `json:"w,omitempty"`
28+ ReadRefs []string `json:"rd,omitempty"`
29+}
30+
31+// Claims are what a cache token vouches for.
32+type Claims struct {
33+ JobID string `json:"j"`
34+ Scope
35+ // Expiry is a Unix time in seconds.
36+ Expiry int64 `json:"x"`
37+}
38+
39+// errForbidden is every authentication failure: the caller learns nothing about
40+// why a token was refused.
41+var errForbidden = errors.New("cache: forbidden")
42+
43+// signer makes and checks the HMACs of the service: job tokens
44+// (ACTIONS_RUNTIME_TOKEN) and the signed archive URLs. The key is random, made
45+// at start and never written to disk: a restart invalidates every token, which
46+// is right since it also ends every job.
47+type signer struct{ key [32]byte }
48+
49+func newSigner() (*signer, error) {
50+ s := &signer{}
51+ if _, err := rand.Read(s.key[:]); err != nil {
52+ return nil, fmt.Errorf("cache: random key: %w", err)
53+ }
54+ return s, nil
55+}
56+
57+// mac separates the uses of the key by a prefix, so that a signature of one kind
58+// can never pass for another.
59+func (s *signer) mac(domain string, parts ...[]byte) []byte {
60+ h := hmac.New(sha256.New, s.key[:])
61+ h.Write([]byte(domain))
62+ for _, p := range parts {
63+ h.Write([]byte{0})
64+ h.Write(p)
65+ }
66+ return h.Sum(nil)
67+}
68+
69+var b64 = base64.RawURLEncoding
70+
71+// maxTokenLen bounds what verify parses.
72+const maxTokenLen = 16 << 10
73+
74+// token is base64url(JSON claims) "." base64url(HMAC-SHA256 of the JSON bytes).
75+func (s *signer) token(c Claims) string {
76+ payload, _ := json.Marshal(c) // plain strings and ints: cannot fail
77+ return b64.EncodeToString(payload) + "." + b64.EncodeToString(s.mac("token", payload))
78+}
79+
80+// verify returns the claims of a token that this runner made and that has not
81+// expired. The comparison is constant time.
82+func (s *signer) verify(tok string, now time.Time) (Claims, error) {
83+ var c Claims
84+ if len(tok) > maxTokenLen {
85+ return c, errForbidden
86+ }
87+ p, m, ok := strings.Cut(tok, ".")
88+ if !ok {
89+ return c, errForbidden
90+ }
91+ payload, err1 := b64.DecodeString(p)
92+ sum, err2 := b64.DecodeString(m)
93+ if err1 != nil || err2 != nil || !hmac.Equal(sum, s.mac("token", payload)) {
94+ return c, errForbidden
95+ }
96+ if err := json.Unmarshal(payload, &c); err != nil || c.Repository == "" || now.Unix() >= c.Expiry {
97+ return Claims{}, errForbidden
98+ }
99+ return c, nil
100+}
101+
102+// archiveSig signs the download URL of one entry. The archive is fetched by a
103+// client without the bearer token (downloadUtils.ts:168 uses a bare HttpClient),
104+// so the URL is the credential; it is bound to the entry, its repository and an
105+// expiry.
106+func (s *signer) archiveSig(id int64, repo string, exp int64) string {
107+ return b64.EncodeToString(s.mac("archive", []byte(strconv.FormatInt(id, 10)), []byte(repo), []byte(strconv.FormatInt(exp, 10))))
108+}
109+
110+func (s *signer) archiveOK(id int64, repo string, exp int64, sig string, now time.Time) bool {
111+ got, err := b64.DecodeString(sig)
112+ if err != nil || now.Unix() >= exp {
113+ return false
114+ }
115+ want, _ := b64.DecodeString(s.archiveSig(id, repo, exp))
116+ return hmac.Equal(got, want)
117+}
A internal/cachesvc/toolkit_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: MIT
2+
3+package cachesvc
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+// envSetupGoDist is the dist directory of a checkout of actions/setup-go
17+// (dist/setup/index.js and dist/cache-save/index.js bundle the real
18+// @actions/cache, 6.3.0 at the time of writing). TestSetupGoCacheRoundTrip runs
19+// them against the service: the protocol as the toolkit speaks it, not as the
20+// other tests of this package spell it. It needs node, tar, zstd and the
21+// checkout, and no network; without them the test is skipped.
22+const envSetupGoDist = "ERMITE_TEST_SETUP_GO_DIST"
23+
24+// TestSetupGoCacheRoundTrip is AC7's "second run of a setup-go cache: true
25+// workflow logs a cache hit and restores", with the real action code: run 1
26+// misses, the post step saves, run 2 hits and restores the files; jobs of
27+// another repository or of another ref miss.
28+func TestSetupGoCacheRoundTrip(t *testing.T) {
29+ dist := os.Getenv(envSetupGoDist)
30+ if dist == "" {
31+ t.Skip(envSetupGoDist + " is not set (a built checkout of actions/setup-go)")
32+ }
33+ for _, bin := range []string{"node", "tar", "zstd", "go", "sed"} {
34+ if _, err := exec.LookPath(bin); err != nil {
35+ t.Skip(bin + " not found")
36+ }
37+ }
38+ r := newRig(t, 1<<30)
39+ root := t.TempDir()
40+ work := filepath.Join(root, "ws")
41+ modcache, gocache := filepath.Join(root, "gomod"), filepath.Join(root, "gocache")
42+ for _, d := range []string{work, filepath.Join(modcache, "pkg"), gocache, filepath.Join(root, "tmp"), filepath.Join(root, "tc"), filepath.Join(root, "bin")} {
43+ if err := os.MkdirAll(d, 0o755); err != nil {
44+ t.Fatal(err)
45+ }
46+ }
47+ cached := map[string]string{
48+ filepath.Join(modcache, "pkg", "a.txt"): "module cache file\n",
49+ filepath.Join(gocache, "blob"): strings.Repeat("0123456789abcdef", 1<<16),
50+ }
51+ write := func() {
52+ t.Helper()
53+ for p, c := range cached {
54+ if err := os.WriteFile(p, []byte(c), 0o644); err != nil {
55+ t.Fatal(err)
56+ }
57+ }
58+ }
59+ remove := func() {
60+ for p := range cached {
61+ _ = os.Remove(p)
62+ }
63+ }
64+ if err := os.WriteFile(filepath.Join(work, "go.sum"), []byte("sum\n"), 0o644); err != nil {
65+ t.Fatal(err)
66+ }
67+ write()
68+
69+ // A "go" without the "-X:…" suffix that some distributions add to the
70+ // version: the setup-go key then has no ':' (a quirk of the local toolchain,
71+ // unrelated to the service).
72+ realGo, _ := exec.LookPath("go")
73+ shim := "#!/bin/sh\n" + realGo + " \"$@\" | sed 's/-X:[a-z0-9]*//g'\n"
74+ if err := os.WriteFile(filepath.Join(root, "bin", "go"), []byte(shim), 0o755); err != nil {
75+ t.Fatal(err)
76+ }
77+
78+ // step runs one entry point of the action as the job would run it.
79+ step := func(j *job, script string, extra ...string) (out, state, output string) {
80+ t.Helper()
81+ for _, f := range []string{"state", "output", "env", "path"} {
82+ if err := os.WriteFile(filepath.Join(root, f+".file"), nil, 0o644); err != nil {
83+ t.Fatal(err)
84+ }
85+ }
86+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
87+ defer cancel()
88+ cmd := exec.CommandContext(ctx, "node", filepath.Join(dist, script, "index.js"))
89+ cmd.Dir = work
90+ cmd.Env = append([]string{
91+ "PATH=" + filepath.Join(root, "bin") + ":" + os.Getenv("PATH"), "HOME=" + root,
92+ "GITHUB_SERVER_URL=http://forge.test", "GITHUB_ACTIONS=true", "GITHUB_WORKSPACE=" + work,
93+ "RUNNER_OS=Linux", "ImageOS=ubuntu24", "RUNNER_TEMP=" + filepath.Join(root, "tmp"), "RUNNER_TOOL_CACHE=" + filepath.Join(root, "tc"),
94+ // What the executor sets for node actions (executor.addRuntimeEnv).
95+ "ACTIONS_CACHE_URL=" + j.r.srv.URL + "/", "ACTIONS_RUNTIME_URL=" + j.r.srv.URL + "/", "ACTIONS_RUNTIME_TOKEN=" + j.token,
96+ "GITHUB_STATE=" + filepath.Join(root, "state.file"), "GITHUB_OUTPUT=" + filepath.Join(root, "output.file"),
97+ "GITHUB_ENV=" + filepath.Join(root, "env.file"), "GITHUB_PATH=" + filepath.Join(root, "path.file"),
98+ "GOMODCACHE=" + modcache, "GOCACHE=" + gocache, "GOPATH=" + filepath.Join(root, "gopath"),
99+ "INPUT_CACHE=true", "INPUT_CHECK-LATEST=false", "INPUT_GO-VERSION=", "INPUT_CACHE-DEPENDENCY-PATH=" + filepath.Join(work, "go.sum"),
100+ }, extra...)
101+ var buf bytes.Buffer
102+ cmd.Stdout, cmd.Stderr = &buf, &buf
103+ cmd.WaitDelay = 5 * time.Second
104+ // Run is synchronous; the output is read after it returns.
105+ if err := cmd.Run(); err != nil {
106+ t.Fatalf("%s: %v\n%s", script, err, buf.String())
107+ }
108+ st, _ := os.ReadFile(filepath.Join(root, "state.file"))
109+ op, _ := os.ReadFile(filepath.Join(root, "output.file"))
110+ return buf.String(), string(st), string(op)
111+ }
112+ // value reads "name<<delimiter\nvalue\ndelimiter" of a file command file.
113+ value := func(file, name string) string {
114+ lines := strings.Split(file, "\n")
115+ for i, l := range lines {
116+ if strings.HasPrefix(l, name+"<<") && i+1 < len(lines) {
117+ return lines[i+1]
118+ }
119+ }
120+ return ""
121+ }
122+
123+ a := r.job("run1", "bnema/app", main_, main_)
124+ out, state, output := step(a, "setup")
125+ if !strings.Contains(out, "Cache is not found") || value(output, "cache-hit") != "false" {
126+ t.Fatalf("run 1 must miss:\n%s", out)
127+ }
128+ key := value(state, "CACHE_KEY")
129+ if !strings.HasPrefix(key, "setup-go-Linux-") {
130+ t.Fatalf("state: %q", state)
131+ }
132+ out, _, _ = step(a, "cache-save", "STATE_CACHE_KEY="+key)
133+ if !strings.Contains(out, "Cache saved successfully") {
134+ t.Fatalf("save:\n%s", out)
135+ }
136+ if n, _, _ := r.svc.store.stats(); n != 1 {
137+ t.Fatalf("entries after save: %d", n)
138+ }
139+
140+ // Run 2: a new job of the same repository and ref, with the caches gone.
141+ remove()
142+ out, _, output = step(r.job("run2", "bnema/app", main_, main_), "setup")
143+ if !strings.Contains(out, "Cache restored from key: "+key) || value(output, "cache-hit") != "true" {
144+ t.Fatalf("run 2 must hit:\n%s", out)
145+ }
146+ for p, want := range cached {
147+ if got, err := os.ReadFile(p); err != nil || string(got) != want {
148+ t.Errorf("%s not restored (%v)", p, err)
149+ }
150+ }
151+
152+ // Another repository, and another ref that does not read main, restore nothing.
153+ for name, j := range map[string]*job{
154+ "another repository": r.job("run3", "bnema/other", main_, main_),
155+ "another ref": r.job("run4", "bnema/app", feat, feat),
156+ } {
157+ remove()
158+ out, _, output = step(j, "setup")
159+ if !strings.Contains(out, "Cache is not found") || value(output, "cache-hit") != "false" {
160+ t.Fatalf("%s must miss:\n%s", name, out)
161+ }
162+ if _, err := os.Stat(filepath.Join(gocache, "blob")); err == nil {
163+ t.Fatalf("%s: files were restored", name)
164+ }
165+ }
166+}
M internal/dnsfwd/dnsfwd.go +21 -11
@@ -122,18 +122,28 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error {
122122 return serr
123123 }
124124 }
125- pc, err := lc.ListenPacket(ctx, "udp", hp)
126- if err != nil {
127- return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err)
128- }
129- // With port 0 (tests) UDP and TCP must share the port the kernel picked.
130- if f.port == 0 {
131- hp = pc.LocalAddr().String()
132- }
133- tl, err := lc.Listen(ctx, "tcp", hp)
134- if err != nil {
125+ var (
126+ pc net.PacketConn
127+ tl net.Listener
128+ err error
129+ )
130+ // With port 0 (tests) UDP and TCP must share the port the kernel picked for
131+ // UDP, which a TCP socket may already hold: pick again a few times.
132+ for attempt := 0; ; attempt++ {
133+ bind := hp
134+ if pc, err = lc.ListenPacket(ctx, "udp", bind); err != nil {
135+ return fmt.Errorf("dnsfwd: listen udp %s: %w", bind, err)
136+ }
137+ if f.port == 0 {
138+ bind = pc.LocalAddr().String()
139+ }
140+ if tl, err = lc.Listen(ctx, "tcp", bind); err == nil {
141+ break
142+ }
135143 _ = pc.Close()
136- return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err)
144+ if f.port != 0 || attempt == 9 || !errors.Is(err, syscall.EADDRINUSE) {
145+ return fmt.Errorf("dnsfwd: listen tcp %s: %w", bind, err)
146+ }
137147 }
138148 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
139149 l := &listener{addr: addr, udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)}
M internal/execstream/exec.proto +10 -0
@@ -75,6 +75,16 @@ message JobSpec {
7575 // sends StepAnnotations and StepSummary frames only when they are set.
7676 bool send_annotations = 13;
7777 bool send_summaries = 14;
78+ // cache_port and cache_token, when set, are the runner's cache service for
79+ // this job: it listens on the sandbox's gateway address (the executor knows
80+ // it: ERMITE_GATEWAY) on that TCP port. The executor gives
81+ // ACTIONS_CACHE_URL=http://<gateway>:<port>/ and the token as
82+ // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official
83+ // runner does, and masks the token. The token is not an ermite credential: it
84+ // is an HMAC that only the runner's cache service accepts, scoped to this
85+ // job's repository and refs.
86+ uint32 cache_port = 15;
87+ string cache_token = 16;
7888 }
7989
8090 enum StreamKind {
M internal/execstream/execpb/exec.pb.go +32 -4 generated file: collapsed
A internal/executor/actions_cache_test.go +93 -0
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+const (
11+ cacheTestURL = "http://10.200.0.1:8089/"
12+ cacheTestToken = "eyJqIjoiMSJ9.c2lnbmF0dXJl"
13+)
14+
15+func withCache(c *Config) { c.CacheURL, c.CacheToken = cacheTestURL, cacheTestToken }
16+
17+const envAction = `name: e
18+description: d
19+runs:
20+ using: node20
21+ main: main.sh
22+`
23+
24+const envScript = "echo \"url=[$ACTIONS_CACHE_URL] rurl=[$ACTIONS_RUNTIME_URL] token=[$ACTIONS_RUNTIME_TOKEN] v2=[${ACTIONS_CACHE_SERVICE_V2-unset}] results=[${ACTIONS_RESULTS_URL-unset}]\"\n"
25+
26+// NodeScriptActionHandler.cs:55-72: a node action gets the runtime variables. The
27+// URL is the base the toolkit appends "_apis/artifactcache/…" to (it ends with a
28+// slash), ACTIONS_CACHE_SERVICE_V2 is never set, and the token is masked in the
29+// log.
30+func TestNodeActionGetsCacheEnv(t *testing.T) {
31+ rec, res := actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
32+ " - uses: ./.github/actions/e\n", withCache)
33+ if res.Conclusion != ConclusionSuccess {
34+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
35+ }
36+ got := logOf(rec.step(t, "Run ./.github/actions/e"))
37+ want := "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"
38+ if !strings.Contains(got, want) {
39+ t.Errorf("want %q in\n%s", want, got)
40+ }
41+ if strings.Contains(got, cacheTestToken) {
42+ t.Errorf("the token is in the log:\n%s", got)
43+ }
44+}
45+
46+// A run: step is not an action handler: it gets none of it (the official runner
47+// sets these for actions only), and a job without a cache service sets nothing.
48+func TestCacheEnvOnlyForActions(t *testing.T) {
49+ rec, res := actionRun(t, files{}, " - run: echo \"[$ACTIONS_CACHE_URL][$ACTIONS_RUNTIME_TOKEN]\"\n", withCache)
50+ if res.Conclusion != ConclusionSuccess {
51+ t.Fatalf("%+v", res)
52+ }
53+ if got := logOf(rec.step(t, "Run echo \"[$ACTIONS_CACHE_URL][$ACTIONS_RUNTIME_TOKEN]\"")); !strings.HasSuffix(strings.TrimRight(got, "\n"), "\n[][]") {
54+ t.Errorf("run step env:\n%s", got)
55+ }
56+ rec, res = actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
57+ " - uses: ./.github/actions/e\n", nil)
58+ if res.Conclusion != ConclusionSuccess {
59+ t.Fatalf("%+v", res)
60+ }
61+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "url=[] rurl=[] token=[]") {
62+ t.Errorf("no cache service, no variables:\n%s", got)
63+ }
64+}
65+
66+// ContainerActionHandler.cs:219-236: a docker action gets them too, passed by
67+// name to "docker run" like the other variables.
68+func TestDockerActionGetsCacheEnv(t *testing.T) {
69+ d, lf := fakeDocker(t, 0)
70+ rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
71+ c.Actions.DockerPath = d
72+ withCache(c)
73+ })
74+ if res.Conclusion != ConclusionSuccess {
75+ t.Fatalf("%+v %v", res, rec.names())
76+ }
77+ cs := calls(t, lf)
78+ if len(cs) != 2 {
79+ t.Fatalf("calls %v", cs)
80+ }
81+ args := "\n" + strings.Join(cs[1], "\n") + "\n"
82+ for _, n := range []string{"ACTIONS_CACHE_URL", "ACTIONS_RUNTIME_URL", "ACTIONS_RUNTIME_TOKEN"} {
83+ if !strings.Contains(args, "\n-e\n"+n+"\n") {
84+ t.Errorf("docker run lacks -e %s:\n%s", n, args)
85+ }
86+ }
87+ if strings.Contains(args, "ACTIONS_CACHE_SERVICE_V2") || strings.Contains(args, cacheTestToken) {
88+ t.Errorf("service v2 or the token value on the command line:\n%s", args)
89+ }
90+ if run := logOf(rec.steps[2]); strings.Contains(run, cacheTestToken) {
91+ t.Errorf("the token is in the log:\n%s", run)
92+ }
93+}
M internal/executor/actions_docker.go +1 -0
@@ -104,6 +104,7 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
104104 v, _ := in.Get(k)
105105 env.Set(inputEnvName(k), v)
106106 }
107+ j.addRuntimeEnv(env)
107108 j.mu.Lock()
108109 workspace := j.state.GitHub("workspace")
109110 temp := j.state.Layout().Temp
M internal/executor/actions_node.go +20 -0
@@ -151,6 +151,25 @@ func (j *job) externalsDir() string {
151151 return defaultExternalsDir
152152 }
153153
154+// addRuntimeEnv is the "Add Actions Runtime server info" part of the handlers
155+// (NodeScriptActionHandler.cs:55-72, ContainerActionHandler.cs:219-236): the
156+// variables @actions/cache reads. ermite has no Actions runtime service, only
157+// the cache of the runner, so only ACTIONS_CACHE_URL (the base of
158+// "_apis/artifactcache/…": the toolkit appends to it, cacheHttpClient.ts:47, so
159+// it ends with a slash), ACTIONS_RUNTIME_URL (same value, for clients that
160+// require the variable) and ACTIONS_RUNTIME_TOKEN are set. ACTIONS_CACHE_SERVICE_V2
161+// is never set: the toolkit would then ignore ACTIONS_CACHE_URL (config.ts:42-58)
162+// and, on a server URL that is not github.com, uses v1 anyway (config.ts:22-27).
163+// Without a cache service in the job nothing is set.
164+func (j *job) addRuntimeEnv(env *ordered) {
165+ if j.cfg.CacheURL == "" || j.cfg.CacheToken == "" {
166+ return
167+ }
168+ env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)
169+ env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
170+ env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)
171+}
172+
154173 // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
155174 // 27-196) on the host of the steps (a job container is phase H): INPUT_
156175 // variables and the PATH of the previous steps in the environment, the
@@ -161,6 +180,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
161180 v, _ := in.Get(k)
162181 env.Set(inputEnvName(k), v)
163182 }
183+ j.addRuntimeEnv(env)
164184 // The PATH of the previous steps is added by procEnv.
165185 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
166186 r := def.runs()
M internal/executor/job.go +5 -0
@@ -74,6 +74,10 @@ type Config struct {
7474 ToolCache string
7575 // BaseEnv is the environment steps inherit; nil means os.Environ().
7676 BaseEnv []string
77+ // CacheURL and CacheToken, when set, are ACTIONS_CACHE_URL and
78+ // ACTIONS_RUNTIME_TOKEN of the node and docker actions of the job: the cache
79+ // service of the runner. The token is masked in the log.
80+ CacheURL, CacheToken string
7781 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
7882 Debug bool
7983 // Actions configures the actions of the job (resolution, downloads, Node.js,
@@ -448,6 +452,7 @@ func (j *job) setUp() ([]qstep, error) {
448452 }
449453 j.state = state
450454 j.masker.Store(newMasker(state.Secrets()...))
455+ j.masker.Load().add(cfg.CacheToken)
451456
452457 // Features that arrive in later phases fail the job with a clear message.
453458 switch {
A internal/guest/cacheurl_test.go +23 -0
@@ -0,0 +1,23 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import "testing"
6+
7+func TestCacheURL(t *testing.T) {
8+ for _, tc := range []struct {
9+ gw string
10+ port uint32
11+ want string
12+ }{
13+ // The toolkit appends "_apis/artifactcache/…" to this: the slash is needed.
14+ {"10.200.0.1", 8089, "http://10.200.0.1:8089/"},
15+ {"", 8089, ""},
16+ {"10.200.0.1", 0, ""},
17+ {"10.200.0.1", 70000, ""},
18+ } {
19+ if got := cacheURL(tc.gw, tc.port); got != tc.want {
20+ t.Errorf("cacheURL(%q, %d) = %q, want %q", tc.gw, tc.port, got, tc.want)
21+ }
22+ }
23+}
M internal/guest/init.go +7 -2
@@ -181,8 +181,13 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin
181181 }
182182 }
183183
184- execPid, err := spawn(cfg.ExecArgs, append(os.Environ(),
185- "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version))
184+ execEnv := append(os.Environ(), "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)
185+ if gw := args[sandbox.CmdlineGateway]; gw != "" {
186+ // A VM learns its gateway from the kernel command line; a container is
187+ // given it in its environment (the container driver).
188+ execEnv = append(execEnv, "ERMITE_GATEWAY="+gw)
189+ }
190+ execPid, err := spawn(cfg.ExecArgs, execEnv)
186191 if err != nil {
187192 log.Error().Err(err).Msg("init: start executor")
188193 stopDockerd(dockerPid)
M internal/guest/jobexec.go +12 -0
@@ -5,6 +5,7 @@ package guest
55 import (
66 "context"
77 "fmt"
8+ "net"
89 "os"
910 "os/user"
1011 "strconv"
@@ -256,6 +257,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
256257 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
257258 RunnerVersion: spec.GetRunnerVersion(),
258259 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
260+ CacheURL: cacheURL(os.Getenv("ERMITE_GATEWAY"), spec.GetCachePort()), CacheToken: spec.GetCacheToken(),
259261 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
260262 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
261263 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
@@ -266,6 +268,16 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
266268 return 1
267269 }
268270
271+// cacheURL is ACTIONS_CACHE_URL: the cache service of the runner on the gateway
272+// of the sandbox. The trailing slash matters: the toolkit appends "_apis/…"
273+// to it (cacheHttpClient.ts:47). Empty when the job has no cache service.
274+func cacheURL(gateway string, port uint32) string {
275+ if gateway == "" || port == 0 || port > 65535 {
276+ return ""
277+ }
278+ return "http://" + net.JoinHostPort(gateway, strconv.FormatUint(uint64(port), 10)) + "/"
279+}
280+
269281 // failSetup ends a job that cannot start, like a failed "Set up job": step 1
270282 // starts, logs the reason as an error, and fails; the job result is a failure.
271283 func failSetup(conn *execstream.Conn, msg string) int {
A internal/host/cache.go +127 -0
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "encoding/json"
7+ "fmt"
8+ "strings"
9+ "time"
10+
11+ ermite "bnema.dev/ermite-api/gen/ermite"
12+
13+ "bnema.dev/ermite-runner/internal/cachesvc"
14+)
15+
16+// cacheTokenMargin is added to the job timeout for the life of the cache token:
17+// the archive URLs of a lookup expire with it.
18+const cacheTokenMargin = 15 * time.Minute
19+
20+// maxCacheReadRefs bounds Job.cache.read_refs (own ref, base branch, default
21+// branch, with room to spare).
22+const maxCacheReadRefs = 8
23+
24+// cacheScope is what the job may do in the cache of the runner.
25+//
26+// The server says (Job.cache, feature "ci.cache"): the repository, the one ref
27+// the job may write and the refs it may read, most specific first. It is used
28+// as given once checked: the repository must be the job's own (the
29+// "owner/name" of the github context, or its name alone, which is how
30+// Job.cache documents it) and the scope always carries the canonical
31+// "owner/name", a ref must be a full "refs/..." name, empty refs
32+// are dropped, and there are at most maxCacheReadRefs read refs. An empty
33+// write_ref makes the job read-only. A scope that fails the checks is an error:
34+// the job runs without a cache.
35+//
36+// A server that sends no Job.cache (an old server) gets the narrowest scope the
37+// job's own fields give: the repository of the github context and the ref that
38+// triggered the run, for reading and writing, with no fallback to the base or
39+// default branch. A job without a repository or a ref has no cache (ok false,
40+// no error).
41+func cacheScope(job *ermite.Job, contexts string) (sc cachesvc.Scope, ok bool, err error) {
42+ repo := githubRepository(contexts)
43+ jc := job.GetCache()
44+ if jc == nil {
45+ if repo == "" || job.Ref == "" {
46+ return cachesvc.Scope{}, false, nil
47+ }
48+ return cachesvc.Scope{Repository: repo, WriteRef: job.Ref, ReadRefs: []string{job.Ref}}, true, nil
49+ }
50+ name := repo[strings.LastIndex(repo, "/")+1:]
51+ if jc.Repository == "" || repo == "" || (jc.Repository != repo && jc.Repository != name) {
52+ return cachesvc.Scope{}, false, fmt.Errorf("the cache repository %q is not the job's repository %q", jc.Repository, repo)
53+ }
54+ // Always the job's canonical "owner/name", whichever way the server spelled
55+ // it: entries of two repositories with the same name under different owners
56+ // must never share a scope.
57+ sc.Repository = repo
58+ if w := jc.WriteRef; w != "" {
59+ if !fullRef(w) {
60+ return cachesvc.Scope{}, false, fmt.Errorf("the cache write ref %q is not a full ref name", w)
61+ }
62+ sc.WriteRef = w
63+ }
64+ for _, r := range jc.ReadRefs {
65+ if r == "" {
66+ continue
67+ }
68+ if !fullRef(r) {
69+ return cachesvc.Scope{}, false, fmt.Errorf("the cache read ref %q is not a full ref name", r)
70+ }
71+ sc.ReadRefs = append(sc.ReadRefs, r)
72+ }
73+ if len(sc.ReadRefs) > maxCacheReadRefs {
74+ return cachesvc.Scope{}, false, fmt.Errorf("%d cache read refs, at most %d", len(sc.ReadRefs), maxCacheReadRefs)
75+ }
76+ if sc.WriteRef == "" && len(sc.ReadRefs) == 0 {
77+ return cachesvc.Scope{}, false, nil // allowed to do nothing
78+ }
79+ return sc, true, nil
80+}
81+
82+// fullRef reports whether s is a full ref name: "refs/" and something after it,
83+// without control characters or spaces.
84+func fullRef(s string) bool {
85+ if !strings.HasPrefix(s, "refs/") || len(s) == len("refs/") || len(s) > 1024 {
86+ return false
87+ }
88+ for _, r := range s {
89+ if r <= ' ' || r == 0x7f {
90+ return false
91+ }
92+ }
93+ return true
94+}
95+
96+// githubRepository is github.repository ("owner/name") of the contexts JSON.
97+func githubRepository(contexts string) string {
98+ var c struct {
99+ Github struct {
100+ Repository string `json:"repository"`
101+ } `json:"github"`
102+ }
103+ if json.Unmarshal([]byte(contexts), &c) != nil {
104+ return ""
105+ }
106+ return c.Github.Repository
107+}
108+
109+// cacheFor returns the cache port and the token of a job, or 0 and "" when the
110+// runner serves no cache or the job has no scope.
111+func (j *jobRun) cacheFor(rt *Runner) (port uint32, token string) {
112+ if rt.Cache == nil || rt.CachePort == 0 {
113+ return 0, ""
114+ }
115+ sc, ok, err := cacheScope(j.job, j.contexts)
116+ if err != nil {
117+ // A bad scope from the server is not the job's failure: it runs without a
118+ // cache.
119+ j.log.Warn().Err(err).Msg("cache disabled for the job")
120+ return 0, ""
121+ }
122+ if !ok {
123+ return 0, ""
124+ }
125+ ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126+ return uint32(rt.CachePort), rt.Cache.Token(j.job.JobId, sc, ttl)
127+}
A internal/host/cache_test.go +187 -0
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "bytes"
7+ "net/http"
8+ "net/http/httptest"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ ermite "bnema.dev/ermite-api/gen/ermite"
16+
17+ "bnema.dev/ermite-runner/internal/cachesvc"
18+)
19+
20+const scopeCtx = `{"github":{"repository":"o/r"}}`
21+
22+func TestCacheScopeFromJobCache(t *testing.T) {
23+ main, pr := "refs/heads/main", "refs/pull/7/merge"
24+ for name, tc := range map[string]struct {
25+ jc *ermite.JobCache
26+ write string
27+ reads []string
28+ }{
29+ "pull request": {&ermite.JobCache{Repository: "o/r", WriteRef: pr, ReadRefs: []string{pr, "refs/heads/dev", main}}, pr, []string{pr, "refs/heads/dev", main}},
30+ // Job.cache documents the repository as its name.
31+ "name only": {&ermite.JobCache{Repository: "r", WriteRef: main, ReadRefs: []string{main}}, main, []string{main}},
32+ "read-only": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{main}}, "", []string{main}},
33+ "empties dropped": {&ermite.JobCache{Repository: "o/r", WriteRef: main, ReadRefs: []string{"", main, ""}}, main, []string{main}},
34+ "write-only": {&ermite.JobCache{Repository: "o/r", WriteRef: main}, main, nil},
35+ } {
36+ // The server's scope wins over Job.Ref.
37+ sc, ok, err := cacheScope(&ermite.Job{Ref: "refs/heads/other", Cache: tc.jc}, scopeCtx)
38+ if err != nil || !ok || sc.Repository != "o/r" || sc.WriteRef != tc.write || strings.Join(sc.ReadRefs, ",") != strings.Join(tc.reads, ",") {
39+ t.Errorf("%s: %v %v %+v", name, ok, err, sc)
40+ }
41+ }
42+ // Nothing allowed: no cache, no error.
43+ if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "o/r", ReadRefs: []string{""}}}, scopeCtx); ok || err != nil {
44+ t.Errorf("empty scope: %v %v", ok, err)
45+ }
46+}
47+
48+// A name alone and owner/name are the same repository, and the scope is always
49+// owner/name: two repositories with the same name under different owners never
50+// share entries.
51+func TestCacheScopeCanonicalRepository(t *testing.T) {
52+ ref := "refs/heads/main"
53+ ctxA, ctxB := `{"github":{"repository":"a/r"}}`, `{"github":{"repository":"b/r"}}`
54+ var got []string
55+ for _, tc := range []struct{ repo, ctx string }{{"r", ctxA}, {"a/r", ctxA}, {"r", ctxB}, {"b/r", ctxB}} {
56+ sc, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: tc.repo, WriteRef: ref, ReadRefs: []string{ref}}}, tc.ctx)
57+ if err != nil || !ok {
58+ t.Fatalf("%+v: %v %v", tc, ok, err)
59+ }
60+ got = append(got, sc.Repository)
61+ }
62+ if strings.Join(got, ",") != "a/r,a/r,b/r,b/r" {
63+ t.Fatalf("scopes %v", got)
64+ }
65+ // A spelling that names the other owner is refused.
66+ if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "b/r", WriteRef: ref}}, ctxA); ok || err == nil {
67+ t.Fatal("b/r accepted for a/r")
68+ }
69+}
70+
71+func TestCacheScopeInvalid(t *testing.T) {
72+ main := "refs/heads/main"
73+ nine := make([]string, 9)
74+ for i := range nine {
75+ nine[i] = "refs/heads/b" + string(rune('0'+i))
76+ }
77+ for name, tc := range map[string]struct {
78+ jc *ermite.JobCache
79+ ctx string
80+ }{
81+ "other repository": {&ermite.JobCache{Repository: "o/other", WriteRef: main}, scopeCtx},
82+ "other owner": {&ermite.JobCache{Repository: "x/r", WriteRef: main}, scopeCtx},
83+ "empty repository": {&ermite.JobCache{WriteRef: main}, scopeCtx},
84+ "job without repo": {&ermite.JobCache{Repository: "o/r", WriteRef: main}, `{}`},
85+ "short write ref": {&ermite.JobCache{Repository: "o/r", WriteRef: "main"}, scopeCtx},
86+ "short read ref": {&ermite.JobCache{Repository: "o/r", WriteRef: main, ReadRefs: []string{main, "dev"}}, scopeCtx},
87+ "bare refs/": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{"refs/"}}, scopeCtx},
88+ "space in ref": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{"refs/heads/a b"}}, scopeCtx},
89+ "nine read refs": {&ermite.JobCache{Repository: "o/r", ReadRefs: nine}, scopeCtx},
90+ } {
91+ if sc, ok, err := cacheScope(&ermite.Job{Ref: main, Cache: tc.jc}, tc.ctx); ok || err == nil {
92+ t.Errorf("%s: accepted %+v", name, sc)
93+ }
94+ }
95+ // Eight are fine.
96+ if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "o/r", ReadRefs: nine[:8]}}, scopeCtx); !ok || err != nil {
97+ t.Errorf("eight read refs: %v %v", ok, err)
98+ }
99+}
100+
101+// An old server sends no Job.cache: the repository and Job.ref, both ways.
102+func TestCacheScopeFallback(t *testing.T) {
103+ sc, ok, err := cacheScope(&ermite.Job{Ref: "refs/heads/main"}, scopeCtx)
104+ if err != nil || !ok || sc.Repository != "o/r" || sc.WriteRef != "refs/heads/main" || len(sc.ReadRefs) != 1 || sc.ReadRefs[0] != "refs/heads/main" {
105+ t.Fatalf("%v %v %+v", ok, err, sc)
106+ }
107+ // No repository or no ref: no cache, rather than a scope that could be
108+ // shared by unrelated jobs.
109+ for name, tc := range map[string]struct {
110+ job *ermite.Job
111+ ctx string
112+ }{
113+ "no ref": {&ermite.Job{}, scopeCtx},
114+ "no repository": {&ermite.Job{Ref: "refs/heads/main"}, `{"github":{}}`},
115+ "bad contexts": {&ermite.Job{Ref: "refs/heads/main"}, `nope`},
116+ "no github": {&ermite.Job{Ref: "refs/heads/main"}, `{}`},
117+ } {
118+ if _, ok, err := cacheScope(tc.job, tc.ctx); ok || err != nil {
119+ t.Errorf("%s: %v %v", name, ok, err)
120+ }
121+ }
122+}
123+
124+// A mismatched scope disables the cache of the job, with a log line; the job
125+// goes on.
126+func TestCacheForMismatchDisablesCache(t *testing.T) {
127+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ t.Cleanup(func() { _ = svc.CloseAll() })
132+ var logs bytes.Buffer
133+ j := &jobRun{
134+ job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
135+ contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),
136+ }
137+ if port, tok := j.cacheFor(&Runner{Cache: svc, CachePort: 8089}); port != 0 || tok != "" {
138+ t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
139+ }
140+ if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {
141+ t.Fatalf("log: %q", logs.String())
142+ }
143+}
144+
145+func TestCacheForNeedsServiceAndScope(t *testing.T) {
146+ j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
147+ if port, tok := j.cacheFor(&Runner{}); port != 0 || tok != "" {
148+ t.Fatalf("no service: %d %q", port, tok)
149+ }
150+}
151+
152+// The token of a job is the one the service accepts, for the job's repository
153+// and ref only.
154+func TestCacheForToken(t *testing.T) {
155+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ t.Cleanup(func() { _ = svc.CloseAll() })
160+ srv := httptest.NewServer(svc.Handler())
161+ defer srv.Close()
162+ rt := &Runner{Cache: svc, CachePort: 8089}
163+ j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
164+ port, tok := j.cacheFor(rt)
165+ if port != 8089 || tok == "" {
166+ t.Fatalf("%d %q", port, tok)
167+ }
168+ do := func(token string) int {
169+ req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`))
170+ if token != "" {
171+ req.Header.Set("Authorization", "Bearer "+token)
172+ }
173+ resp, err := http.DefaultClient.Do(req)
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ _ = resp.Body.Close()
178+ return resp.StatusCode
179+ }
180+ if c := do(tok); c != http.StatusCreated {
181+ t.Fatalf("reserve with the job's token: %d", c)
182+ }
183+ // The job token of ermite is not a cache token, nor the other way round.
184+ if c := do("ermitejob_0123456789"); c != http.StatusForbidden {
185+ t.Fatalf("foreign token: %d", c)
186+ }
187+}
M internal/host/job.go +1 -0
@@ -691,6 +691,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
691691 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
692692 SendAnnotations: j.features.annotations, SendSummaries: j.features.summaries,
693693 }}
694+ run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)
694695 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
695696 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
696697 Log: j.logLine,
M internal/host/runner.go +7 -0
@@ -19,6 +19,7 @@ import (
1919 ermite "bnema.dev/ermite-api/gen/ermite"
2020 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
2121
22+ "bnema.dev/ermite-runner/internal/cachesvc"
2223 "bnema.dev/ermite-runner/internal/execstream/execpb"
2324 "bnema.dev/ermite-runner/internal/sandbox"
2425 )
@@ -36,6 +37,12 @@ type Runner struct {
3637 // step summaries are sent only when it says "ci.annotations" and
3738 // "ci.summaries". Nil means neither is sent.
3839 Features ServerFeatures
40+ // Cache, when not nil, is the cache service of the runner; the sandbox
41+ // drivers serve it on CachePort of each sandbox's gateway (see
42+ // sandbox.Config.Cache), and each job gets a token scoped to its repository
43+ // and refs.
44+ Cache *cachesvc.Service
45+ CachePort uint16
3946
4047 // now returns the current time; tests replace it.
4148 now func() time.Time
M internal/netpolicy/netpolicy.go +16 -0
@@ -16,6 +16,9 @@
1616 // only): TCP from the guest's source address to its own gateway address on
1717 // that one port. The host's own listener on that port receives the traffic;
1818 // nothing is proxied, and every other host port stays closed;
19+// - optionally (Guest.CachePort, when the runner serves the GitHub Actions
20+// cache): the same, for the cache service port; that port of a gateway is
21+// also closed to every other interface;
1922 // - IPv6 from the guest is dropped (the guest also disables IPv6);
2023 // - forwarded traffic must carry the guest's own source address;
2124 // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays),
@@ -82,6 +85,11 @@ type Guest struct {
8285 // guest's interface, source and gateway, so it neither reaches other
8386 // addresses of the host nor other guests.
8487 ForgePort uint16
88+ // CachePort, when not 0, is the port of the runner's cache service on
89+ // Gateway (package cachesvc). Same scope as ForgePort, and in addition the
90+ // port is dropped from every other interface, as port 53 is: the service
91+ // answers the guest of that gateway only.
92+ CachePort uint16
8593 }
8694
8795 // Validate checks the fields used in generated rules.
@@ -160,6 +168,11 @@ func Ruleset(guests []Guest) string {
160168 fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface)
161169 }
162170 }
171+ for _, g := range gs {
172+ if g.CachePort != 0 {
173+ fmt.Fprintf(&b, "\t\tip daddr %s tcp dport %d iifname != \"%s\" drop\n", g.Gateway, g.CachePort, g.Iface)
174+ }
175+ }
163176 for _, g := range gs {
164177 for _, proto := range []string{"udp", "tcp"} {
165178 fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto)
@@ -169,6 +182,9 @@ func Ruleset(guests []Guest) string {
169182 // which this table does not touch.
170183 fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.ForgePort)
171184 }
185+ if g.CachePort != 0 {
186+ fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.CachePort)
187+ }
172188 fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface)
173189 }
174190 // An interface with our prefix that no live guest owns (a leftover) is
M internal/netpolicy/netpolicy_test.go +45 -0
@@ -292,3 +292,48 @@ func TestGuestForgeRuleScopeAndOrder(t *testing.T) {
292292 }
293293 }
294294 }
295+
296+func cacheGuest(t *testing.T, iface string, slot int, forge, cache uint16) Guest {
297+ t.Helper()
298+ g := guest(t, iface, slot)
299+ g.ForgePort, g.CachePort = forge, cache
300+ return g
301+}
302+
303+func TestRulesetCacheGolden(t *testing.T) {
304+ golden(t, "cache.nft", Ruleset([]Guest{cacheGuest(t, "ertap0", 0, 8088, 8089), cacheGuest(t, "erbr1", 1, 0, 8089)}))
305+}
306+
307+// The cache port is open to the guest's own source and gateway only, closed to
308+// every other interface, and a runner without the cache has no such rule
309+// (one.nft and guest_forge.nft are unchanged).
310+func TestCachePortScope(t *testing.T) {
311+ rs := Ruleset([]Guest{cacheGuest(t, "ertap0", 0, 0, 8089), cacheGuest(t, "erbr1", 1, 0, 8089), guest(t, "ertap2", 2)})
312+ in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")]
313+ for _, want := range []string{
314+ `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8089 accept`,
315+ `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8089 accept`,
316+ `ip daddr 10.200.0.1 tcp dport 8089 iifname != "ertap0" drop`,
317+ `ip daddr 10.200.0.5 tcp dport 8089 iifname != "erbr1" drop`,
318+ } {
319+ if strings.Count(in, want) != 1 {
320+ t.Errorf("want exactly one %q in the input chain:\n%s", want, in)
321+ }
322+ }
323+ if strings.Count(rs, "8089") != 4 {
324+ t.Errorf("port 8089 appears %d times, want 4:\n%s", strings.Count(rs, "8089"), rs)
325+ }
326+ // Drops of other interfaces come before every accept, accepts before the
327+ // guest's catch-all drop.
328+ if strings.LastIndex(in, `iifname != "erbr1" drop`) > strings.Index(in, `dport 53 accept`) {
329+ t.Error("the cross-interface drops must precede the accepts")
330+ }
331+ if strings.Index(in, `tcp dport 8089 accept`) > strings.Index(in, `iifname "ertap0" drop`) {
332+ t.Error("the cache accept must precede the guest's catch-all drop")
333+ }
334+ plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)})
335+ tail := func(s string) string { return s[strings.Index(s, "chain forward"):] }
336+ if tail(rs) != tail(plain) {
337+ t.Error("the forward/postrouting chains changed with the cache port")
338+ }
339+}
A internal/netpolicy/testdata/cache.nft +52 -0
@@ -0,0 +1,52 @@
1+table inet ermite_runner
2+delete table inet ermite_runner
3+table inet ermite_runner {
4+ set blocked_v4 {
5+ type ipv4_addr
6+ flags interval
7+ elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 }
8+ }
9+ chain input {
10+ type filter hook input priority filter; policy accept;
11+ iifname "ertap*" meta nfproto ipv6 drop
12+ iifname "erbr*" meta nfproto ipv6 drop
13+ ip daddr 10.200.0.5 udp dport 53 iifname != "erbr1" drop
14+ ip daddr 10.200.0.5 tcp dport 53 iifname != "erbr1" drop
15+ ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop
16+ ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop
17+ ip daddr 10.200.0.5 tcp dport 8089 iifname != "erbr1" drop
18+ ip daddr 10.200.0.1 tcp dport 8089 iifname != "ertap0" drop
19+ iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 udp dport 53 accept
20+ iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 53 accept
21+ iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8089 accept
22+ iifname "erbr1" drop
23+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept
24+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept
25+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept
26+ iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8089 accept
27+ iifname "ertap0" drop
28+ iifname "ertap*" drop
29+ iifname "erbr*" drop
30+ }
31+ chain forward {
32+ type filter hook forward priority filter; policy accept;
33+ iifname "ertap*" meta nfproto ipv6 drop
34+ iifname "erbr*" meta nfproto ipv6 drop
35+ iifname "erbr1" ip saddr != 10.200.0.6 drop
36+ iifname "erbr1" ip daddr @blocked_v4 drop
37+ iifname "ertap0" ip saddr != 10.200.0.2 drop
38+ iifname "ertap0" ip daddr @blocked_v4 drop
39+ iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept
40+ oifname "ertap*" drop
41+ oifname "erbr*" drop
42+ iifname "erbr1" accept
43+ iifname "ertap0" accept
44+ iifname "ertap*" drop
45+ iifname "erbr*" drop
46+ }
47+ chain postrouting {
48+ type nat hook postrouting priority srcnat; policy accept;
49+ ip saddr 10.200.0.6 oifname != "erbr1" masquerade
50+ ip saddr 10.200.0.2 oifname != "ertap0" masquerade
51+ }
52+}
M internal/sandbox/config.go +7 -0
@@ -11,6 +11,8 @@ import (
1111 "runtime"
1212 "strconv"
1313 "strings"
14+
15+ "bnema.dev/ermite-runner/internal/cachesvc"
1416 )
1517
1618 // Environment variables read by LoadConfig. The other ERMITE_RUNNER_* variables
@@ -118,6 +120,11 @@ type Config struct {
118120 // GuestForge, when not nil, is the one host endpoint that every sandbox may
119121 // reach, and the name the DNS forwarder answers with the sandbox's gateway.
120122 GuestForge *GuestForge
123+ // Cache, when not nil, is the runner's cache service (package cachesvc),
124+ // served on the gateway of every sandbox on CachePort. The caller makes it
125+ // before building the drivers, like GuestForge.
126+ Cache *cachesvc.Service
127+ CachePort uint16
121128 // Rate limits of micro-VMs; zero disables.
122129 DiskBandwidthMiB, DiskIOPS, NetMbit int
123130
M internal/sandbox/container/driver.go +4 -2
@@ -163,7 +163,9 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
163163 }
164164 d.net = opt.Net
165165 if d.net == nil {
166- d.net = newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName())
166+ n := newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName())
167+ n.Cache, n.CachePort, n.CacheFreeBind = cfg.Cache, cfg.CachePort, info.Podman
168+ d.net = n
167169 }
168170 if err := d.net.EnableForwarding(); err != nil {
169171 return fail(err)
@@ -447,7 +449,7 @@ func (s *containerSandbox) runContainer(ctx context.Context, spec sandbox.JobSpe
447449 Name: "ermite-" + s.id,
448450 Image: d.image,
449451 Entrypoint: []string{entry, "init"},
450- Env: []string{"ERMITE_CONNECT=unix:" + sandbox.ContainerRunDir + "/" + sandbox.ContainerSocket},
452+ Env: []string{"ERMITE_CONNECT=unix:" + sandbox.ContainerRunDir + "/" + sandbox.ContainerSocket, "ERMITE_GATEWAY=" + netw.Gateway.String()},
451453 Labels: labels,
452454 Runtime: d.runtime,
453455 Privileged: d.privileged,
M internal/sandbox/netsetup/netsetup.go +26 -2
@@ -14,6 +14,7 @@ import (
1414 "os"
1515 "strings"
1616
17+ "bnema.dev/ermite-runner/internal/cachesvc"
1718 "bnema.dev/ermite-runner/internal/cmdrun"
1819 "bnema.dev/ermite-runner/internal/dnsfwd"
1920 "bnema.dev/ermite-runner/internal/netpolicy"
@@ -36,6 +37,14 @@ var _ Plumbing = (*Net)(nil)
3637 type Net struct {
3738 Policy *netpolicy.Manager
3839 DNS *dnsfwd.Forwarder
40+ // Cache, when not nil, is the runner's cache service: it listens on the
41+ // gateway of every guest, on CachePort, and netpolicy opens that port to
42+ // that guest only.
43+ Cache *cachesvc.Service
44+ CachePort uint16
45+ // CacheFreeBind lets the cache listener bind before the gateway address
46+ // exists (podman, like dnsfwd.Forwarder.FreeBind).
47+ CacheFreeBind bool
3948 // ForwardFile is the ip_forward sysctl; overridden in tests.
4049 ForwardFile string
4150 }
@@ -68,18 +77,30 @@ func (n *Net) EnableForwarding() error {
6877 // gateway address, which must already be configured on the host interface.
6978 // On error nothing stays installed.
7079 func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error {
80+ if n.Cache != nil {
81+ g.CachePort = n.CachePort
82+ }
7183 if err := n.Policy.Add(ctx, g); err != nil {
7284 return err
7385 }
7486 if err := n.DNS.Listen(ctx, g.Gateway); err != nil {
7587 return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface))
7688 }
89+ if n.Cache != nil {
90+ if err := n.Cache.Listen(ctx, g.Gateway, n.CachePort, n.CacheFreeBind); err != nil {
91+ n.DNS.Close(g.Gateway)
92+ return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface))
93+ }
94+ }
7795 return nil
7896 }
7997
8098 // Detach undoes Attach. It is idempotent and keeps going after an error.
8199 func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error {
82100 n.DNS.Close(gateway)
101+ if n.Cache != nil {
102+ n.Cache.Close(gateway)
103+ }
83104 return n.Policy.Remove(ctx, iface)
84105 }
85106
@@ -87,5 +108,8 @@ func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) erro
87108 // run. Only the table is touched, never other tables.
88109 func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) }
89110
90-// Close stops every DNS listener.
91-func (n *Net) Close() { n.DNS.CloseAll() }
111+// Close stops every DNS listener. Cache listeners end with Detach; the cache
112+// service itself belongs to whoever made it.
113+func (n *Net) Close() {
114+ n.DNS.CloseAll()
115+}
M internal/sandbox/netsetup/netsetup_test.go +90 -0
@@ -5,12 +5,18 @@ package netsetup
55 import (
66 "context"
77 "errors"
8+ "net"
9+ "net/http"
810 "net/netip"
911 "os"
1012 "path/filepath"
13+ "strconv"
1114 "strings"
15+ "syscall"
1216 "testing"
17+ "time"
1318
19+ "bnema.dev/ermite-runner/internal/cachesvc"
1420 "bnema.dev/ermite-runner/internal/cmdrun"
1521 "bnema.dev/ermite-runner/internal/netpolicy"
1622 )
@@ -87,3 +93,87 @@ func TestEnableForwarding(t *testing.T) {
8793 t.Fatalf("want a clear error, got %v", err)
8894 }
8995 }
96+
97+// With a cache service, Attach opens its port in the policy of that guest and
98+// serves it on the gateway; Detach closes both.
99+func TestAttachDetachCache(t *testing.T) {
100+ probe := listenLoopback(t)
101+ port := uint16(probe.Addr().(*net.TCPAddr).Port)
102+ _ = probe.Close()
103+
104+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ t.Cleanup(func() { _ = svc.CloseAll() })
109+ f := &cmdrun.Fake{}
110+ n := New(f, 0, "")
111+ n.Cache, n.CachePort = svc, port
112+ t.Cleanup(n.Close)
113+ ctx := context.Background()
114+ if err := n.Attach(ctx, guest()); err != nil {
115+ t.Fatalf("Attach: %v", err) // the port was free a moment ago: not an environmental skip
116+ }
117+ if rs := f.Calls[0].Stdin; !strings.Contains(rs, "ip daddr 127.0.0.1 tcp dport "+itoa(port)+" iifname != \"ertap0\" drop") ||
118+ !strings.Contains(rs, "iifname \"ertap0\" ip saddr 10.200.0.2 ip daddr 127.0.0.1 tcp dport "+itoa(port)+" accept") {
119+ t.Fatalf("the policy lacks the cache port:\n%s", rs)
120+ }
121+ cli := &http.Client{Timeout: 5 * time.Second}
122+ url := "http://127.0.0.1:" + itoa(port) + cachesvc.APIPath + "cache?keys=k&version=v"
123+ resp, err := cli.Get(url)
124+ if err != nil {
125+ t.Fatalf("the cache does not answer on the gateway: %v", err)
126+ }
127+ _ = resp.Body.Close()
128+ if resp.StatusCode != http.StatusForbidden { // no token
129+ t.Fatalf("status %d", resp.StatusCode)
130+ }
131+ if err := n.Detach(ctx, "ertap0", guest().Gateway); err != nil {
132+ t.Fatal(err)
133+ }
134+ if resp, err := cli.Get(url); err == nil {
135+ _ = resp.Body.Close()
136+ t.Fatal("the cache still answers after Detach")
137+ }
138+}
139+
140+// A failure to serve the cache rolls back the policy and the DNS listener.
141+func TestAttachRollsBackWhenCacheFails(t *testing.T) {
142+ busy := listenLoopback(t)
143+ defer func() { _ = busy.Close() }()
144+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ t.Cleanup(func() { _ = svc.CloseAll() })
149+ f := &cmdrun.Fake{}
150+ n := New(f, 0, "")
151+ n.Cache, n.CachePort = svc, uint16(busy.Addr().(*net.TCPAddr).Port)
152+ t.Cleanup(n.Close)
153+ if err := n.Attach(context.Background(), guest()); err == nil {
154+ t.Fatal("Attach must fail when the cache port is taken")
155+ }
156+ if _, ok := n.DNS.Addr(guest().Gateway); ok {
157+ t.Fatal("DNS still listening after the rollback")
158+ }
159+ if len(f.Calls) != 2 || f.Calls[1].Stdin != netpolicy.Ruleset(nil) {
160+ t.Fatalf("calls: %v", f.Lines())
161+ }
162+}
163+
164+// listenLoopback binds a free loopback port. Only a sandbox that forbids
165+// sockets altogether (EPERM, EACCES, EAFNOSUPPORT) skips the test; any other
166+// error fails it.
167+func listenLoopback(t *testing.T) net.Listener {
168+ t.Helper()
169+ ln, err := net.Listen("tcp", "127.0.0.1:0")
170+ if err != nil {
171+ if errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EAFNOSUPPORT) {
172+ t.Skip("loopback sockets are not allowed here:", err)
173+ }
174+ t.Fatal(err)
175+ }
176+ return ln
177+}
178+
179+func itoa(p uint16) string { return strconv.Itoa(int(p)) }
M internal/sandbox/vm/driver.go +1 -0
@@ -152,6 +152,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
152152 if d.net == nil {
153153 n := netsetup.New(opt.Run, cfg.DNSPort, "")
154154 n.DNS.Override = cfg.GuestForge.DNSName()
155+ n.Cache, n.CachePort = cfg.Cache, cfg.CachePort
155156 d.net = n
156157 }
157158 if cfg.GuestForge != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close