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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 | |||||
| 58 | 58 | ||||
| 59 | ### Network policy (both modes) | 59 | ### Network policy (both modes) | ||
| 60 | 60 | ||||
| 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. |
| 62 | 62 | ||||
| 63 | **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). | 63 | **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). | ||
| 64 | 64 | ||||
| @@ -75,6 +75,20 @@ The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the sc | |||||
| 75 | 75 | ||||
| 76 | **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. | 76 | **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. | ||
| 77 | 77 | ||||
| 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 | + | ||||
| 78 | ### Cancellation | 92 | ### Cancellation | ||
| 79 | 93 | ||||
| 80 | 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. | 94 | 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. | |||||
| 328 | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit | | 342 | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit | | ||
| 329 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox | | 343 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox | | ||
| 330 | | `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") | | 344 | | `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 | | |||
| 331 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") | | 347 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") | | ||
| 332 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | 348 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | ||
| 333 | | `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 | | 349 | | `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 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | bnema.dev/ermite-actions v0.2.0 | 6 | 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 |
| 8 | connectrpc.com/connect v1.21.0 | 8 | connectrpc.com/connect v1.21.0 | ||
| 9 | github.com/bnema/zerowrap v1.4.1 | 9 | github.com/bnema/zerowrap v1.4.1 | ||
| 10 | github.com/containerd/errdefs v1.0.0 | 10 | 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 ( | |||||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "net" | 10 | "net" | ||
| 11 | "os" | 11 | "os" | ||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 12 | "time" | 14 | "time" | ||
| 13 | 15 | ||||
| 14 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 15 | 17 | ||||
| 16 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 18 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 17 | 19 | ||||
| 20 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |||
| 18 | "bnema.dev/ermite-runner/internal/externals" | 21 | "bnema.dev/ermite-runner/internal/externals" | ||
| 19 | "bnema.dev/ermite-runner/internal/host" | 22 | "bnema.dev/ermite-runner/internal/host" | ||
| 20 | "bnema.dev/ermite-runner/internal/sandbox" | 23 | "bnema.dev/ermite-runner/internal/sandbox" | ||
| @@ -33,6 +36,35 @@ func loadHostConfig() (sandbox.Config, host.Config, error) { | |||||
| 33 | return scfg, hcfg, err | 36 | return scfg, hcfg, err | ||
| 34 | } | 37 | } | ||
| 35 | 38 | ||||
| 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 | + | ||||
| 36 | // externalsWait is how long the start of the runner waits for the Node.js | 68 | // externalsWait is how long the start of the runner waits for the Node.js | ||
| 37 | // downloads. A runner with a working network is then ready for its first job; one | 69 | // downloads. A runner with a working network is then ready for its first job; one | ||
| 38 | // without goes on after the first failure (nothing waits for a dead network), and | 70 | // without goes on after the first failure (nothing waits for a dead network), and | ||
| @@ -144,6 +176,14 @@ func runCmd(ctx context.Context) int { | |||||
| 144 | hcfg.GuestForgeOrigin = gf.Origin | 176 | hcfg.GuestForgeOrigin = gf.Origin | ||
| 145 | } | 177 | } | ||
| 146 | setupExternals(ctx, &scfg) | 178 | 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 | + | } | |||
| 147 | drv, err := buildDriver(ctx, scfg) | 187 | drv, err := buildDriver(ctx, scfg) | ||
| 148 | if err != nil { | 188 | if err != nil { | ||
| 149 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | 189 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | ||
| @@ -160,6 +200,7 @@ func runCmd(ctx context.Context) int { | |||||
| 160 | return hcfg.Client(url, tok) | 200 | return hcfg.Client(url, tok) | ||
| 161 | }, | 201 | }, | ||
| 162 | Features: hcfg.ServerFeaturesFunc(url), | 202 | Features: hcfg.ServerFeaturesFunc(url), | ||
| 203 | + | Cache: cache, CachePort: scfg.CachePort, | |||
| 163 | } | 204 | } | ||
| 164 | r.Cfg.URL = url | 205 | r.Cfg.URL = url | ||
| 165 | if err := r.Run(ctx); err != nil { | 206 | 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 { | |||||
| 122 | return serr | 122 | return serr | ||
| 123 | } | 123 | } | ||
| 124 | } | 124 | } | ||
| 125 | - | pc, err := lc.ListenPacket(ctx, "udp", hp) | 125 | + | var ( |
| 126 | - | if err != nil { | 126 | + | pc net.PacketConn |
| 127 | - | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | 127 | + | tl net.Listener |
| 128 | - | } | 128 | + | err error |
| 129 | - | // With port 0 (tests) UDP and TCP must share the port the kernel picked. | 129 | + | ) |
| 130 | - | if f.port == 0 { | 130 | + | // With port 0 (tests) UDP and TCP must share the port the kernel picked for |
| 131 | - | hp = pc.LocalAddr().String() | 131 | + | // UDP, which a TCP socket may already hold: pick again a few times. |
| 132 | - | } | 132 | + | for attempt := 0; ; attempt++ { |
| 133 | - | tl, err := lc.Listen(ctx, "tcp", hp) | 133 | + | bind := hp |
| 134 | - | if err != nil { | 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 | + | } | |||
| 135 | _ = pc.Close() | 143 | _ = 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 | + | } | |||
| 137 | } | 147 | } | ||
| 138 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | 148 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 139 | l := &listener{addr: addr, udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)} | 149 | 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 { | |||||
| 75 | // sends StepAnnotations and StepSummary frames only when they are set. | 75 | // sends StepAnnotations and StepSummary frames only when they are set. | ||
| 76 | bool send_annotations = 13; | 76 | bool send_annotations = 13; | ||
| 77 | bool send_summaries = 14; | 77 | 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; | |||
| 78 | } | 88 | } | ||
| 79 | 89 | ||||
| 80 | enum StreamKind { | 90 | 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, | |||||
| 104 | v, _ := in.Get(k) | 104 | v, _ := in.Get(k) | ||
| 105 | env.Set(inputEnvName(k), v) | 105 | env.Set(inputEnvName(k), v) | ||
| 106 | } | 106 | } | ||
| 107 | + | j.addRuntimeEnv(env) | |||
| 107 | j.mu.Lock() | 108 | j.mu.Lock() | ||
| 108 | workspace := j.state.GitHub("workspace") | 109 | workspace := j.state.GitHub("workspace") | ||
| 109 | temp := j.state.Layout().Temp | 110 | temp := j.state.Layout().Temp | ||
M internal/executor/actions_node.go +20 -0
| @@ -151,6 +151,25 @@ func (j *job) externalsDir() string { | |||||
| 151 | return defaultExternalsDir | 151 | return defaultExternalsDir | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 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 | + | ||||
| 154 | // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs: | 173 | // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs: | ||
| 155 | // 27-196) on the host of the steps (a job container is phase H): INPUT_ | 174 | // 27-196) on the host of the steps (a job container is phase H): INPUT_ | ||
| 156 | // variables and the PATH of the previous steps in the environment, the | 175 | // 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 | |||||
| 161 | v, _ := in.Get(k) | 180 | v, _ := in.Get(k) | ||
| 162 | env.Set(inputEnvName(k), v) | 181 | env.Set(inputEnvName(k), v) | ||
| 163 | } | 182 | } | ||
| 183 | + | j.addRuntimeEnv(env) | |||
| 164 | // The PATH of the previous steps is added by procEnv. | 184 | // The PATH of the previous steps is added by procEnv. | ||
| 165 | env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795 | 185 | env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795 | ||
| 166 | r := def.runs() | 186 | r := def.runs() | ||
M internal/executor/job.go +5 -0
| @@ -74,6 +74,10 @@ type Config struct { | |||||
| 74 | ToolCache string | 74 | ToolCache string | ||
| 75 | // BaseEnv is the environment steps inherit; nil means os.Environ(). | 75 | // BaseEnv is the environment steps inherit; nil means os.Environ(). | ||
| 76 | BaseEnv []string | 76 | 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 | |||
| 77 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | 81 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | ||
| 78 | Debug bool | 82 | Debug bool | ||
| 79 | // Actions configures the actions of the job (resolution, downloads, Node.js, | 83 | // Actions configures the actions of the job (resolution, downloads, Node.js, | ||
| @@ -448,6 +452,7 @@ func (j *job) setUp() ([]qstep, error) { | |||||
| 448 | } | 452 | } | ||
| 449 | j.state = state | 453 | j.state = state | ||
| 450 | j.masker.Store(newMasker(state.Secrets()...)) | 454 | j.masker.Store(newMasker(state.Secrets()...)) | ||
| 455 | + | j.masker.Load().add(cfg.CacheToken) | |||
| 451 | 456 | ||||
| 452 | // Features that arrive in later phases fail the job with a clear message. | 457 | // Features that arrive in later phases fail the job with a clear message. | ||
| 453 | switch { | 458 | 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 | |||||
| 181 | } | 181 | } | ||
| 182 | } | 182 | } | ||
| 183 | 183 | ||||
| 184 | - | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | 184 | + | execEnv := append(os.Environ(), "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version) |
| 185 | - | "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) | |||
| 186 | if err != nil { | 191 | if err != nil { | ||
| 187 | log.Error().Err(err).Msg("init: start executor") | 192 | log.Error().Err(err).Msg("init: start executor") | ||
| 188 | stopDockerd(dockerPid) | 193 | stopDockerd(dockerPid) | ||
M internal/guest/jobexec.go +12 -0
| @@ -5,6 +5,7 @@ package guest | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "net" | |||
| 8 | "os" | 9 | "os" | ||
| 9 | "os/user" | 10 | "os/user" | ||
| 10 | "strconv" | 11 | "strconv" | ||
| @@ -256,6 +257,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||||
| 256 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | 257 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | ||
| 257 | RunnerVersion: spec.GetRunnerVersion(), | 258 | RunnerVersion: spec.GetRunnerVersion(), | ||
| 258 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | 259 | 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(), | |||
| 259 | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx), | 261 | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx), | ||
| 260 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | 262 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | ||
| 261 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | 263 | 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 | |||||
| 266 | return 1 | 268 | return 1 | ||
| 267 | } | 269 | } | ||
| 268 | 270 | ||||
| 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 | + | ||||
| 269 | // failSetup ends a job that cannot start, like a failed "Set up job": step 1 | 281 | // failSetup ends a job that cannot start, like a failed "Set up job": step 1 | ||
| 270 | // starts, logs the reason as an error, and fails; the job result is a failure. | 282 | // starts, logs the reason as an error, and fails; the job result is a failure. | ||
| 271 | func failSetup(conn *execstream.Conn, msg string) int { | 283 | 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) { | |||||
| 691 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | 691 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | ||
| 692 | SendAnnotations: j.features.annotations, SendSummaries: j.features.summaries, | 692 | SendAnnotations: j.features.annotations, SendSummaries: j.features.summaries, | ||
| 693 | }} | 693 | }} | ||
| 694 | + | run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt) | |||
| 694 | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | 695 | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | ||
| 695 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | 696 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | ||
| 696 | Log: j.logLine, | 697 | Log: j.logLine, | ||
M internal/host/runner.go +7 -0
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | ermite "bnema.dev/ermite-api/gen/ermite" | 19 | ermite "bnema.dev/ermite-api/gen/ermite" | ||
| 20 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 20 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 21 | 21 | ||||
| 22 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |||
| 22 | "bnema.dev/ermite-runner/internal/execstream/execpb" | 23 | "bnema.dev/ermite-runner/internal/execstream/execpb" | ||
| 23 | "bnema.dev/ermite-runner/internal/sandbox" | 24 | "bnema.dev/ermite-runner/internal/sandbox" | ||
| 24 | ) | 25 | ) | ||
| @@ -36,6 +37,12 @@ type Runner struct { | |||||
| 36 | // step summaries are sent only when it says "ci.annotations" and | 37 | // step summaries are sent only when it says "ci.annotations" and | ||
| 37 | // "ci.summaries". Nil means neither is sent. | 38 | // "ci.summaries". Nil means neither is sent. | ||
| 38 | Features ServerFeatures | 39 | 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 | |||
| 39 | 46 | ||||
| 40 | // now returns the current time; tests replace it. | 47 | // now returns the current time; tests replace it. | ||
| 41 | now func() time.Time | 48 | now func() time.Time | ||
M internal/netpolicy/netpolicy.go +16 -0
| @@ -16,6 +16,9 @@ | |||||
| 16 | // only): TCP from the guest's source address to its own gateway address on | 16 | // only): TCP from the guest's source address to its own gateway address on | ||
| 17 | // that one port. The host's own listener on that port receives the traffic; | 17 | // that one port. The host's own listener on that port receives the traffic; | ||
| 18 | // nothing is proxied, and every other host port stays closed; | 18 | // 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; | |||
| 19 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | 22 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | ||
| 20 | // - forwarded traffic must carry the guest's own source address; | 23 | // - forwarded traffic must carry the guest's own source address; | ||
| 21 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | 24 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | ||
| @@ -82,6 +85,11 @@ type Guest struct { | |||||
| 82 | // guest's interface, source and gateway, so it neither reaches other | 85 | // guest's interface, source and gateway, so it neither reaches other | ||
| 83 | // addresses of the host nor other guests. | 86 | // addresses of the host nor other guests. | ||
| 84 | ForgePort uint16 | 87 | 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 | |||
| 85 | } | 93 | } | ||
| 86 | 94 | ||||
| 87 | // Validate checks the fields used in generated rules. | 95 | // Validate checks the fields used in generated rules. | ||
| @@ -160,6 +168,11 @@ func Ruleset(guests []Guest) string { | |||||
| 160 | fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface) | 168 | fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface) | ||
| 161 | } | 169 | } | ||
| 162 | } | 170 | } | ||
| 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 | + | } | |||
| 163 | for _, g := range gs { | 176 | for _, g := range gs { | ||
| 164 | for _, proto := range []string{"udp", "tcp"} { | 177 | for _, proto := range []string{"udp", "tcp"} { | ||
| 165 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | 178 | 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 { | |||||
| 169 | // which this table does not touch. | 182 | // which this table does not touch. | ||
| 170 | 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) | 183 | 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) | ||
| 171 | } | 184 | } | ||
| 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 | + | } | |||
| 172 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | 188 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | ||
| 173 | } | 189 | } | ||
| 174 | // An interface with our prefix that no live guest owns (a leftover) is | 190 | // 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) { | |||||
| 292 | } | 292 | } | ||
| 293 | } | 293 | } | ||
| 294 | } | 294 | } | ||
| 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 ( | |||||
| 11 | "runtime" | 11 | "runtime" | ||
| 12 | "strconv" | 12 | "strconv" | ||
| 13 | "strings" | 13 | "strings" | ||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | // Environment variables read by LoadConfig. The other ERMITE_RUNNER_* variables | 18 | // Environment variables read by LoadConfig. The other ERMITE_RUNNER_* variables | ||
| @@ -118,6 +120,11 @@ type Config struct { | |||||
| 118 | // GuestForge, when not nil, is the one host endpoint that every sandbox may | 120 | // GuestForge, when not nil, is the one host endpoint that every sandbox may | ||
| 119 | // reach, and the name the DNS forwarder answers with the sandbox's gateway. | 121 | // reach, and the name the DNS forwarder answers with the sandbox's gateway. | ||
| 120 | GuestForge *GuestForge | 122 | 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 | |||
| 121 | // Rate limits of micro-VMs; zero disables. | 128 | // Rate limits of micro-VMs; zero disables. | ||
| 122 | DiskBandwidthMiB, DiskIOPS, NetMbit int | 129 | DiskBandwidthMiB, DiskIOPS, NetMbit int | ||
| 123 | 130 | ||||
M internal/sandbox/container/driver.go +4 -2
| @@ -163,7 +163,9 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 163 | } | 163 | } | ||
| 164 | d.net = opt.Net | 164 | d.net = opt.Net | ||
| 165 | if d.net == nil { | 165 | 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 | |||
| 167 | } | 169 | } | ||
| 168 | if err := d.net.EnableForwarding(); err != nil { | 170 | if err := d.net.EnableForwarding(); err != nil { | ||
| 169 | return fail(err) | 171 | return fail(err) | ||
| @@ -447,7 +449,7 @@ func (s *containerSandbox) runContainer(ctx context.Context, spec sandbox.JobSpe | |||||
| 447 | Name: "ermite-" + s.id, | 449 | Name: "ermite-" + s.id, | ||
| 448 | Image: d.image, | 450 | Image: d.image, | ||
| 449 | Entrypoint: []string{entry, "init"}, | 451 | 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()}, |
| 451 | Labels: labels, | 453 | Labels: labels, | ||
| 452 | Runtime: d.runtime, | 454 | Runtime: d.runtime, | ||
| 453 | Privileged: d.privileged, | 455 | Privileged: d.privileged, | ||
M internal/sandbox/netsetup/netsetup.go +26 -2
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "os" | 14 | "os" | ||
| 15 | "strings" | 15 | "strings" | ||
| 16 | 16 | ||||
| 17 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |||
| 17 | "bnema.dev/ermite-runner/internal/cmdrun" | 18 | "bnema.dev/ermite-runner/internal/cmdrun" | ||
| 18 | "bnema.dev/ermite-runner/internal/dnsfwd" | 19 | "bnema.dev/ermite-runner/internal/dnsfwd" | ||
| 19 | "bnema.dev/ermite-runner/internal/netpolicy" | 20 | "bnema.dev/ermite-runner/internal/netpolicy" | ||
| @@ -36,6 +37,14 @@ var _ Plumbing = (*Net)(nil) | |||||
| 36 | type Net struct { | 37 | type Net struct { | ||
| 37 | Policy *netpolicy.Manager | 38 | Policy *netpolicy.Manager | ||
| 38 | DNS *dnsfwd.Forwarder | 39 | 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 | |||
| 39 | // ForwardFile is the ip_forward sysctl; overridden in tests. | 48 | // ForwardFile is the ip_forward sysctl; overridden in tests. | ||
| 40 | ForwardFile string | 49 | ForwardFile string | ||
| 41 | } | 50 | } | ||
| @@ -68,18 +77,30 @@ func (n *Net) EnableForwarding() error { | |||||
| 68 | // gateway address, which must already be configured on the host interface. | 77 | // gateway address, which must already be configured on the host interface. | ||
| 69 | // On error nothing stays installed. | 78 | // On error nothing stays installed. | ||
| 70 | func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error { | 79 | func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error { | ||
| 80 | + | if n.Cache != nil { | |||
| 81 | + | g.CachePort = n.CachePort | |||
| 82 | + | } | |||
| 71 | if err := n.Policy.Add(ctx, g); err != nil { | 83 | if err := n.Policy.Add(ctx, g); err != nil { | ||
| 72 | return err | 84 | return err | ||
| 73 | } | 85 | } | ||
| 74 | if err := n.DNS.Listen(ctx, g.Gateway); err != nil { | 86 | if err := n.DNS.Listen(ctx, g.Gateway); err != nil { | ||
| 75 | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | 87 | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | ||
| 76 | } | 88 | } | ||
| 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 | + | } | |||
| 77 | return nil | 95 | return nil | ||
| 78 | } | 96 | } | ||
| 79 | 97 | ||||
| 80 | // Detach undoes Attach. It is idempotent and keeps going after an error. | 98 | // Detach undoes Attach. It is idempotent and keeps going after an error. | ||
| 81 | func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error { | 99 | func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error { | ||
| 82 | n.DNS.Close(gateway) | 100 | n.DNS.Close(gateway) | ||
| 101 | + | if n.Cache != nil { | |||
| 102 | + | n.Cache.Close(gateway) | |||
| 103 | + | } | |||
| 83 | return n.Policy.Remove(ctx, iface) | 104 | return n.Policy.Remove(ctx, iface) | ||
| 84 | } | 105 | } | ||
| 85 | 106 | ||||
| @@ -87,5 +108,8 @@ func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) erro | |||||
| 87 | // run. Only the table is touched, never other tables. | 108 | // run. Only the table is touched, never other tables. | ||
| 88 | func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) } | 109 | func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) } | ||
| 89 | 110 | ||||
| 90 | - | // Close stops every DNS listener. | 111 | + | // Close stops every DNS listener. Cache listeners end with Detach; the cache |
| 91 | - | func (n *Net) Close() { n.DNS.CloseAll() } | 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 | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | + | "net" | |||
| 9 | + | "net/http" | |||
| 8 | "net/netip" | 10 | "net/netip" | ||
| 9 | "os" | 11 | "os" | ||
| 10 | "path/filepath" | 12 | "path/filepath" | ||
| 13 | + | "strconv" | |||
| 11 | "strings" | 14 | "strings" | ||
| 15 | + | "syscall" | |||
| 12 | "testing" | 16 | "testing" | ||
| 17 | + | "time" | |||
| 13 | 18 | ||||
| 19 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |||
| 14 | "bnema.dev/ermite-runner/internal/cmdrun" | 20 | "bnema.dev/ermite-runner/internal/cmdrun" | ||
| 15 | "bnema.dev/ermite-runner/internal/netpolicy" | 21 | "bnema.dev/ermite-runner/internal/netpolicy" | ||
| 16 | ) | 22 | ) | ||
| @@ -87,3 +93,87 @@ func TestEnableForwarding(t *testing.T) { | |||||
| 87 | t.Fatalf("want a clear error, got %v", err) | 93 | t.Fatalf("want a clear error, got %v", err) | ||
| 88 | } | 94 | } | ||
| 89 | } | 95 | } | ||
| 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) | |||||
| 152 | if d.net == nil { | 152 | if d.net == nil { | ||
| 153 | n := netsetup.New(opt.Run, cfg.DNSPort, "") | 153 | n := netsetup.New(opt.Run, cfg.DNSPort, "") | ||
| 154 | n.DNS.Override = cfg.GuestForge.DNSName() | 154 | n.DNS.Override = cfg.GuestForge.DNSName() | ||
| 155 | + | n.Cache, n.CachePort = cfg.Cache, cfg.CachePort | |||
| 155 | d.net = n | 156 | d.net = n | ||
| 156 | } | 157 | } | ||
| 157 | if cfg.GuestForge != nil { | 158 | if cfg.GuestForge != nil { | ||