feat(ci): artifacts v4 results service (#32)
- commit
- 0e950d6dafe1e487abf0ec1ee1db518fb6c960d2
- author
- brice · · 2026-10-16 08:33 UTC
- committer
- bnema · · 2026-10-16 08:33 UTC
- parent
- cbcd612
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
42 files · +3720 -121
M README.md +17 -4
| @@ -79,20 +79,32 @@ The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the sc | |||
| 79 | 79 | ||
| 80 | 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 | 81 | ||
| 82 | - | - **Where.** One service per runner, reachable **only on the loopback of the sandbox**, `127.0.0.1:9089`; it listens on no network and on no host port, and the host firewall has no rule for it (nothing to open on the host). A small relay in the sandbox (`internal/guest/relay.go`, started with the job and ended with it) listens on that address and pipes each TCP connection, in both directions with half-close, to a new connection on the host channel the executor already uses, and the runner serves HTTP on the other end: | |
| 82 | + | - **Where.** One service per runner, reachable **only from inside the sandbox**: on its loopback, `127.0.0.1:9089`, and on the address of the sandbox's own Docker bridge (`docker0`, typically `172.17.0.1:9089`) for the steps that run in a container; it listens on no network of the host and on no host port, and the host firewall has no rule for it (nothing to open on the host). A small relay in the sandbox (`internal/guest/relay.go`, started with the job and ended with it) listens on those addresses and pipes each TCP connection, in both directions with half-close, to a new connection on the host channel the executor already uses, and the runner serves HTTP on the other end: | |
| 83 | 83 | - **VM:** the vsock port 9089 (the relay connects to the host, CID 2). Firecracker hands the connection to the VM's own Unix socket `<vm dir>/v.sock_9089`, next to the one for the executor (`_1024`); with the jailer both are in the VM's chroot. | |
| 84 | 84 | - **Container:** `cache.sock` next to `exec.sock` in the per-job directory that is bind-mounted as `/run/ermite`. That directory exists for that container only. | |
| 85 | 85 | ||
| 86 | - | Each sandbox has its own listener and handler on the runner, and the runner accepts on it only the token made for that sandbox's job, so one sandbox can neither reach another's channel nor use a token on it that was not made for its job. The connection caps apply to the channel (16 per sandbox, 64 in all; the relay itself allows 32 open connections). Node and docker actions get `ACTIONS_CACHE_URL=http://127.0.0.1:9089/` (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. A docker action or a job container with its own network namespace does not share the sandbox's loopback and does not reach the cache (host networking does); this has not been exercised in a live sandbox. A relay that cannot start (port taken) leaves that job without a cache; the job runs. | |
| 86 | + | Each sandbox has its own listener and handler on the runner, and the runner accepts on it only the token made for that sandbox's job, so one sandbox can neither reach another's channel nor use a token on it that was not made for its job. The connection caps apply to the channel (32 per sandbox, 128 in all; each relay allows 32 open connections). Node actions on the sandbox get `ACTIONS_CACHE_URL=http://127.0.0.1:9089/` (the toolkit appends `_apis/…`), `ACTIONS_RUNTIME_URL` (same value), `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_CACHE_MODE`; `run:` steps get none, as on the official runner. The token is masked. **Containers:** a job container and a docker action have a network namespace of their own, whose loopback is not the sandbox's, so node steps in a job container and docker actions get the same variables with the bridge address instead (`http://<docker0>:9089/`, read when the job starts: the nested daemon picks it). A container on any bridge network of that daemon reaches a local address of the sandbox through `INPUT`, which Docker does not filter: no `--add-host`, no name, no host rule. Without a Docker bridge those steps get no runtime variables. A relay that cannot start (port taken) leaves that job without a cache; the job runs. | |
| 87 | 87 | - **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. | |
| 88 | 88 | - **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`. | |
| 89 | - | - **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, issuing job, expiry) because the toolkit downloads without the bearer token; a sandbox's channel serves only URLs issued to its own job, so a URL taken out of one sandbox is refused on every other's. | |
| 89 | + | - **Tokens.** `ACTIONS_RUNTIME_TOKEN` is a JWT (three base64url segments, HS256) whose claims are the job id, repository, write ref, read refs, expiry (job timeout plus 15 min), `iat`, `exp` and `scp` (`Actions.GenericRead:…`, plus the job's `Actions.Results:<run>:<job>` when it has the results service, see Artifacts). The key is derived for tokens from a random key made at start and kept in memory only (a restart ends every job anyway), so no URL signature can pass for a token. The comparison is constant time; everything wrong (no token, bad signature, expired, ids of another job or ref, a token without a cache scope) is a bare `403`. The token is not an ermite credential and ermite refuses it. The archive URL carries its own HMAC (entry, repository, issuing job, expiry) because the toolkit downloads without the bearer token; a sandbox's channel serves only URLs issued to its own job, so a URL taken out of one sandbox is refused on every other's. | |
| 90 | 90 | - **Off.** `ERMITE_RUNNER_CACHE=0`. | |
| 91 | 91 | - **Action archives.** The same channel serves the tarballs of actions. When the server resolves an action to a commit, the host downloads the archive once into `<state dir>/actions-cache/` (keyed by forge, repository and commit sha, never by ref) and points the sandbox at a signed URL on the cache channel (bound to the job, the key and the expiry); the sandbox extracts it as it would a download, and "Download action repository" is printed on a hit as on a miss. Concurrent jobs share one download; each entry stores its sha256, which is checked on every read (a corrupt entry is dropped and fetched again); least recently used entries are evicted above `ERMITE_RUNNER_ACTIONS_CACHE_MAX` (default `2G`, `0` turns it off), and an entry just handed out is kept for 30 minutes, and downloads in progress are charged against the cap as they are written (an archive that does not fit is not cached and the sandbox downloads it). The sandbox never writes to this cache. Whatever cannot be cached (no cache channel for the job, no sha, a failed host download) is downloaded by the sandbox as before. | |
| 92 | 92 | ||
| 93 | 93 | **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. | |
| 94 | 94 | ||
| 95 | - | **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. **Bandwidth:** a VM's cache traffic does not cross `eth0`, so the runner limits it itself: one limit per sandbox, shared by all its connections and both directions (request bodies and replies or archives), at the sandbox's `ERMITE_RUNNER_JOB_NET_BANDWIDTH` (Mbit/s, same default; `0` is unlimited). A transfer over the limit is slowed, not cut. Containers have no per-job network rate limit, so their cache channel is unlimited. | |
| 95 | + | **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 32 connections per sandbox and 128 in all; an upload or download that makes no progress for 60 s is cut. **Bandwidth:** a VM's cache traffic does not cross `eth0`, so the runner limits it itself: one limit per sandbox, shared by all its connections and both directions (request bodies and replies or archives), at the sandbox's `ERMITE_RUNNER_JOB_NET_BANDWIDTH` (Mbit/s, same default; `0` is unlimited). A transfer over the limit is slowed, not cut. Containers have no per-job network rate limit, so their cache channel is unlimited. | |
| 96 | + | ||
| 97 | + | ### Artifacts (artifacts v4) | |
| 98 | + | ||
| 99 | + | `actions/upload-artifact@v4+` and `actions/download-artifact@v4+` (and anything else bundling `@actions/artifact` v2) work when the server has the feature `ci.artifacts`. **ermite stores the artifacts** (cross-runner downloads, retention, quota, the run page and `ermite run download`); the runner keeps no byte of them on disk. It serves GitHub's results service on the same channel and port as the cache (`internal/resultssvc`), and translates it to ermite's `JobArtifactService` with the job token, which never enters the sandbox: | |
| 100 | + | ||
| 101 | + | - **Protocol.** Twirp JSON, `POST /twirp/github.actions.results.api.v1.ArtifactService/{CreateArtifact,FinalizeArtifact,ListArtifacts,GetSignedArtifactURL,DeleteArtifact}`, authenticated by `ACTIONS_RUNTIME_TOKEN` (the job's token with its `Actions.Results:<run id>:<job>` scope, which the toolkit decodes for its ids). The bytes go through Azure block blob requests on signed URLs: `PUT ?comp=block` (each 8 MiB block streamed to ermite as it arrives, answered `201` once ermite has stored it), `PUT ?comp=blocklist` (the commit), single-shot `PUT`, and `GET` for downloads (streamed from ermite; a stream that breaks is resumed at the byte reached, up to 3 times). Signed URLs (`/_apis/results/blob/…?se=&sig=`) are bound to the operation, the artifact, the job and the token's expiry, and use the host the step used, so they work from the sandbox and from containers alike. | |
| 102 | + | - **Isolation.** Run and job ids come from the job the channel serves, never from request bodies; ermite checks the run again. A job reaches the artifacts of its own run only. | |
| 103 | + | - **Errors.** ermite's message reaches the step log. Quota and size limits are `412 failed_precondition` (`413` on a block), not retried; ermite unavailable is `503`, which the toolkit retries. | |
| 104 | + | - **Variables.** Node actions and docker actions get `ACTIONS_RESULTS_URL` (the same origin as `ACTIONS_CACHE_URL`, the bridge address in containers), as `NodeScriptActionHandler.cs`/`ContainerActionHandler.cs` set it. A job with no cache scope still gets the channel for artifacts, without `ACTIONS_CACHE_URL`. | |
| 105 | + | - **`GITHUB_SERVER_URL` of the artifact steps.** This is the one place where the runner changes an action's environment. `@actions/artifact` refuses to run on GitHub Enterprise Server, which it recognises **only** by `GITHUB_SERVER_URL` (`isGhes()`, `toolkit/packages/artifact/src/internal/shared/config.ts`): any host but `github.com`, `*.ghe.com` and `*.localhost` fails with "not currently supported on GHES". So in the process of a **node** step (main, pre, post, also inside composites) of `actions/upload-artifact` or `actions/download-artifact` (any subpath, such as `actions/upload-artifact/merge`; `upload-pages-artifact` uses it), or of an action whose script contains that toolkit message, `GITHUB_SERVER_URL` is `https://ermite.ghe.localhost`. Nothing resolves or connects to that host: the toolkit only reads it. The `github` context, `${{ github.server_url }}` and every other step keep the real URL. Changing it globally would break git, curl and checkout; forking the actions would break SHA pins. | |
| 106 | + | - **Not covered.** Artifact actions pinned below `upload-artifact@v4.3.1` / `download-artifact@v4.1.2` (their toolkit treats `*.localhost` as GHES), artifacts v3 (`@actions/artifact` v1), and the `artifact-url` output, which points at the fake host (the run page in ermite lists the artifacts). Downloads of another run (`run-id` with `github-token`) need ermite's REST endpoints. | |
| 107 | + | - **Off.** `ERMITE_RUNNER_ARTIFACTS=0`, or a server without `ci.artifacts`: no `ACTIONS_RESULTS_URL`, no override, and the artifact actions fail with the toolkit's GHES message, as before. The service needs the cache channel: `ERMITE_RUNNER_CACHE=0` turns it off too. | |
| 96 | 108 | ||
| 97 | 109 | ### Cancellation | |
| 98 | 110 | ||
| @@ -350,6 +362,7 @@ the moment it starts. | |||
| 350 | 362 | | `ERMITE_RUNNER_CACHE` | `1` | `0` turns the cache service off (see "Cache") | | |
| 351 | 363 | | `ERMITE_RUNNER_CACHE_MAX` | `10G` | size cap of the cache, LRU eviction | | |
| 352 | 364 | | `ERMITE_RUNNER_ACTIONS_CACHE_MAX` | `2G` | size cap of the action archive cache; `0` turns it off | | |
| 365 | + | | `ERMITE_RUNNER_ARTIFACTS` | `1` | `0` turns the results service (artifacts v4) off (see "Artifacts") | | |
| 353 | 366 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") | | |
| 354 | 367 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | |
| 355 | 368 | | `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 host_cmd.go +35 -0
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | "bnema.dev/ermite-runner/internal/cachesvc" | |
| 22 | 22 | "bnema.dev/ermite-runner/internal/externals" | |
| 23 | 23 | "bnema.dev/ermite-runner/internal/host" | |
| 24 | + | "bnema.dev/ermite-runner/internal/resultssvc" | |
| 24 | 25 | "bnema.dev/ermite-runner/internal/sandbox" | |
| 25 | 26 | "bnema.dev/ermite-runner/internal/sandbox/drivers" | |
| 26 | 27 | ) | |
| @@ -91,6 +92,31 @@ func setupArchives(ctx context.Context, stateDir string) (*actionscache.Cache, e | |||
| 91 | 92 | // envCache=0 turns the cache service off. | |
| 92 | 93 | const envCache = "ERMITE_RUNNER_CACHE" | |
| 93 | 94 | ||
| 95 | + | // envArtifacts=0 turns the results service (artifacts v4) off. | |
| 96 | + | const envArtifacts = "ERMITE_RUNNER_ARTIFACTS" | |
| 97 | + | ||
| 98 | + | // setupResults makes the results service of the runner (README, "Artifacts"): | |
| 99 | + | // on by default, off with ERMITE_RUNNER_ARTIFACTS=0. It is served on the cache | |
| 100 | + | // channel of each sandbox and checks the cache's runtime tokens, so it needs | |
| 101 | + | // the cache service; a job gets it only when the server has ci.artifacts. | |
| 102 | + | func setupResults(ctx context.Context, cache *cachesvc.Service) (*resultssvc.Service, error) { | |
| 103 | + | log := zerowrap.FromCtx(ctx) | |
| 104 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv(envArtifacts))) { | |
| 105 | + | case "", "1", "true", "yes": | |
| 106 | + | case "0", "false", "no": | |
| 107 | + | log.Info().Msg("the results service is off (" + envArtifacts + "=0): artifact actions fail") | |
| 108 | + | return nil, nil | |
| 109 | + | default: | |
| 110 | + | return nil, fmt.Errorf("%s: %q is not 0 or 1", envArtifacts, os.Getenv(envArtifacts)) | |
| 111 | + | } | |
| 112 | + | if cache == nil { | |
| 113 | + | log.Warn().Msg("the results service needs the cache service (" + envCache + "=0): artifact actions fail") | |
| 114 | + | return nil, nil | |
| 115 | + | } | |
| 116 | + | log.Info().Msg("results service on (artifacts v4, on the cache channel; stored by ermite)") | |
| 117 | + | return resultssvc.New(resultssvc.Config{Auth: cache, Log: log}), nil | |
| 118 | + | } | |
| 119 | + | ||
| 94 | 120 | // externalsWait is how long the start of the runner waits for the Node.js | |
| 95 | 121 | // downloads. A runner with a working network is then ready for its first job; one | |
| 96 | 122 | // without goes on after the first failure (nothing waits for a dead network), and | |
| @@ -210,6 +236,11 @@ func runCmd(ctx context.Context) int { | |||
| 210 | 236 | if cache != nil { | |
| 211 | 237 | defer func() { _ = cache.CloseAll() }() | |
| 212 | 238 | } | |
| 239 | + | results, err := setupResults(ctx, cache) | |
| 240 | + | if err != nil { | |
| 241 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| 242 | + | return 2 | |
| 243 | + | } | |
| 213 | 244 | drv, err := buildDriver(ctx, scfg) | |
| 214 | 245 | if err != nil { | |
| 215 | 246 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| @@ -228,6 +259,10 @@ func runCmd(ctx context.Context) int { | |||
| 228 | 259 | Features: hcfg.ServerFeaturesFunc(url), | |
| 229 | 260 | Cache: cache, | |
| 230 | 261 | Archives: archives, | |
| 262 | + | Results: results, | |
| 263 | + | Artifacts: func(tok string) ermiteconnect.JobArtifactServiceClient { | |
| 264 | + | return hcfg.ArtifactClient(url, tok) | |
| 265 | + | }, | |
| 231 | 266 | } | |
| 232 | 267 | r.Cfg.URL = url | |
| 233 | 268 | if err := r.Run(ctx); err != nil { | |
M internal/cachesvc/archives_test.go +13 -0
| @@ -88,3 +88,16 @@ func TestActionArchiveURLNeedsACache(t *testing.T) { | |||
| 88 | 88 | t.Errorf("url without a cache: %q", u) | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | + | ||
| 92 | + | // A channel that also serves the results service still sends the action archive | |
| 93 | + | // route to the cache: the results handler takes only the paths of neither. | |
| 94 | + | func TestActionArchiveRouteWithResults(t *testing.T) { | |
| 95 | + | r, _, key := archiveRig(t) | |
| 96 | + | results := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 97 | + | http.Error(w, "results", http.StatusNotFound) | |
| 98 | + | }) | |
| 99 | + | a := channel(t, r.svc, "er0000000a", "job-a", WithResults(results)) | |
| 100 | + | if code, body := archiveStatus(t, a, "GET", r.svc.ArchiveURL("job-a", key, time.Hour)); code != http.StatusOK || body != "tarball" { | |
| 101 | + | t.Fatalf("archive with results: %d %q", code, body) | |
| 102 | + | } | |
| 103 | + | } | |
M internal/cachesvc/bandwidth.go +9 -0
| @@ -4,6 +4,7 @@ package cachesvc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "net" | |
| 7 | + | "net/http" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 9 | 10 | ) | |
| @@ -151,6 +152,7 @@ type ServeOption func(*serveConfig) | |||
| 151 | 152 | ||
| 152 | 153 | type serveConfig struct { | |
| 153 | 154 | bytesPerSec int64 | |
| 155 | + | results http.Handler | |
| 154 | 156 | } | |
| 155 | 157 | ||
| 156 | 158 | // WithBandwidth limits the channel to mbit Mbit/s, summed over all its | |
| @@ -167,3 +169,10 @@ func WithBandwidth(mbit int) ServeOption { | |||
| 167 | 169 | func withRate(bytesPerSec int64) ServeOption { | |
| 168 | 170 | return func(c *serveConfig) { c.bytesPerSec = bytesPerSec } | |
| 169 | 171 | } | |
| 172 | + | ||
| 173 | + | // WithResults serves h, the results service of the channel's job | |
| 174 | + | // (resultssvc), beside the cache: every request outside the cache API goes to | |
| 175 | + | // it, under the same connection caps, bandwidth limit and progress deadlines. | |
| 176 | + | func WithResults(h http.Handler) ServeOption { | |
| 177 | + | return func(c *serveConfig) { c.results = h } | |
| 178 | + | } | |
M internal/cachesvc/handler.go +23 -0
| @@ -73,6 +73,29 @@ func (s *Service) handler(job string) http.Handler { | |||
| 73 | 73 | }) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // channelHandler is the handler of a sandbox's channel: the cache API and the | |
| 77 | + | // action archives, and when results is not nil everything else goes to it (the results service of the | |
| 78 | + | // job: Twirp and blob paths). The results service gets the same progress | |
| 79 | + | // deadlines as the cache: its bodies and replies must keep moving. | |
| 80 | + | func (s *Service) channelHandler(job string, results http.Handler) http.Handler { | |
| 81 | + | cache := s.handler(job) | |
| 82 | + | if results == nil { | |
| 83 | + | return cache | |
| 84 | + | } | |
| 85 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 86 | + | if strings.HasPrefix(r.URL.Path, APIPath) || strings.HasPrefix(r.URL.Path, ArchivePath) { | |
| 87 | + | cache.ServeHTTP(w, r) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | rc := http.NewResponseController(w) | |
| 91 | + | pr := newProgressReader(r.Body, rc, s.progress, nil) | |
| 92 | + | defer pr.done() | |
| 93 | + | pr.set() | |
| 94 | + | r.Body = readCloser{pr, r.Body} | |
| 95 | + | results.ServeHTTP(&progressWriter{ResponseWriter: w, rc: rc, idle: s.progress}, r) | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | ||
| 76 | 99 | type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims) | |
| 77 | 100 | ||
| 78 | 101 | // auth checks the bearer token (ACTIONS_RUNTIME_TOKEN), and when job is not | |
M internal/cachesvc/service.go +39 -12
| @@ -9,9 +9,11 @@ | |||
| 9 | 9 | // One Service per runner host. Entries live under <state>/cache/ (files plus a | |
| 10 | 10 | // JSON index, atomic finalisation, LRU eviction by total size) and are scoped by | |
| 11 | 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. | |
| 12 | + | // (ACTIONS_RUNTIME_TOKEN): a JWT signed with HS256 under a key that exists only | |
| 13 | + | // in the memory of the runner, over its job id, repository, write ref, read | |
| 14 | + | // refs, expiry and scopes. The same token serves the results service of the | |
| 15 | + | // job (package resultssvc), which the channel of a sandbox carries beside the | |
| 16 | + | // cache (WithResults). | |
| 15 | 17 | // | |
| 16 | 18 | // The service never listens on a network. Each sandbox has a channel of its own | |
| 17 | 19 | // (Serve): a Unix socket, bind-mounted into a container or reached through | |
| @@ -26,6 +28,7 @@ import ( | |||
| 26 | 28 | "net" | |
| 27 | 29 | "net/http" | |
| 28 | 30 | "strconv" | |
| 31 | + | "strings" | |
| 29 | 32 | "sync" | |
| 30 | 33 | "time" | |
| 31 | 34 | ||
| @@ -51,9 +54,10 @@ const ( | |||
| 51 | 54 | // progress before its connection is cut. | |
| 52 | 55 | DefaultProgressTimeout = 60 * time.Second | |
| 53 | 56 | // DefaultConnsPerListener and DefaultConns bound the open connections of one | |
| 54 | - | // sandbox's listener and of the whole service. | |
| 55 | - | DefaultConnsPerListener = 16 | |
| 56 | - | DefaultConns = 64 | |
| 57 | + | // sandbox's listener and of the whole service. upload-artifact sends 5 | |
| 58 | + | // blocks at once by default, besides downloads and the cache. | |
| 59 | + | DefaultConnsPerListener = 32 | |
| 60 | + | DefaultConns = 128 | |
| 57 | 61 | ) | |
| 58 | 62 | ||
| 59 | 63 | // Config configures a Service. | |
| @@ -166,21 +170,44 @@ func (s *Service) housekeeping() { | |||
| 166 | 170 | } | |
| 167 | 171 | ||
| 168 | 172 | // Token returns the ACTIONS_RUNTIME_TOKEN of a job. It is valid for ttl, which | |
| 169 | - | // should be the job timeout plus a margin. | |
| 170 | - | func (s *Service) Token(jobID string, sc Scope, ttl time.Duration) string { | |
| 171 | - | return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: s.now().Add(ttl).Unix()}) | |
| 173 | + | // should be the job timeout plus a margin. sc is the job's cache scope (the | |
| 174 | + | // zero Scope for a job without a cache: the cache then refuses the token); | |
| 175 | + | // scopes are added to its scp claim (resultssvc.Scope for a job with the | |
| 176 | + | // results service). | |
| 177 | + | func (s *Service) Token(jobID string, sc Scope, ttl time.Duration, scopes ...string) string { | |
| 178 | + | now := s.now() | |
| 179 | + | exp := now.Add(ttl).Unix() | |
| 180 | + | return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: exp, ExpiresAt: exp, IssuedAt: now.Unix(), | |
| 181 | + | Scopes: strings.Join(append([]string{genericReadScope}, scopes...), " ")}) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // RuntimeToken checks a runtime token for the results service (resultssvc.Auth): | |
| 185 | + | // made by this runner and not expired, whatever its cache scope. It returns | |
| 186 | + | // the job, the scp claim and the expiry. | |
| 187 | + | func (s *Service) RuntimeToken(tok string) (jobID, scopes string, expiry time.Time, err error) { | |
| 188 | + | c, err := s.sign.verifyAny(tok, s.now()) | |
| 189 | + | if err != nil { | |
| 190 | + | return "", "", time.Time{}, err | |
| 191 | + | } | |
| 192 | + | return c.JobID, c.Scopes, time.Unix(c.Expiry, 0), nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // BlobMAC signs the blob URLs of the results service (resultssvc.Auth), under | |
| 196 | + | // a key domain of their own. | |
| 197 | + | func (s *Service) BlobMAC(parts ...[]byte) []byte { | |
| 198 | + | return s.sign.mac("blob", parts...) | |
| 172 | 199 | } | |
| 173 | 200 | ||
| 174 | 201 | // start serves HTTP on ln, subject to the connection caps, until the listener | |
| 175 | 202 | // is closed. job, when not empty, restricts the tokens accepted to that job's. | |
| 176 | - | func (s *Service) start(name string, ln net.Listener, job string, bw *bandwidth) *listener { | |
| 203 | + | func (s *Service) start(name string, ln net.Listener, job string, bw *bandwidth, results http.Handler) *listener { | |
| 177 | 204 | if bw != nil { | |
| 178 | 205 | ln = throttleListener{ln, bw} | |
| 179 | 206 | } | |
| 180 | 207 | ln = newLimitListener(ln, s.perListener, s.connsGlobal) | |
| 181 | 208 | l := &listener{ | |
| 182 | 209 | srv: &http.Server{ | |
| 183 | - | Handler: s.handler(job), | |
| 210 | + | Handler: s.channelHandler(job, results), | |
| 184 | 211 | ReadHeaderTimeout: 30 * time.Second, | |
| 185 | 212 | // Bodies are chunks of up to tens of MiB and archives of GiB: only | |
| 186 | 213 | // idle connections time out. | |
| @@ -227,7 +254,7 @@ func (s *Service) Serve(id string, ln net.Listener, job string, opts ...ServeOpt | |||
| 227 | 254 | _ = ln.Close() | |
| 228 | 255 | return fmt.Errorf("cache: already serving %s", id) | |
| 229 | 256 | } | |
| 230 | - | s.channels[id] = s.start(id, ln, job, newBandwidth(sc.bytesPerSec)) | |
| 257 | + | s.channels[id] = s.start(id, ln, job, newBandwidth(sc.bytesPerSec), sc.results) | |
| 231 | 258 | return nil | |
| 232 | 259 | } | |
| 233 | 260 | ||
M internal/cachesvc/service_test.go +8 -4
| @@ -370,15 +370,19 @@ func TestBadTokens(t *testing.T) { | |||
| 370 | 370 | good := r.job("j1", repoA, main_, main_) | |
| 371 | 371 | other := newRig(t, 1<<20) // another runner: another key | |
| 372 | 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 | + | seg := strings.Split(good.token, ".") | |
| 373 | 374 | tampered := func() string { | |
| 374 | - | p, m, _ := strings.Cut(good.token, ".") | |
| 375 | - | payload, _ := b64.DecodeString(p) | |
| 375 | + | payload, _ := b64.DecodeString(seg[1]) | |
| 376 | 376 | payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1) | |
| 377 | - | return b64.EncodeToString(payload) + "." + m | |
| 377 | + | return seg[0] + "." + b64.EncodeToString(payload) + "." + seg[2] | |
| 378 | 378 | }() | |
| 379 | + | algNone := b64.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + "." + seg[1] + "." | |
| 380 | + | // A token for the results service only has no cache scope. | |
| 381 | + | resultsOnly := r.svc.Token("j1", Scope{}, time.Hour, "Actions.Results:1:x") | |
| 379 | 382 | for name, tok := range map[string]string{ | |
| 380 | 383 | "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] + ".", | |
| 384 | + | "expired": expired, "tampered claims": tampered, "no mac": seg[0] + "." + seg[1] + ".", | |
| 385 | + | "two segments": seg[1] + "." + seg[2], "alg none": algNone, "results only": resultsOnly, | |
| 382 | 386 | } { | |
| 383 | 387 | j := &job{r: r, token: tok} | |
| 384 | 388 | 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"}} { | |
M internal/cachesvc/token.go +54 -12
| @@ -28,14 +28,27 @@ type Scope struct { | |||
| 28 | 28 | ReadRefs []string `json:"rd,omitempty"` | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | - | // Claims are what a cache token vouches for. | |
| 31 | + | // Claims are what a runtime token (ACTIONS_RUNTIME_TOKEN) vouches for: the | |
| 32 | + | // job, its cache scope and its other scopes. The token is shared by the cache | |
| 33 | + | // and the results service of the job's channel. | |
| 32 | 34 | type Claims struct { | |
| 33 | 35 | JobID string `json:"j"` | |
| 34 | 36 | Scope | |
| 35 | 37 | // Expiry is a Unix time in seconds. | |
| 36 | 38 | Expiry int64 `json:"x"` | |
| 39 | + | // ExpiresAt and IssuedAt are the JWT's exp and iat; exp is Expiry. | |
| 40 | + | ExpiresAt int64 `json:"exp"` | |
| 41 | + | IssuedAt int64 `json:"iat"` | |
| 42 | + | // Scopes is the scp claim: space-separated scopes, as GitHub's runtime | |
| 43 | + | // token has them. @actions/artifact decodes the token (without checking | |
| 44 | + | // it) and takes the run and job ids from its Actions.Results scope | |
| 45 | + | // (toolkit packages/artifact/src/internal/shared/util.ts:21-75). | |
| 46 | + | Scopes string `json:"scp"` | |
| 37 | 47 | } | |
| 38 | 48 | ||
| 49 | + | // genericReadScope is the first scope of GitHub's runtime tokens. | |
| 50 | + | const genericReadScope = "Actions.GenericRead:00000000-0000-0000-0000-000000000000" | |
| 51 | + | ||
| 39 | 52 | // errForbidden is every authentication failure: the caller learns nothing about | |
| 40 | 53 | // why a token was refused. | |
| 41 | 54 | var errForbidden = errors.New("cache: forbidden") | |
| @@ -71,29 +84,58 @@ var b64 = base64.RawURLEncoding | |||
| 71 | 84 | // maxTokenLen bounds what verify parses. | |
| 72 | 85 | const maxTokenLen = 16 << 10 | |
| 73 | 86 | ||
| 74 | - | // token is base64url(JSON claims) "." base64url(HMAC-SHA256 of the JSON bytes). | |
| 87 | + | // jwtHeader is the header of every runtime token, as base64url. | |
| 88 | + | var jwtHeader = b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) | |
| 89 | + | ||
| 90 | + | // jwtKey is the HS256 key of the tokens: derived from the signer's key under | |
| 91 | + | // the "token" domain, so that no other signature of the signer (archive or | |
| 92 | + | // blob URLs) can ever pass for a token, and the other way round. | |
| 93 | + | func (s *signer) jwtKey() []byte { return s.mac("token") } | |
| 94 | + | ||
| 95 | + | // token is a three-segment JWT (header, claims, HS256 signature), which is what | |
| 96 | + | // @actions/artifact requires of ACTIONS_RUNTIME_TOKEN; @actions/cache sends it | |
| 97 | + | // as an opaque bearer token. | |
| 75 | 98 | func (s *signer) token(c Claims) string { | |
| 76 | 99 | payload, _ := json.Marshal(c) // plain strings and ints: cannot fail | |
| 77 | - | return b64.EncodeToString(payload) + "." + b64.EncodeToString(s.mac("token", payload)) | |
| 100 | + | signed := jwtHeader + "." + b64.EncodeToString(payload) | |
| 101 | + | h := hmac.New(sha256.New, s.jwtKey()) | |
| 102 | + | h.Write([]byte(signed)) | |
| 103 | + | return signed + "." + b64.EncodeToString(h.Sum(nil)) | |
| 78 | 104 | } | |
| 79 | 105 | ||
| 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) { | |
| 106 | + | // verifyAny returns the claims of a token that this runner made and that has | |
| 107 | + | // not expired, whatever its scopes. The comparison is constant time. | |
| 108 | + | func (s *signer) verifyAny(tok string, now time.Time) (Claims, error) { | |
| 83 | 109 | var c Claims | |
| 84 | 110 | if len(tok) > maxTokenLen { | |
| 85 | 111 | return c, errForbidden | |
| 86 | 112 | } | |
| 87 | - | p, m, ok := strings.Cut(tok, ".") | |
| 88 | - | if !ok { | |
| 113 | + | head, rest, ok1 := strings.Cut(tok, ".") | |
| 114 | + | p, m, ok2 := strings.Cut(rest, ".") | |
| 115 | + | if !ok1 || !ok2 || head != jwtHeader { | |
| 116 | + | return c, errForbidden | |
| 117 | + | } | |
| 118 | + | sum, err := b64.DecodeString(m) | |
| 119 | + | if err != nil { | |
| 89 | 120 | return c, errForbidden | |
| 90 | 121 | } | |
| 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)) { | |
| 122 | + | h := hmac.New(sha256.New, s.jwtKey()) | |
| 123 | + | h.Write([]byte(head + "." + p)) | |
| 124 | + | if !hmac.Equal(sum, h.Sum(nil)) { | |
| 94 | 125 | return c, errForbidden | |
| 95 | 126 | } | |
| 96 | - | if err := json.Unmarshal(payload, &c); err != nil || c.Repository == "" || now.Unix() >= c.Expiry { | |
| 127 | + | payload, err := b64.DecodeString(p) | |
| 128 | + | if err != nil || json.Unmarshal(payload, &c) != nil || c.JobID == "" || now.Unix() >= c.Expiry { | |
| 129 | + | return Claims{}, errForbidden | |
| 130 | + | } | |
| 131 | + | return c, nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // verify is verifyAny for the cache: the token must carry a cache scope (a | |
| 135 | + | // token made for the results service only has none). | |
| 136 | + | func (s *signer) verify(tok string, now time.Time) (Claims, error) { | |
| 137 | + | c, err := s.verifyAny(tok, now) | |
| 138 | + | if err != nil || c.Repository == "" { | |
| 97 | 139 | return Claims{}, errForbidden | |
| 98 | 140 | } | |
| 99 | 141 | return c, nil | |
A internal/cachesvc/token_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package cachesvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/hmac" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/json" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // The runtime token is a JWT as @actions/artifact reads it: three base64url | |
| 17 | + | // segments, a JSON header and claims that jwt-decode accepts, and an scp claim | |
| 18 | + | // whose Actions.Results entry splits on ':' into the run and job ids | |
| 19 | + | // (toolkit packages/artifact/src/internal/shared/util.ts:21-75). Its signature | |
| 20 | + | // is a real HS256 under the key derived for tokens. | |
| 21 | + | func TestRuntimeTokenIsAJWT(t *testing.T) { | |
| 22 | + | r := newRig(t, 1<<20) | |
| 23 | + | tok := r.svc.Token("j1", Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, time.Hour, "Actions.Results:42:abc") | |
| 24 | + | seg := strings.Split(tok, ".") | |
| 25 | + | if len(seg) != 3 { | |
| 26 | + | t.Fatalf("%d segments", len(seg)) | |
| 27 | + | } | |
| 28 | + | var hdr map[string]string | |
| 29 | + | if b, err := b64.DecodeString(seg[0]); err != nil || json.Unmarshal(b, &hdr) != nil || hdr["alg"] != "HS256" || hdr["typ"] != "JWT" { | |
| 30 | + | t.Fatalf("header %q: %v", seg[0], hdr) | |
| 31 | + | } | |
| 32 | + | var c struct { | |
| 33 | + | Scp string `json:"scp"` | |
| 34 | + | Exp int64 `json:"exp"` | |
| 35 | + | Iat int64 `json:"iat"` | |
| 36 | + | J string `json:"j"` | |
| 37 | + | } | |
| 38 | + | if b, err := b64.DecodeString(seg[1]); err != nil || json.Unmarshal(b, &c) != nil { | |
| 39 | + | t.Fatalf("claims %q", seg[1]) | |
| 40 | + | } | |
| 41 | + | now := r.clk.now().Unix() | |
| 42 | + | if c.J != "j1" || c.Iat != now || c.Exp != now+3600 { | |
| 43 | + | t.Fatalf("claims %+v", c) | |
| 44 | + | } | |
| 45 | + | scopes := strings.Fields(c.Scp) | |
| 46 | + | if len(scopes) != 2 || scopes[0] != genericReadScope || scopes[1] != "Actions.Results:42:abc" { | |
| 47 | + | t.Fatalf("scp %q", c.Scp) | |
| 48 | + | } | |
| 49 | + | if p := strings.Split(scopes[1], ":"); len(p) != 3 || p[1] != "42" || p[2] != "abc" { | |
| 50 | + | t.Fatalf("results scope %q", scopes[1]) | |
| 51 | + | } | |
| 52 | + | h := hmac.New(sha256.New, r.svc.sign.mac("token")) | |
| 53 | + | h.Write([]byte(seg[0] + "." + seg[1])) | |
| 54 | + | if got, _ := b64.DecodeString(seg[2]); !hmac.Equal(got, h.Sum(nil)) { | |
| 55 | + | t.Fatal("not an HS256 signature under the token key") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | job, scp, exp, err := r.svc.RuntimeToken(tok) | |
| 59 | + | if err != nil || job != "j1" || scp != c.Scp || exp.Unix() != c.Exp { | |
| 60 | + | t.Fatalf("RuntimeToken: %q %q %v %v", job, scp, exp, err) | |
| 61 | + | } | |
| 62 | + | // A cache-only token has no results scope; a results-only one is accepted | |
| 63 | + | // by RuntimeToken but not by the cache (TestBadTokens). | |
| 64 | + | if _, scp, _, _ := r.svc.RuntimeToken(r.job("j2", repoA, main_, main_).token); strings.Contains(scp, "Actions.Results") { | |
| 65 | + | t.Fatalf("cache-only token with %q", scp) | |
| 66 | + | } | |
| 67 | + | if job, _, _, err := r.svc.RuntimeToken(r.svc.Token("j3", Scope{}, time.Hour, "Actions.Results:1:x")); err != nil || job != "j3" { | |
| 68 | + | t.Fatalf("results-only token: %q %v", job, err) | |
| 69 | + | } | |
| 70 | + | if _, _, _, err := r.svc.RuntimeToken(tok[:len(tok)-2]); err == nil { | |
| 71 | + | t.Fatal("bad signature accepted") | |
| 72 | + | } | |
| 73 | + | // Blob signatures have a domain of their own. | |
| 74 | + | if hmac.Equal(r.svc.BlobMAC(), r.svc.sign.mac("token")) || hmac.Equal(r.svc.BlobMAC([]byte("x")), r.svc.sign.mac("archive", []byte("x"))) { | |
| 75 | + | t.Fatal("blob MAC shares a domain") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // With WithResults, the channel sends the cache API to the cache and every | |
| 80 | + | // other path to the results handler. | |
| 81 | + | func TestChannelRoutesResults(t *testing.T) { | |
| 82 | + | r := newRig(t, 1<<20) | |
| 83 | + | results := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { | |
| 84 | + | b, _ := io.ReadAll(req.Body) | |
| 85 | + | _, _ = io.WriteString(w, "results "+req.URL.Path+" "+string(b)) | |
| 86 | + | }) | |
| 87 | + | c := channel(t, r.svc, "er0000000a", "job-a", WithResults(results)) | |
| 88 | + | tok := r.job("job-a", repoA, main_, main_).token | |
| 89 | + | if got := lookupStatus(t, c, tok); got != http.StatusNoContent { | |
| 90 | + | t.Fatalf("cache lookup: %d", got) | |
| 91 | + | } | |
| 92 | + | resp, err := c.Post("http://cache/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", "application/json", strings.NewReader("{}")) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | b, _ := io.ReadAll(resp.Body) | |
| 97 | + | _ = resp.Body.Close() | |
| 98 | + | if string(b) != "results /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts {}" { | |
| 99 | + | t.Fatalf("results: %d %q", resp.StatusCode, b) | |
| 100 | + | } | |
| 101 | + | // Without it, those paths are not found. | |
| 102 | + | plain := channel(t, r.svc, "er0000000b", "job-b") | |
| 103 | + | resp, err = plain.Post("http://cache/twirp/x", "application/json", strings.NewReader("{}")) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | _ = resp.Body.Close() | |
| 108 | + | if resp.StatusCode != http.StatusNotFound { | |
| 109 | + | t.Fatalf("no results handler: %d", resp.StatusCode) | |
| 110 | + | } | |
| 111 | + | } | |
M internal/execstream/exec.proto +12 -3
| @@ -95,8 +95,8 @@ message JobSpec { | |||
| 95 | 95 | // ACTIONS_CACHE_URL=http://127.0.0.1:<port>/ and the token as | |
| 96 | 96 | // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official | |
| 97 | 97 | // runner does, and masks the token. The token is not an ermite credential: it | |
| 98 | - | // is an HMAC that only the runner's cache service accepts, scoped to this | |
| 99 | - | // job's repository and refs. | |
| 98 | + | // is a JWT (HS256) that only the runner's runtime services accept, scoped to | |
| 99 | + | // this job's repository and refs (and its run, see results). | |
| 100 | 100 | uint32 cache_port = 15; | |
| 101 | 101 | string cache_token = 16; | |
| 102 | 102 | // Job.secrets and Job.vars are not here: they come in NamedValue frames | |
| @@ -117,8 +117,17 @@ message JobSpec { | |||
| 117 | 117 | TokenPermissions token_permissions = 20; | |
| 118 | 118 | // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write" | |
| 119 | 119 | // when the job's cache scope has a write ref, "read" when it has none. Unset | |
| 120 | - | // without a cache service. | |
| 120 | + | // without a cache service, and unset when the channel serves the results | |
| 121 | + | // service only (results set, no cache scope): no ACTIONS_CACHE_URL then. | |
| 121 | 122 | string cache_mode = 21; | |
| 123 | + | // results says that the service behind cache_port also serves artifacts | |
| 124 | + | // (Twirp github.actions.results.api.v1.ArtifactService and its blobs) and | |
| 125 | + | // that cache_token carries the job's Actions.Results scope. The executor | |
| 126 | + | // then gives node and docker actions ACTIONS_RESULTS_URL (the same origin as | |
| 127 | + | // ACTIONS_CACHE_URL) and sets GITHUB_SERVER_URL to a *.ghe.localhost host in | |
| 128 | + | // the process of the node steps of the artifact actions only (README, | |
| 129 | + | // "Artifacts"). Set only when the server has the feature ci.artifacts. | |
| 130 | + | bool results = 22; | |
| 122 | 131 | } | |
| 123 | 132 | ||
| 124 | 133 | // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to | |
M internal/execstream/execpb/exec.pb.go +23 -6 generated file: collapsed
A internal/executor/actions_artifacts.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/action" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // @actions/artifact v2 (upload-artifact and download-artifact v4 and later) | |
| 15 | + | // refuses to run when it believes the server is GitHub Enterprise Server: | |
| 16 | + | // isGhes() (toolkit packages/artifact/src/internal/shared/config.ts) reads | |
| 17 | + | // GITHUB_SERVER_URL only, and any host that is not github.com, *.ghe.com or | |
| 18 | + | // *.localhost is GHES. ermite's server URL is its own, so the node steps that | |
| 19 | + | // run that client get a server URL the client takes for GitHub, in the | |
| 20 | + | // environment of their process only. It is the one place where ermite changes | |
| 21 | + | // the environment of an action. | |
| 22 | + | const ( | |
| 23 | + | // artifactsServerURL is the GITHUB_SERVER_URL those steps get. isGhes() | |
| 24 | + | // treats *.localhost as GitHub; the host is never resolved nor connected to | |
| 25 | + | // (the client talks to ACTIONS_RESULTS_URL only). | |
| 26 | + | artifactsServerURL = "https://ermite.ghe.localhost" | |
| 27 | + | // ghesMessage is the error @actions/artifact v2 throws on GHES | |
| 28 | + | // (packages/artifact/src/internal/shared/errors.ts:32): a script that holds | |
| 29 | + | // it bundles the client. | |
| 30 | + | ghesMessage = "@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES." | |
| 31 | + | // sniffChunk is the size of the reads of the sniff. | |
| 32 | + | sniffChunk = 64 << 10 | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // artifactRepos are the repository actions that use @actions/artifact v2, any | |
| 36 | + | // path inside them included (actions/upload-artifact/merge). | |
| 37 | + | var artifactRepos = []string{"actions/upload-artifact", "actions/download-artifact"} | |
| 38 | + | ||
| 39 | + | // setArtifactsServerURL decides the GITHUB_SERVER_URL of the process of a node | |
| 40 | + | // action step that runs script (main, pre or post): artifactsServerURL when the | |
| 41 | + | // job has the results service and the step is an artifact action or its script | |
| 42 | + | // bundles @actions/artifact v2, else the job's own value. | |
| 43 | + | func (j *job) setArtifactsServerURL(s *stepRun, script string) { | |
| 44 | + | s.serverURL = "" | |
| 45 | + | if !j.cfg.Results { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | if !isArtifactAction(s.n) && !j.bundlesArtifactClient(script) { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | s.serverURL = artifactsServerURL | |
| 52 | + | j.debug(s.num, "GITHUB_SERVER_URL set to "+artifactsServerURL+" for the artifacts service") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // isArtifactAction reports whether the step uses one of artifactRepos, in any | |
| 56 | + | // case. | |
| 57 | + | func isArtifactAction(n *node) bool { | |
| 58 | + | if n == nil || n.uses.Kind != action.UsesRepository { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | name := n.uses.Owner + "/" + n.uses.Repo | |
| 62 | + | for _, r := range artifactRepos { | |
| 63 | + | if strings.EqualFold(name, r) { | |
| 64 | + | return true | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // bundlesArtifactClient reports whether the file holds ghesMessage. The answer | |
| 71 | + | // is kept per path for the job: the pre, main and post stages and other uses of | |
| 72 | + | // the action read it once. A file that cannot be read does not hold it. | |
| 73 | + | func (j *job) bundlesArtifactClient(script string) bool { | |
| 74 | + | j.mu.Lock() | |
| 75 | + | hit, ok := j.ghesSniff[script] | |
| 76 | + | j.mu.Unlock() | |
| 77 | + | if ok { | |
| 78 | + | return hit | |
| 79 | + | } | |
| 80 | + | hit = fileContains(script, []byte(ghesMessage)) | |
| 81 | + | j.mu.Lock() | |
| 82 | + | if j.ghesSniff == nil { | |
| 83 | + | j.ghesSniff = map[string]bool{} | |
| 84 | + | } | |
| 85 | + | j.ghesSniff[script] = hit | |
| 86 | + | j.mu.Unlock() | |
| 87 | + | return hit | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // fileContains reads the file in chunks and reports whether it holds needle; | |
| 91 | + | // len(needle)-1 bytes of a chunk are kept for the next, so a match across two | |
| 92 | + | // reads is found. A read error is a miss. | |
| 93 | + | func fileContains(name string, needle []byte) bool { | |
| 94 | + | if len(needle) == 0 { | |
| 95 | + | return true | |
| 96 | + | } | |
| 97 | + | f, err := os.Open(name) | |
| 98 | + | if err != nil { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | defer func() { _ = f.Close() }() | |
| 102 | + | keep := len(needle) - 1 | |
| 103 | + | buf := make([]byte, keep+sniffChunk) | |
| 104 | + | have := 0 | |
| 105 | + | for { | |
| 106 | + | n, err := f.Read(buf[have:]) | |
| 107 | + | have += n | |
| 108 | + | if bytes.Contains(buf[:have], needle) { | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | if err == io.EOF { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | if err != nil { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | if have > keep { | |
| 118 | + | have = copy(buf, buf[have-keep:have]) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
A internal/executor/actions_artifacts_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // withResults is a job whose runtime channel serves the cache and the results | |
| 14 | + | // service. | |
| 15 | + | func withResults(c *Config) { withCache(c); c.Results = true } | |
| 16 | + | ||
| 17 | + | // ACTIONS_RESULTS_URL (NodeScriptActionHandler.cs:71-84) is the runtime URL, | |
| 18 | + | // for node actions, when the job has the results service only. | |
| 19 | + | func TestNodeActionGetsResultsURL(t *testing.T) { | |
| 20 | + | tree := files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript} | |
| 21 | + | for _, tc := range []struct { | |
| 22 | + | name string | |
| 23 | + | tweak func(*Config) | |
| 24 | + | want string | |
| 25 | + | }{ | |
| 26 | + | {"results", withResults, "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[" + cacheTestURL + "]"}, | |
| 27 | + | {"cache only", withCache, "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"}, | |
| 28 | + | // A channel that serves results only: no cache variables. | |
| 29 | + | {"results only", func(c *Config) { withResults(c); c.CacheMode = "write"; c.CacheOff = true }, | |
| 30 | + | "mode=[unset] url=[] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[" + cacheTestURL + "]"}, | |
| 31 | + | } { | |
| 32 | + | rec, res := actionRun(t, tree, " - uses: ./.github/actions/e\n", tc.tweak) | |
| 33 | + | if res.Conclusion != ConclusionSuccess { | |
| 34 | + | t.Fatalf("%s: %+v", tc.name, res) | |
| 35 | + | } | |
| 36 | + | if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, tc.want) { | |
| 37 | + | t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ContainerActionHandler.cs:237-245: a docker action gets ACTIONS_RESULTS_URL | |
| 43 | + | // too, with the URL of the bridge (its network is not the sandbox's), and no | |
| 44 | + | // GITHUB_SERVER_URL of its own. | |
| 45 | + | func TestDockerActionGetsResultsURL(t *testing.T) { | |
| 46 | + | for _, tc := range []struct { | |
| 47 | + | name string | |
| 48 | + | tweak func(*Config) | |
| 49 | + | want string | |
| 50 | + | }{ | |
| 51 | + | {"results", withResults, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=" + cacheTestContainerURL + " RESULTS=" + cacheTestContainerURL + " SERVER=https://ermite.example\n"}, | |
| 52 | + | {"cache only", withCache, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=" + cacheTestContainerURL + " RESULTS=unset SERVER=https://ermite.example\n"}, | |
| 53 | + | {"results only", func(c *Config) { withResults(c); c.CacheOff = true }, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=unset RESULTS=" + cacheTestContainerURL + " SERVER=https://ermite.example\n"}, | |
| 54 | + | // No bridge in the sandbox: the container gets nothing. | |
| 55 | + | {"no bridge", func(c *Config) { withResults(c); c.ContainerCacheURL = "" }, "RUN-RT RUNTIME=unset CACHE=unset RESULTS=unset SERVER=https://ermite.example\n"}, | |
| 56 | + | } { | |
| 57 | + | d, _ := fakeDocker(t, 0) | |
| 58 | + | rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) { | |
| 59 | + | c.Actions.DockerPath = d | |
| 60 | + | tc.tweak(c) | |
| 61 | + | }) | |
| 62 | + | if res.Conclusion != ConclusionSuccess { | |
| 63 | + | t.Fatalf("%s: %+v %v", tc.name, res, rec.names()) | |
| 64 | + | } | |
| 65 | + | if got := logOf(rec.steps[2]); !strings.Contains(got, tc.want) { | |
| 66 | + | t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // A node action in the job container reaches the relay at the bridge URL; a | |
| 72 | + | // run step there gets no runtime variable. | |
| 73 | + | func TestContainerStepsGetContainerURL(t *testing.T) { | |
| 74 | + | tree := files{ | |
| 75 | + | ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n", | |
| 76 | + | ".github/actions/x/index.js": "x\n", | |
| 77 | + | } | |
| 78 | + | vars := "ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL ACTIONS_RESULTS_URL GITHUB_SERVER_URL" | |
| 79 | + | for _, tc := range []struct { | |
| 80 | + | name string | |
| 81 | + | tweak func(*Config) | |
| 82 | + | want string | |
| 83 | + | }{ | |
| 84 | + | {"bridge", withResults, "VAR ACTIONS_RUNTIME_URL=" + cacheTestContainerURL + "\nVAR ACTIONS_CACHE_URL=" + cacheTestContainerURL + "\nVAR ACTIONS_RESULTS_URL=" + cacheTestContainerURL + "\nVAR GITHUB_SERVER_URL=https://ermite.example"}, | |
| 85 | + | {"no bridge", func(c *Config) { withResults(c); c.ContainerCacheURL = "" }, "VAR ACTIONS_RUNTIME_URL=unset\nVAR ACTIONS_CACHE_URL=unset\nVAR ACTIONS_RESULTS_URL=unset\n"}, | |
| 86 | + | } { | |
| 87 | + | f := newFakeCtr(t) | |
| 88 | + | f.set(t, "exec_vars", vars) | |
| 89 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n - name: r\n run: echo\n", func(c *Config) { | |
| 90 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 91 | + | tc.tweak(c) | |
| 92 | + | }) | |
| 93 | + | if res.Conclusion != ConclusionSuccess { | |
| 94 | + | t.Fatalf("%s: %+v\n%s", tc.name, res, logOf(rec.step(t, "a"))) | |
| 95 | + | } | |
| 96 | + | if got := logOf(rec.step(t, "a")); !strings.Contains(got, tc.want) { | |
| 97 | + | t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got) | |
| 98 | + | } | |
| 99 | + | if got := logOf(rec.step(t, "r")); !strings.Contains(got, "VAR ACTIONS_RUNTIME_URL=unset\nVAR ACTIONS_CACHE_URL=unset\nVAR ACTIONS_RESULTS_URL=unset\n") { | |
| 100 | + | t.Errorf("%s: run step:\n%s", tc.name, got) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // serverAction prints the GITHUB_SERVER_URL of its process and the | |
| 106 | + | // github.server_url of its inputs, in its pre, main and post stages. | |
| 107 | + | const serverAction = `name: s | |
| 108 | + | inputs: | |
| 109 | + | ctx: | |
| 110 | + | default: "${{ github.server_url }}" | |
| 111 | + | runs: | |
| 112 | + | using: node20 | |
| 113 | + | pre: pre.sh | |
| 114 | + | main: main.sh | |
| 115 | + | post: post.sh | |
| 116 | + | ` | |
| 117 | + | ||
| 118 | + | func serverScripts(extra string) files { | |
| 119 | + | f := files{"action.yml": serverAction} | |
| 120 | + | for _, st := range []string{"pre", "main", "post"} { | |
| 121 | + | f[st+".sh"] = extra + "echo \"" + st + " env=[$GITHUB_SERVER_URL] ctx=[$INPUT_CTX]\"\n" | |
| 122 | + | } | |
| 123 | + | return f | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // serverForge serves, under each of the given repositories, an action that | |
| 127 | + | // prints its server URL, at the root and in merge/. | |
| 128 | + | func serverForge(t *testing.T, names ...string) *fakeForge { | |
| 129 | + | tree := files{} | |
| 130 | + | for k, v := range serverScripts("") { | |
| 131 | + | tree[k], tree["merge/"+k] = v, v | |
| 132 | + | } | |
| 133 | + | archives := map[string][]byte{} | |
| 134 | + | for _, n := range names { | |
| 135 | + | archives["/tarball/"+n+"/v4"] = tarOf(t, strings.ReplaceAll(n, "/", "-")+"-x", tree) | |
| 136 | + | } | |
| 137 | + | return newForge(t, archives) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | const ghesURL = "env=[" + artifactsServerURL + "] ctx=[https://ermite.example]" | |
| 141 | + | const ownURL = "env=[https://ermite.example] ctx=[https://ermite.example]" | |
| 142 | + | ||
| 143 | + | // The node steps of upload-artifact and download-artifact (any case, any path | |
| 144 | + | // inside them) get a GITHUB_SERVER_URL @actions/artifact takes for GitHub, in | |
| 145 | + | // pre, main and post; the github context and the next steps keep the job's. | |
| 146 | + | func TestArtifactActionsGetServerURL(t *testing.T) { | |
| 147 | + | for _, uses := range []string{"actions/upload-artifact@v4", "actions/download-artifact@v4", "actions/upload-artifact/merge@v4", "Actions/Upload-Artifact@v4", "ACTIONS/download-artifact@v4"} { | |
| 148 | + | name, _, _ := strings.Cut(uses, "@") | |
| 149 | + | repo := strings.Join(strings.SplitN(name, "/", 3)[:2], "/") | |
| 150 | + | forge := serverForge(t, repo) | |
| 151 | + | rec, res := actionRun(t, files{}, " - uses: "+uses+"\n - run: echo \"after=[$GITHUB_SERVER_URL] [${{ github.server_url }}]\"\n", func(c *Config) { | |
| 152 | + | withResults(c) | |
| 153 | + | c.Debug = true | |
| 154 | + | c.Actions.Resolver = forge.resolver("") | |
| 155 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 156 | + | }) | |
| 157 | + | if res.Conclusion != ConclusionSuccess { | |
| 158 | + | t.Fatalf("%s: %+v %v\n%s", uses, res, rec.names(), logOf(rec.steps[0])) | |
| 159 | + | } | |
| 160 | + | for _, st := range []string{"Pre Run " + uses, "Run " + uses, "Post Run " + uses} { | |
| 161 | + | got := logOf(rec.step(t, st)) | |
| 162 | + | if !strings.Contains(got, ghesURL) { | |
| 163 | + | t.Errorf("%s: %s:\n%s", uses, st, got) | |
| 164 | + | } | |
| 165 | + | if strings.Count(got, "##[debug]GITHUB_SERVER_URL set to "+artifactsServerURL+" for the artifacts service") != 1 { | |
| 166 | + | t.Errorf("%s: %s: the override is logged once:\n%s", uses, st, got) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | if got := logOf(rec.step(t, "Run echo \"after=[$GITHUB_SERVER_URL] [https://ermite.example]\"")); !strings.Contains(got, "after=[https://ermite.example] [https://ermite.example]") { | |
| 170 | + | t.Errorf("%s: the next step:\n%s", uses, got) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // A node action in a composite action is one too. | |
| 176 | + | func TestArtifactActionInCompositeGetsServerURL(t *testing.T) { | |
| 177 | + | forge := serverForge(t, "actions/upload-artifact") | |
| 178 | + | tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: actions/upload-artifact@v4\n"} | |
| 179 | + | rec, res := actionRun(t, tree, " - uses: ./c\n", func(c *Config) { withResults(c); c.Actions.Resolver = forge.resolver("") }) | |
| 180 | + | if res.Conclusion != ConclusionSuccess { | |
| 181 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 182 | + | } | |
| 183 | + | if got := logOf(rec.step(t, "Run ./c")); !strings.Contains(got, "main "+ghesURL) { | |
| 184 | + | t.Errorf("composite:\n%s", got) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Without the results service, or for another action, nothing changes. | |
| 189 | + | func TestServerURLUnchangedForOtherActions(t *testing.T) { | |
| 190 | + | forge := serverForge(t, "actions/upload-artifact", "octo/tool") | |
| 191 | + | for _, tc := range []struct { | |
| 192 | + | uses string | |
| 193 | + | tweak func(*Config) | |
| 194 | + | }{{"actions/upload-artifact@v4", withCache}, {"octo/tool@v4", withResults}} { | |
| 195 | + | rec, res := actionRun(t, files{}, " - uses: "+tc.uses+"\n", func(c *Config) { | |
| 196 | + | tc.tweak(c) | |
| 197 | + | c.Debug = true | |
| 198 | + | c.Actions.Resolver = forge.resolver("") | |
| 199 | + | }) | |
| 200 | + | if res.Conclusion != ConclusionSuccess { | |
| 201 | + | t.Fatalf("%s: %+v %v", tc.uses, res, rec.names()) | |
| 202 | + | } | |
| 203 | + | for _, s := range rec.steps { | |
| 204 | + | if got := logOf(s); strings.Contains(got, artifactsServerURL) { | |
| 205 | + | t.Errorf("%s: %s:\n%s", tc.uses, s.Name, got) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if got := logOf(rec.step(t, "Run "+tc.uses)); !strings.Contains(got, "main "+ownURL) { | |
| 209 | + | t.Errorf("%s:\n%s", tc.uses, got) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // A script that bundles @actions/artifact v2 (its GHES message is in it) gets | |
| 215 | + | // the URL whatever the action; a run step and a docker action never do. | |
| 216 | + | func TestBundledArtifactClientGetsServerURL(t *testing.T) { | |
| 217 | + | tree := files{} | |
| 218 | + | for k, v := range serverScripts("# throw new GHESNotSupportedError('" + ghesMessage + "')\n") { | |
| 219 | + | tree["a/"+k] = v | |
| 220 | + | } | |
| 221 | + | d, _ := fakeDocker(t, 0) | |
| 222 | + | rec, res := actionRun(t, tree, " - uses: ./a\n - run: echo \"run=[$GITHUB_SERVER_URL]\"\n - uses: docker://alpine:3.20\n", func(c *Config) { | |
| 223 | + | withResults(c) | |
| 224 | + | c.Actions.DockerPath = d | |
| 225 | + | }) | |
| 226 | + | if res.Conclusion != ConclusionSuccess { | |
| 227 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 228 | + | } | |
| 229 | + | // A local action has no pre stage (it is not downloaded before the job). | |
| 230 | + | for _, st := range []string{"Run ./a", "Post Run ./a"} { | |
| 231 | + | if got := logOf(rec.step(t, st)); !strings.Contains(got, ghesURL) { | |
| 232 | + | t.Errorf("%s:\n%s", st, got) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | if got := logOf(rec.step(t, "Run echo \"run=[$GITHUB_SERVER_URL]\"")); !strings.Contains(got, "run=[https://ermite.example]") { | |
| 236 | + | t.Errorf("run step:\n%s", got) | |
| 237 | + | } | |
| 238 | + | if got := logOf(rec.step(t, "Run alpine:3.20")); !strings.Contains(got, "SERVER=https://ermite.example\n") { | |
| 239 | + | t.Errorf("docker action:\n%s", got) | |
| 240 | + | } | |
| 241 | + | // The same script without the results service: no change. | |
| 242 | + | rec, res = actionRun(t, tree, " - uses: ./a\n", withCache) | |
| 243 | + | if res.Conclusion != ConclusionSuccess { | |
| 244 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 245 | + | } | |
| 246 | + | if got := logOf(rec.step(t, "Run ./a")); !strings.Contains(got, "main "+ownURL) { | |
| 247 | + | t.Errorf("without results:\n%s", got) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // The sniff finds the message across the boundary of two reads, and a file | |
| 252 | + | // that cannot be read holds nothing. | |
| 253 | + | func TestFileContains(t *testing.T) { | |
| 254 | + | dir := t.TempDir() | |
| 255 | + | needle := []byte(ghesMessage) | |
| 256 | + | for _, at := range []int{0, sniffChunk - 10, sniffChunk - len(needle), sniffChunk + len(needle) - 1, 3*sniffChunk + 5} { | |
| 257 | + | b := make([]byte, at, at+len(needle)+100) | |
| 258 | + | for i := range b { | |
| 259 | + | b[i] = 'x' | |
| 260 | + | } | |
| 261 | + | b = append(b, needle...) | |
| 262 | + | b = append(b, []byte(strings.Repeat("y", 100))...) | |
| 263 | + | p := filepath.Join(dir, "f") | |
| 264 | + | if err := os.WriteFile(p, b, 0o644); err != nil { | |
| 265 | + | t.Fatal(err) | |
| 266 | + | } | |
| 267 | + | if !fileContains(p, needle) { | |
| 268 | + | t.Errorf("message at %d not found", at) | |
| 269 | + | } | |
| 270 | + | // One byte short of the message: no match. | |
| 271 | + | if err := os.WriteFile(p, b[:at+len(needle)-1], 0o644); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | if fileContains(p, needle) { | |
| 275 | + | t.Errorf("truncated message at %d found", at) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | if fileContains(filepath.Join(dir, "missing"), needle) || fileContains(dir, needle) { | |
| 279 | + | t.Error("an unreadable file holds the message") | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // In the job container the replacement comes after the runtime variables of | |
| 284 | + | // the contexts too, in the environment docker exec passes on. | |
| 285 | + | func TestContainerNodeStepGetsServerURL(t *testing.T) { | |
| 286 | + | tree := files{ | |
| 287 | + | ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n", | |
| 288 | + | ".github/actions/x/index.js": "throw new Error('" + ghesMessage + "')\n", | |
| 289 | + | } | |
| 290 | + | f := newFakeCtr(t) | |
| 291 | + | f.set(t, "exec_vars", "GITHUB_SERVER_URL") | |
| 292 | + | rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n - name: r\n run: echo\n", func(c *Config) { | |
| 293 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 294 | + | withResults(c) | |
| 295 | + | }) | |
| 296 | + | if res.Conclusion != ConclusionSuccess { | |
| 297 | + | t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "a"))) | |
| 298 | + | } | |
| 299 | + | if got := logOf(rec.step(t, "a")); !strings.Contains(got, "VAR GITHUB_SERVER_URL="+artifactsServerURL) { | |
| 300 | + | t.Errorf("node step:\n%s", got) | |
| 301 | + | } | |
| 302 | + | if got := logOf(rec.step(t, "r")); !strings.Contains(got, "VAR GITHUB_SERVER_URL=https://ermite.example") { | |
| 303 | + | t.Errorf("run step:\n%s", got) | |
| 304 | + | } | |
| 305 | + | } | |
M internal/executor/actions_cache_test.go +7 -1
| @@ -12,7 +12,13 @@ const ( | |||
| 12 | 12 | cacheTestToken = "eyJqIjoiMSJ9.c2lnbmF0dXJl" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func withCache(c *Config) { c.CacheURL, c.CacheToken = cacheTestURL, cacheTestToken } | |
| 15 | + | // cacheTestContainerURL is the relay on the docker bridge, for the steps that | |
| 16 | + | // run in a container. | |
| 17 | + | const cacheTestContainerURL = "http://172.17.0.1:9089/" | |
| 18 | + | ||
| 19 | + | func withCache(c *Config) { | |
| 20 | + | c.CacheURL, c.CacheToken, c.ContainerCacheURL = cacheTestURL, cacheTestToken, cacheTestContainerURL | |
| 21 | + | } | |
| 16 | 22 | ||
| 17 | 23 | const envAction = `name: e | |
| 18 | 24 | description: d | |
M internal/executor/actions_docker.go +2 -1
| @@ -104,7 +104,8 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, | |||
| 104 | 104 | v, _ := in.Get(k) | |
| 105 | 105 | env.Set(inputEnvName(k), v) | |
| 106 | 106 | } | |
| 107 | - | j.addRuntimeEnv(env) | |
| 107 | + | // A docker action always runs in a container of its own. | |
| 108 | + | j.addRuntimeEnv(env, true) | |
| 108 | 109 | j.mu.Lock() | |
| 109 | 110 | workspace := j.state.GitHub("workspace") | |
| 110 | 111 | temp := j.state.Layout().Temp | |
M internal/executor/actions_docker_test.go +1 -0
| @@ -29,6 +29,7 @@ pull) | |||
| 29 | 29 | build) echo "built" ;; | |
| 30 | 30 | run) | |
| 31 | 31 | echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO" | |
| 32 | + | echo "RUN-RT RUNTIME=${ACTIONS_RUNTIME_URL-unset} CACHE=${ACTIONS_CACHE_URL-unset} RESULTS=${ACTIONS_RESULTS_URL-unset} SERVER=$GITHUB_SERVER_URL" | |
| 32 | 33 | echo "::set-output name=x::y" ;; | |
| 33 | 34 | esac | |
| 34 | 35 | exit 0 | |
M internal/executor/actions_node.go +37 -18
| @@ -152,26 +152,44 @@ func (j *job) externalsDir() string { | |||
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | 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 == "" { | |
| 155 | + | // (NodeScriptActionHandler.cs:55-84, ContainerActionHandler.cs:219-245): the | |
| 156 | + | // variables @actions/cache and @actions/artifact read. ermite's runtime service | |
| 157 | + | // is the channel of the runner behind one URL, which serves the cache, the | |
| 158 | + | // results service (artifacts) or both: ACTIONS_RUNTIME_URL and | |
| 159 | + | // ACTIONS_RUNTIME_TOKEN, then, when the channel serves the cache, | |
| 160 | + | // ACTIONS_CACHE_URL (the base of "_apis/artifactcache/…": the toolkit appends | |
| 161 | + | // to it, cacheHttpClient.ts:47, so it ends with a slash) and ACTIONS_CACHE_MODE, | |
| 162 | + | // and, when it serves results, ACTIONS_RESULTS_URL (the handlers set it from | |
| 163 | + | // ResultsServiceUrl, NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs: | |
| 164 | + | // 237-245; the toolkit keeps only its origin, artifact shared/config.ts). | |
| 165 | + | // ACTIONS_CACHE_SERVICE_V2 is never set: the toolkit would then ignore | |
| 166 | + | // ACTIONS_CACHE_URL (config.ts:42-58) and, on a server URL that is not | |
| 167 | + | // github.com, uses v1 anyway (config.ts:22-27). | |
| 168 | + | // | |
| 169 | + | // inContainer says the process runs in a container (a step in the job | |
| 170 | + | // container, a docker action): it has its own network namespace and reaches | |
| 171 | + | // the relay at ContainerCacheURL, not on the loopback. Without a URL for where | |
| 172 | + | // the step runs, or without a token, nothing is set. | |
| 173 | + | func (j *job) addRuntimeEnv(env *ordered, inContainer bool) { | |
| 174 | + | url := j.cfg.CacheURL | |
| 175 | + | if inContainer { | |
| 176 | + | url = j.cfg.ContainerCacheURL | |
| 177 | + | } | |
| 178 | + | if url == "" || j.cfg.CacheToken == "" { | |
| 166 | 179 | return | |
| 167 | 180 | } | |
| 168 | - | env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL) | |
| 181 | + | env.Set("ACTIONS_RUNTIME_URL", url) | |
| 169 | 182 | env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken) | |
| 170 | - | env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL) | |
| 171 | - | // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs: | |
| 172 | - | // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40). | |
| 173 | - | if j.cfg.CacheMode != "" { | |
| 174 | - | env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode) | |
| 183 | + | if !j.cfg.CacheOff { | |
| 184 | + | env.Set("ACTIONS_CACHE_URL", url) | |
| 185 | + | // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs: | |
| 186 | + | // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40). | |
| 187 | + | if j.cfg.CacheMode != "" { | |
| 188 | + | env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | if j.cfg.Results { | |
| 192 | + | env.Set("ACTIONS_RESULTS_URL", url) | |
| 175 | 193 | } | |
| 176 | 194 | } | |
| 177 | 195 | ||
| @@ -185,7 +203,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca | |||
| 185 | 203 | v, _ := in.Get(k) | |
| 186 | 204 | env.Set(inputEnvName(k), v) | |
| 187 | 205 | } | |
| 188 | - | j.addRuntimeEnv(env) | |
| 206 | + | j.addRuntimeEnv(env, j.stepContainer() != nil) | |
| 189 | 207 | // The PATH of the previous steps is added by procEnv. | |
| 190 | 208 | env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795 | |
| 191 | 209 | r := def.runs() | |
| @@ -206,6 +224,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca | |||
| 206 | 224 | if st, err := os.Stat(script); err != nil || st.IsDir() { | |
| 207 | 225 | return fail(ghErrorf("File not found: '%s'", script)) | |
| 208 | 226 | } | |
| 227 | + | j.setArtifactsServerURL(s, script) | |
| 209 | 228 | if c := j.stepContainer(); c != nil { | |
| 210 | 229 | return j.runNodeInContainer(s, c, script, env, cancel) | |
| 211 | 230 | } | |
M internal/executor/containers_fake_test.go +2 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | // health words, one per inspect: starting, healthy, unhealthy | |
| 18 | 18 | // create_fail docker create exits 125 | |
| 19 | 19 | // exec_out what docker exec prints | |
| 20 | + | // exec_vars names of variables docker exec prints ("VAR NAME=value") | |
| 20 | 21 | // hang_exec docker exec waits until it gets a signal | |
| 21 | 22 | type fakeCtr struct { | |
| 22 | 23 | dir, bin, log string | |
| @@ -61,6 +62,7 @@ rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove conta | |||
| 61 | 62 | echo "$3" ;; | |
| 62 | 63 | exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi | |
| 63 | 64 | [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH" | |
| 65 | + | [ -f $D/exec_vars ] && for v in $(cat $D/exec_vars); do eval "echo \"VAR $v=\${$v-unset}\""; done | |
| 64 | 66 | [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; } | |
| 65 | 67 | [ -f $D/exec_out ] && cat $D/exec_out | |
| 66 | 68 | if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;; | |
M internal/executor/containers_node.go +4 -0
| @@ -87,6 +87,10 @@ func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *o | |||
| 87 | 87 | wd := j.state.GitHub("workspace") | |
| 88 | 88 | j.mu.Unlock() | |
| 89 | 89 | henv := j.handlerEnv(env) | |
| 90 | + | // After the runtime variables of the contexts, which hold GITHUB_SERVER_URL. | |
| 91 | + | if s.serverURL != "" { | |
| 92 | + | henv.Set("GITHUB_SERVER_URL", s.serverURL) | |
| 93 | + | } | |
| 90 | 94 | if err := checkEnvNames(henv.Keys()); err != nil { | |
| 91 | 95 | return fail(err) | |
| 92 | 96 | } | |
M internal/executor/job.go +17 -0
| @@ -81,6 +81,20 @@ type Config struct { | |||
| 81 | 81 | // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL; | |
| 82 | 82 | // empty sets nothing. | |
| 83 | 83 | CacheMode string | |
| 84 | + | // ContainerCacheURL is CacheURL for the steps that run in a container (a | |
| 85 | + | // step in the job container, a docker action): the same relay on an address | |
| 86 | + | // of the sandbox their network reaches. Empty: those steps get no runtime | |
| 87 | + | // variables. | |
| 88 | + | ContainerCacheURL string | |
| 89 | + | // Results says that the service behind CacheURL also serves the results | |
| 90 | + | // service (artifacts) and that CacheToken carries its scope: node and docker | |
| 91 | + | // actions get ACTIONS_RESULTS_URL, and the node steps of the artifact actions | |
| 92 | + | // get a GITHUB_SERVER_URL @actions/artifact takes for GitHub (see | |
| 93 | + | // setArtifactsServerURL). | |
| 94 | + | Results bool | |
| 95 | + | // CacheOff says that the service behind CacheURL serves no cache (results | |
| 96 | + | // only): ACTIONS_CACHE_URL and ACTIONS_CACHE_MODE are not set. | |
| 97 | + | CacheOff bool | |
| 84 | 98 | // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in | |
| 85 | 99 | // the masker before the first step and form the secrets context; they are | |
| 86 | 100 | // never logged. Vars are the fallback of the vars context. | |
| @@ -199,6 +213,9 @@ type job struct { | |||
| 199 | 213 | summaries map[int]string | |
| 200 | 214 | logLines map[int]int // lines written to the log of each step, under mu | |
| 201 | 215 | states map[string]*intraState // intra-action state, see saveState | |
| 216 | + | // ghesSniff is, by script path, whether a node action's script bundles | |
| 217 | + | // @actions/artifact v2 (see bundlesArtifactClient); under mu. | |
| 218 | + | ghesSniff map[string]bool | |
| 202 | 219 | } | |
| 203 | 220 | ||
| 204 | 221 | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | |
M internal/executor/step.go +24 -2
| @@ -50,6 +50,9 @@ type stepRun struct { | |||
| 50 | 50 | // def is the action the step runs. | |
| 51 | 51 | def *actionDef | |
| 52 | 52 | nodeVersion string | |
| 53 | + | // serverURL, when set, replaces GITHUB_SERVER_URL in the environment of the | |
| 54 | + | // process only (see setArtifactsServerURL); the github context keeps its value. | |
| 55 | + | serverURL string | |
| 53 | 56 | // scope is what the step shares with the step that contains it, see | |
| 54 | 57 | // stepScope. name is the display name of the step in the log; timedOut is set when the step | |
| 55 | 58 | // reached its timeout-minutes. | |
| @@ -76,10 +79,29 @@ func (s *stepRun) procEnv() []string { | |||
| 76 | 79 | if s.rawEnvList != nil { | |
| 77 | 80 | return s.rawEnvList | |
| 78 | 81 | } | |
| 82 | + | env := s.env | |
| 79 | 83 | if s.actEnv != nil { | |
| 80 | - | return s.j.processEnv(s.actEnv) | |
| 84 | + | env = s.actEnv | |
| 81 | 85 | } | |
| 82 | - | return s.j.processEnv(s.env) | |
| 86 | + | list := s.j.processEnv(env) | |
| 87 | + | if s.serverURL != "" { | |
| 88 | + | // processEnv sets the runtime variables of the contexts last: the | |
| 89 | + | // replacement comes after them. | |
| 90 | + | list = setEnvList(list, "GITHUB_SERVER_URL", s.serverURL) | |
| 91 | + | } | |
| 92 | + | return list | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // setEnvList sets name=value in a NAME=value list, in place of an existing | |
| 96 | + | // entry or at the end. | |
| 97 | + | func setEnvList(list []string, name, value string) []string { | |
| 98 | + | for i, kv := range list { | |
| 99 | + | if k, _, _ := strings.Cut(kv, "="); k == name { | |
| 100 | + | list[i] = name + "=" + value | |
| 101 | + | return list | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return append(list, name+"="+value) | |
| 83 | 105 | } | |
| 84 | 106 | ||
| 85 | 107 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | |
M internal/guest/cacheurl_test.go +116 -4
| @@ -4,10 +4,12 @@ package guest | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "strconv" | |
| 11 | 13 | "testing" | |
| 12 | 14 | "time" | |
| 13 | 15 | ||
| @@ -30,6 +32,40 @@ func TestCacheURL(t *testing.T) { | |||
| 30 | 32 | } | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 35 | + | // noBridge is a sandbox without a docker daemon. | |
| 36 | + | func noBridge() (net.IP, error) { return nil, errors.New("no docker0") } | |
| 37 | + | ||
| 38 | + | // serveCache serves the cache channel next to the executor target in dir; it | |
| 39 | + | // answers with the Authorization header it got. | |
| 40 | + | func serveCache(t *testing.T, dir string) Target { | |
| 41 | + | t.Helper() | |
| 42 | + | ln, err := net.Listen("unix", filepath.Join(dir, "cache.sock")) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 47 | + | _, _ = io.WriteString(w, "cache says "+r.Header.Get("Authorization")) | |
| 48 | + | })} | |
| 49 | + | go func() { _ = srv.Serve(ln) }() | |
| 50 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 51 | + | return Target{Network: "unix", Addr: filepath.Join(dir, "exec.sock")} | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // get fetches url with the token and returns the body. | |
| 55 | + | func get(t *testing.T, url string) string { | |
| 56 | + | t.Helper() | |
| 57 | + | req, _ := http.NewRequest("GET", url+"_apis/artifactcache/cache", nil) | |
| 58 | + | req.Header.Set("Authorization", "Bearer tok") | |
| 59 | + | cl := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DisableKeepAlives: true}} | |
| 60 | + | resp, err := cl.Do(req) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | body, _ := io.ReadAll(resp.Body) | |
| 65 | + | _ = resp.Body.Close() | |
| 66 | + | return string(body) | |
| 67 | + | } | |
| 68 | + | ||
| 33 | 69 | // freePort returns a loopback port nobody listens on. | |
| 34 | 70 | func freePort(t *testing.T) uint32 { | |
| 35 | 71 | t.Helper() | |
| @@ -59,8 +95,9 @@ func TestStartCacheRelay(t *testing.T) { | |||
| 59 | 95 | ||
| 60 | 96 | port := freePort(t) | |
| 61 | 97 | spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"} | |
| 62 | - | got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec) | |
| 63 | - | if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) { | |
| 98 | + | got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec, noBridge) | |
| 99 | + | if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) || | |
| 100 | + | got.bridge != nil || got.containerURL != "" || got.results || got.cacheOff { | |
| 64 | 101 | t.Fatalf("relay %+v", got) | |
| 65 | 102 | } | |
| 66 | 103 | ||
| @@ -89,7 +126,7 @@ func TestStartCacheRelay(t *testing.T) { | |||
| 89 | 126 | func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) { | |
| 90 | 127 | target := Target{Network: "unix", Addr: filepath.Join(t.TempDir(), "exec.sock")} | |
| 91 | 128 | for _, spec := range []*execpb.JobSpec{{}, {CachePort: 9089}, {CacheToken: "tok"}} { | |
| 92 | - | if got := startCacheRelay(context.Background(), target, spec); got != (cacheRelay{}) { | |
| 129 | + | if got := startCacheRelay(context.Background(), target, spec, noBridge); got != (cacheRelay{}) { | |
| 93 | 130 | t.Errorf("%+v: relay %+v", spec, got) | |
| 94 | 131 | } | |
| 95 | 132 | } | |
| @@ -99,7 +136,82 @@ func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) { | |||
| 99 | 136 | } | |
| 100 | 137 | defer func() { _ = busy.Close() }() | |
| 101 | 138 | spec := &execpb.JobSpec{CachePort: uint32(busy.Addr().(*net.TCPAddr).Port), CacheToken: "tok"} | |
| 102 | - | if got := startCacheRelay(context.Background(), target, spec); got != (cacheRelay{}) { | |
| 139 | + | if got := startCacheRelay(context.Background(), target, spec, noBridge); got != (cacheRelay{}) { | |
| 103 | 140 | t.Errorf("taken port: relay %+v", got) | |
| 104 | 141 | } | |
| 105 | 142 | } | |
| 143 | + | ||
| 144 | + | // The relay also listens on the address of the docker bridge, on the same | |
| 145 | + | // port, for the steps that run in a container; their URL has that address. | |
| 146 | + | // 127.0.0.2 stands for docker0 (a loopback address that is bindable on Linux). | |
| 147 | + | func TestStartCacheRelayOnBridge(t *testing.T) { | |
| 148 | + | target := serveCache(t, t.TempDir()) | |
| 149 | + | port := freePort(t) | |
| 150 | + | probe, err := net.Listen("tcp", net.JoinHostPort("127.0.0.2", strconv.FormatUint(uint64(port), 10))) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Skipf("127.0.0.2 is not bindable here: %v", err) | |
| 153 | + | } | |
| 154 | + | _ = probe.Close() | |
| 155 | + | bridge := func() (net.IP, error) { return net.ParseIP("127.0.0.2"), nil } | |
| 156 | + | got := startCacheRelay(context.Background(), target, &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "write"}, bridge) | |
| 157 | + | defer got.close() | |
| 158 | + | want := "http://127.0.0.2:" + strconv.FormatUint(uint64(port), 10) + "/" | |
| 159 | + | if got.bridge == nil || got.containerURL != want || got.url != cacheURL(port) { | |
| 160 | + | t.Fatalf("relay %+v", got) | |
| 161 | + | } | |
| 162 | + | for _, u := range []string{got.url, got.containerURL} { | |
| 163 | + | if body := get(t, u); body != "cache says Bearer tok" { | |
| 164 | + | t.Errorf("%s: got %q", u, body) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | got.close() | |
| 168 | + | if c, err := net.DialTimeout("tcp", got.bridge.Addr().String(), time.Second); err == nil { | |
| 169 | + | _ = c.Close() | |
| 170 | + | t.Fatal("the bridge relay outlives the job") | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // A bridge address that cannot be listened on leaves the loopback relay, and | |
| 175 | + | // no URL for the containers. | |
| 176 | + | func TestStartCacheRelayBridgeFails(t *testing.T) { | |
| 177 | + | target := serveCache(t, t.TempDir()) | |
| 178 | + | port := freePort(t) | |
| 179 | + | // 192.0.2.1 (TEST-NET-1) is not an address of this machine. | |
| 180 | + | bridge := func() (net.IP, error) { return net.ParseIP("192.0.2.1"), nil } | |
| 181 | + | got := startCacheRelay(context.Background(), target, &execpb.JobSpec{CachePort: port, CacheToken: "tok"}, bridge) | |
| 182 | + | defer got.close() | |
| 183 | + | if got.relay == nil || got.bridge != nil || got.containerURL != "" { | |
| 184 | + | t.Fatalf("relay %+v", got) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // JobSpec.results: the channel serves the results service; without a cache | |
| 189 | + | // mode it serves nothing else, and the cache variables are off. | |
| 190 | + | func TestStartCacheRelayResults(t *testing.T) { | |
| 191 | + | target := serveCache(t, t.TempDir()) | |
| 192 | + | for _, tc := range []struct { | |
| 193 | + | mode string | |
| 194 | + | results, cacheOff bool | |
| 195 | + | }{{"write", true, false}, {"", true, true}, {"read", false, false}, {"", false, false}} { | |
| 196 | + | spec := &execpb.JobSpec{CachePort: freePort(t), CacheToken: "tok", CacheMode: tc.mode, Results: tc.results} | |
| 197 | + | got := startCacheRelay(context.Background(), target, spec, noBridge) | |
| 198 | + | got.close() | |
| 199 | + | if got.relay == nil || got.results != tc.results || got.cacheOff != tc.cacheOff { | |
| 200 | + | t.Errorf("%+v: relay %+v", tc, got) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // docker0IPv4 without the interface is an error, not an address. | |
| 206 | + | func TestDocker0IPv4(t *testing.T) { | |
| 207 | + | ip, err := docker0IPv4() | |
| 208 | + | if _, ierr := net.InterfaceByName(BridgeInterface); ierr != nil { | |
| 209 | + | if err == nil { | |
| 210 | + | t.Errorf("no %s, got %v", BridgeInterface, ip) | |
| 211 | + | } | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | if err == nil && ip.To4() == nil { | |
| 215 | + | t.Errorf("not IPv4: %v", ip) | |
| 216 | + | } | |
| 217 | + | } | |
M internal/guest/jobexec.go +85 -17
| @@ -214,7 +214,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 214 | 214 | canceller := executor.NewCanceller() | |
| 215 | 215 | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | |
| 216 | 216 | res := newResolver(conn) | |
| 217 | - | cacheEnv := startCacheRelay(ctx, cfg.Target, spec) | |
| 217 | + | cacheEnv := startCacheRelay(ctx, cfg.Target, spec, docker0IPv4) | |
| 218 | 218 | defer cacheEnv.close() | |
| 219 | 219 | ||
| 220 | 220 | // Frames from the host while the job runs: a Cancel stops the job; a lost | |
| @@ -260,6 +260,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 260 | 260 | RunnerVersion: spec.GetRunnerVersion(), | |
| 261 | 261 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | |
| 262 | 262 | CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode, | |
| 263 | + | ContainerCacheURL: cacheEnv.containerURL, Results: cacheEnv.results, CacheOff: cacheEnv.cacheOff, | |
| 263 | 264 | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx), | |
| 264 | 265 | Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec), | |
| 265 | 266 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | |
| @@ -276,44 +277,111 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 276 | 277 | // the toolkit appends "_apis/…" to it (cacheHttpClient.ts:47). Empty when the | |
| 277 | 278 | // job has no cache service. | |
| 278 | 279 | func cacheURL(port uint32) string { | |
| 280 | + | return relayURL(CacheListenHost, port) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // relayURL is the URL of the relay on host and port, "" for a port that is | |
| 284 | + | // not one. | |
| 285 | + | func relayURL(host string, port uint32) string { | |
| 279 | 286 | if port == 0 || port > 65535 { | |
| 280 | 287 | return "" | |
| 281 | 288 | } | |
| 282 | - | return "http://" + net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(port), 10)) + "/" | |
| 289 | + | return "http://" + net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) + "/" | |
| 283 | 290 | } | |
| 284 | 291 | ||
| 285 | - | // cacheRelay is the cache of a job in the sandbox: the relay, the URL of the | |
| 286 | - | // loopback it listens on and the token of the job. All are empty without a cache | |
| 287 | - | // service. | |
| 292 | + | // BridgeInterface is the default bridge of the sandbox's docker daemon. Its | |
| 293 | + | // address is a local address of the sandbox that every container of that | |
| 294 | + | // daemon reaches, whatever its bridge network: the traffic to it goes through | |
| 295 | + | // INPUT, which Docker does not filter (what host-gateway relies on). | |
| 296 | + | const BridgeInterface = "docker0" | |
| 297 | + | ||
| 298 | + | // bridgeLookup returns the IPv4 address the relay listens on for the steps | |
| 299 | + | // that run in a container; an error means there is none. | |
| 300 | + | type bridgeLookup func() (net.IP, error) | |
| 301 | + | ||
| 302 | + | // docker0IPv4 is the first IPv4 address of BridgeInterface, read when the job | |
| 303 | + | // starts: the nested daemon picks it, and init starts the executor once the | |
| 304 | + | // daemon answers (init.go), so the bridge exists by then when there is one. | |
| 305 | + | func docker0IPv4() (net.IP, error) { | |
| 306 | + | ifi, err := net.InterfaceByName(BridgeInterface) | |
| 307 | + | if err != nil { | |
| 308 | + | return nil, err | |
| 309 | + | } | |
| 310 | + | addrs, err := ifi.Addrs() | |
| 311 | + | if err != nil { | |
| 312 | + | return nil, err | |
| 313 | + | } | |
| 314 | + | for _, a := range addrs { | |
| 315 | + | if n, ok := a.(*net.IPNet); ok { | |
| 316 | + | if ip4 := n.IP.To4(); ip4 != nil { | |
| 317 | + | return ip4, nil | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return nil, fmt.Errorf("%s has no IPv4 address", BridgeInterface) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // cacheRelay is the runtime service of a job in the sandbox (the cache, the | |
| 325 | + | // results service or both, on one channel): the relays, the URLs they listen | |
| 326 | + | // on and the token of the job. All are empty without a service; containerURL | |
| 327 | + | // and bridge are empty when the steps that run in a container cannot reach it. | |
| 288 | 328 | type cacheRelay struct { | |
| 289 | - | relay *Relay | |
| 290 | - | url, token, mode string | |
| 329 | + | relay, bridge *Relay | |
| 330 | + | url, containerURL string | |
| 331 | + | token, mode string | |
| 332 | + | results, cacheOff bool | |
| 291 | 333 | } | |
| 292 | 334 | ||
| 293 | 335 | func (c cacheRelay) close() { | |
| 294 | 336 | if c.relay != nil { | |
| 295 | 337 | _ = c.relay.Close() | |
| 296 | 338 | } | |
| 339 | + | if c.bridge != nil { | |
| 340 | + | _ = c.bridge.Close() | |
| 341 | + | } | |
| 297 | 342 | } | |
| 298 | 343 | ||
| 299 | - | // startCacheRelay starts the relay of the cache for a job that has a cache | |
| 300 | - | // service: it listens on the loopback port the host says and pipes every | |
| 344 | + | // startCacheRelay starts the relay of the runtime service for a job that has | |
| 345 | + | // one: it listens on the loopback port the host says and pipes every | |
| 301 | 346 | // connection to a new stream on the cache channel of the host (a sibling of | |
| 302 | - | // the executor's target). A relay that cannot start leaves the job without a | |
| 303 | - | // cache, like a scope the host refuses: the job runs. | |
| 304 | - | func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) cacheRelay { | |
| 305 | - | url := cacheURL(spec.GetCachePort()) | |
| 347 | + | // the executor's target). A relay that cannot start leaves the job without the | |
| 348 | + | // service, like a scope the host refuses: the job runs. A second relay listens | |
| 349 | + | // on the same port at the address bridge returns, for the job container and | |
| 350 | + | // the docker actions, whose loopback is their own; without it those steps get | |
| 351 | + | // no runtime variables. | |
| 352 | + | func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec, bridge bridgeLookup) cacheRelay { | |
| 353 | + | port := spec.GetCachePort() | |
| 354 | + | url := cacheURL(port) | |
| 306 | 355 | if url == "" || spec.GetCacheToken() == "" { | |
| 307 | 356 | return cacheRelay{} | |
| 308 | 357 | } | |
| 309 | - | addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(spec.GetCachePort()), 10)) | |
| 310 | - | r, err := StartRelay(ctx, addr, target.CacheTarget().DialStream, DefaultRelayMax) | |
| 358 | + | log := zerowrap.FromCtx(ctx) | |
| 359 | + | dial := target.CacheTarget().DialStream | |
| 360 | + | addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(port), 10)) | |
| 361 | + | r, err := StartRelay(ctx, addr, dial, DefaultRelayMax) | |
| 311 | 362 | if err != nil { | |
| 312 | - | log := zerowrap.FromCtx(ctx) | |
| 313 | 363 | log.Warn().Err(err).Msg("job runs without the cache") | |
| 314 | 364 | return cacheRelay{} | |
| 315 | 365 | } | |
| 316 | - | return cacheRelay{relay: r, url: url, token: spec.GetCacheToken(), mode: spec.GetCacheMode()} | |
| 366 | + | results := spec.GetResults() | |
| 367 | + | c := cacheRelay{relay: r, url: url, token: spec.GetCacheToken(), mode: spec.GetCacheMode(), | |
| 368 | + | results: results, cacheOff: results && spec.GetCacheMode() == ""} | |
| 369 | + | if bridge == nil { | |
| 370 | + | return c | |
| 371 | + | } | |
| 372 | + | ip, err := bridge() | |
| 373 | + | if err != nil { | |
| 374 | + | log.Debug().Err(err).Msg("no docker bridge: steps in containers run without the runtime service") | |
| 375 | + | return c | |
| 376 | + | } | |
| 377 | + | host := ip.String() | |
| 378 | + | br, err := StartRelay(ctx, net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)), dial, DefaultRelayMax) | |
| 379 | + | if err != nil { | |
| 380 | + | log.Warn().Err(err).Msg("steps in containers run without the runtime service") | |
| 381 | + | return c | |
| 382 | + | } | |
| 383 | + | c.bridge, c.containerURL = br, relayURL(host, port) | |
| 384 | + | return c | |
| 317 | 385 | } | |
| 318 | 386 | ||
| 319 | 387 | // failSetup ends a job that cannot start, like a failed "Set up job": step 1 | |
M internal/guest/relay.go +7 -5
| @@ -14,10 +14,12 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | // The cache service of the runner never listens on a network. In the sandbox it | |
| 16 | 16 | // is reached on the guest's own loopback (CacheListenHost, on the port the | |
| 17 | - | // host gives in JobSpec.cache_port), where a Relay accepts the TCP connections | |
| 18 | - | // of the actions and pipes each of them to a new stream on the host↔guest | |
| 19 | - | // channel (a vsock port for a VM, a Unix socket for a container); the host | |
| 20 | - | // serves HTTP on the other end of that stream. | |
| 17 | + | // host gives in JobSpec.cache_port), and on the same port at the address of | |
| 18 | + | // the docker bridge for the steps that run in a container (BridgeInterface), | |
| 19 | + | // where a Relay accepts the TCP connections of the actions and pipes each of | |
| 20 | + | // them to a new stream on the host↔guest channel (a vsock port for a VM, a | |
| 21 | + | // Unix socket for a container); the host serves HTTP on the other end of that | |
| 22 | + | // stream. | |
| 21 | 23 | const ( | |
| 22 | 24 | // CacheListenHost is the loopback address of the relay. | |
| 23 | 25 | CacheListenHost = "127.0.0.1" | |
| @@ -40,7 +42,7 @@ const ( | |||
| 40 | 42 | // DialFunc opens one new stream to the host. | |
| 41 | 43 | type DialFunc func(ctx context.Context) (io.ReadWriteCloser, error) | |
| 42 | 44 | ||
| 43 | - | // Relay accepts TCP connections on a loopback address and pipes each one to a | |
| 45 | + | // Relay accepts TCP connections on a local address and pipes each one to a | |
| 44 | 46 | // new stream from its DialFunc, in both directions, until Close. | |
| 45 | 47 | type Relay struct { | |
| 46 | 48 | ln net.Listener | |
M internal/host/cache.go +42 -10
| @@ -5,12 +5,14 @@ package host | |||
| 5 | 5 | import ( | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | + | "net/http" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 12 | 13 | ||
| 13 | 14 | "bnema.dev/ermite-runner/internal/cachesvc" | |
| 15 | + | "bnema.dev/ermite-runner/internal/resultssvc" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | // cacheTokenMargin is added to the job timeout for the life of the cache token: | |
| @@ -106,26 +108,56 @@ func githubRepository(contexts string) string { | |||
| 106 | 108 | return c.Github.Repository | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | - | // cacheFor returns the loopback port the cache has in the guest and the token | |
| 110 | - | // of a job, or 0 and "" when the runner serves no cache or the job has no scope. | |
| 111 | - | func (j *jobRun) cacheFor(rt *Runner) (port uint32, token, mode string) { | |
| 111 | + | // runtimeFor returns what the executor needs of the job's runtime channel: the | |
| 112 | + | // port it has in the guest, the job's ACTIONS_RUNTIME_TOKEN, the cache mode and | |
| 113 | + | // whether the channel serves the results service. The channel exists when the | |
| 114 | + | // runner serves a cache and the job has a cache scope or the results service; | |
| 115 | + | // without it, 0, "", "" and false. A job with the results service and no cache | |
| 116 | + | // scope gets an empty mode (the executor then sets no ACTIONS_CACHE_URL) and a | |
| 117 | + | // token that the cache refuses. | |
| 118 | + | func (j *jobRun) runtimeFor(rt *Runner) (port uint32, token, mode string, results bool) { | |
| 112 | 119 | if rt.Cache == nil { | |
| 113 | - | return 0, "", "" | |
| 120 | + | return 0, "", "", false | |
| 114 | 121 | } | |
| 115 | 122 | sc, ok, err := cacheScope(j.job, j.contexts) | |
| 116 | 123 | if err != nil { | |
| 117 | 124 | // A bad scope from the server is not the job's failure: it runs without a | |
| 118 | 125 | // cache. | |
| 119 | 126 | j.log.Warn().Err(err).Msg("cache disabled for the job") | |
| 120 | - | return 0, "", "" | |
| 127 | + | ok = false | |
| 121 | 128 | } | |
| 122 | 129 | if !ok { | |
| 123 | - | return 0, "", "" | |
| 130 | + | sc = cachesvc.Scope{} | |
| 131 | + | } else { | |
| 132 | + | mode = "read" | |
| 133 | + | if sc.WriteRef != "" { | |
| 134 | + | mode = "write" | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if !ok && !j.results { | |
| 138 | + | return 0, "", "", false | |
| 139 | + | } | |
| 140 | + | var scopes []string | |
| 141 | + | if j.results { | |
| 142 | + | scopes = append(scopes, resultssvc.Scope(j.job.RunId, j.job.JobId, j.job.Attempt)) | |
| 124 | 143 | } | |
| 125 | 144 | ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin | |
| 126 | - | mode = "read" | |
| 127 | - | if sc.WriteRef != "" { | |
| 128 | - | mode = "write" | |
| 145 | + | return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl, scopes...), mode, j.results | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // resultsOn says whether the job gets the results service: the runner has one | |
| 149 | + | // (ERMITE_RUNNER_ARTIFACTS is not 0) with a cache channel to serve it on, and | |
| 150 | + | // the server has the feature ci.artifacts. Without it artifact actions fail as | |
| 151 | + | // on a GitHub Enterprise Server without artifacts v4. | |
| 152 | + | func (j *jobRun) resultsOn(rt *Runner) bool { | |
| 153 | + | return rt.Results != nil && rt.Cache != nil && rt.Artifacts != nil && j.features.artifacts | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // resultsHandler is the results service of the job's channel, nil without one. | |
| 157 | + | // It calls ermite with the job token. | |
| 158 | + | func (j *jobRun) resultsHandler(rt *Runner) http.Handler { | |
| 159 | + | if !j.results { | |
| 160 | + | return nil | |
| 129 | 161 | } | |
| 130 | - | return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl), mode | |
| 162 | + | return rt.Results.Handler(resultssvc.Job{ID: j.job.JobId, RunID: j.job.RunId, Attempt: j.job.Attempt, Client: rt.Artifacts(j.job.JobToken)}) | |
| 131 | 163 | } | |
M internal/host/cache_test.go +86 -10
| @@ -13,8 +13,10 @@ import ( | |||
| 13 | 13 | "github.com/bnema/zerowrap" | |
| 14 | 14 | ||
| 15 | 15 | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 16 | 17 | ||
| 17 | 18 | "bnema.dev/ermite-runner/internal/cachesvc" | |
| 19 | + | "bnema.dev/ermite-runner/internal/resultssvc" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | const scopeCtx = `{"github":{"repository":"o/r"}}` | |
| @@ -123,7 +125,7 @@ func TestCacheScopeFallback(t *testing.T) { | |||
| 123 | 125 | ||
| 124 | 126 | // A mismatched scope disables the cache of the job, with a log line; the job | |
| 125 | 127 | // goes on. | |
| 126 | - | func TestCacheForMismatchDisablesCache(t *testing.T) { | |
| 128 | + | func TestRuntimeForMismatchDisablesCache(t *testing.T) { | |
| 127 | 129 | svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")}) | |
| 128 | 130 | if err != nil { | |
| 129 | 131 | t.Fatal(err) | |
| @@ -134,7 +136,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) { | |||
| 134 | 136 | job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}}, | |
| 135 | 137 | contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}), | |
| 136 | 138 | } | |
| 137 | - | if port, tok, _ := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" { | |
| 139 | + | if port, tok, _, _ := j.runtimeFor(&Runner{Cache: svc}); port != 0 || tok != "" { | |
| 138 | 140 | t.Fatalf("cache for a mismatched scope: %d %q", port, tok) | |
| 139 | 141 | } | |
| 140 | 142 | if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") { | |
| @@ -142,16 +144,16 @@ func TestCacheForMismatchDisablesCache(t *testing.T) { | |||
| 142 | 144 | } | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | - | func TestCacheForNeedsServiceAndScope(t *testing.T) { | |
| 147 | + | func TestRuntimeForNeedsServiceAndScope(t *testing.T) { | |
| 146 | 148 | 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 != "" { | |
| 149 | + | if port, tok, _, _ := j.runtimeFor(&Runner{}); port != 0 || tok != "" { | |
| 148 | 150 | t.Fatalf("no service: %d %q", port, tok) | |
| 149 | 151 | } | |
| 150 | 152 | } | |
| 151 | 153 | ||
| 152 | 154 | // The token of a job is the one the service accepts, for the job's repository | |
| 153 | 155 | // and ref only. | |
| 154 | - | func TestCacheForToken(t *testing.T) { | |
| 156 | + | func TestRuntimeForToken(t *testing.T) { | |
| 155 | 157 | svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")}) | |
| 156 | 158 | if err != nil { | |
| 157 | 159 | t.Fatal(err) | |
| @@ -161,10 +163,10 @@ func TestCacheForToken(t *testing.T) { | |||
| 161 | 163 | defer srv.Close() | |
| 162 | 164 | rt := &Runner{Cache: svc} | |
| 163 | 165 | j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`} | |
| 164 | - | port, tok, mode := j.cacheFor(rt) | |
| 166 | + | port, tok, mode, results := j.runtimeFor(rt) | |
| 165 | 167 | // The port is the cache's loopback port in the guest, not a host port. | |
| 166 | - | if port != 9089 || tok == "" { | |
| 167 | - | t.Fatalf("%d %q", port, tok) | |
| 168 | + | if port != 9089 || tok == "" || results { | |
| 169 | + | t.Fatalf("%d %q %v", port, tok, results) | |
| 168 | 170 | } | |
| 169 | 171 | // No Job.cache: the narrowest scope writes the job's own ref. | |
| 170 | 172 | if mode != "write" { | |
| @@ -193,7 +195,7 @@ func TestCacheForToken(t *testing.T) { | |||
| 193 | 195 | ||
| 194 | 196 | // ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job | |
| 195 | 197 | // read-only. | |
| 196 | - | func TestCacheForMode(t *testing.T) { | |
| 198 | + | func TestRuntimeForMode(t *testing.T) { | |
| 197 | 199 | svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")}) | |
| 198 | 200 | if err != nil { | |
| 199 | 201 | t.Fatal(err) | |
| @@ -204,8 +206,82 @@ func TestCacheForMode(t *testing.T) { | |||
| 204 | 206 | job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}}, | |
| 205 | 207 | contexts: `{"github":{"repository":"o/r"}}`, | |
| 206 | 208 | } | |
| 207 | - | if _, _, mode := j.cacheFor(&Runner{Cache: svc}); mode != tc.want { | |
| 209 | + | if _, _, mode, _ := j.runtimeFor(&Runner{Cache: svc}); mode != tc.want { | |
| 208 | 210 | t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want) | |
| 209 | 211 | } | |
| 210 | 212 | } | |
| 211 | 213 | } | |
| 214 | + | ||
| 215 | + | // A job with the results service gets the channel even without a cache scope: | |
| 216 | + | // its token carries the job's Actions.Results scope, the results service takes | |
| 217 | + | // it and the cache refuses it. With a cache scope the token serves both. | |
| 218 | + | func TestRuntimeForResults(t *testing.T) { | |
| 219 | + | svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")}) | |
| 220 | + | if err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | t.Cleanup(func() { _ = svc.CloseAll() }) | |
| 224 | + | rt := &Runner{Cache: svc, Results: resultssvc.New(resultssvc.Config{Auth: svc}), | |
| 225 | + | Artifacts: func(string) ermiteconnect.JobArtifactServiceClient { return nil }} | |
| 226 | + | scope := resultssvc.Scope(42, "j1", 2) | |
| 227 | + | for _, tc := range []struct { | |
| 228 | + | name, ref string | |
| 229 | + | mode string | |
| 230 | + | }{{"no cache scope", "", ""}, {"cache scope", "refs/heads/main", "write"}} { | |
| 231 | + | j := &jobRun{job: &ermite.Job{JobId: "j1", RunId: 42, Attempt: 2, Ref: tc.ref, TimeoutMinutes: 5}, | |
| 232 | + | contexts: `{"github":{"repository":"o/r"}}`, features: jobFeatures{artifacts: true}} | |
| 233 | + | j.results = j.resultsOn(rt) | |
| 234 | + | port, tok, mode, results := j.runtimeFor(rt) | |
| 235 | + | if port != cachesvc.DefaultPort || tok == "" || mode != tc.mode || !results { | |
| 236 | + | t.Fatalf("%s: %d %q %q %v", tc.name, port, tok, mode, results) | |
| 237 | + | } | |
| 238 | + | job, scp, _, err := svc.RuntimeToken(tok) | |
| 239 | + | if err != nil || job != "j1" || !strings.Contains(" "+scp+" ", " "+scope+" ") { | |
| 240 | + | t.Fatalf("%s: %q %q %v", tc.name, job, scp, err) | |
| 241 | + | } | |
| 242 | + | if j.resultsHandler(rt) == nil { | |
| 243 | + | t.Fatalf("%s: no results handler", tc.name) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // The results service needs the runner's service, its cache channel, the | |
| 249 | + | // client factory and the server's ci.artifacts; without any of them the job | |
| 250 | + | // gets none, and its token no Actions.Results scope. | |
| 251 | + | func TestResultsOnNeedsEverything(t *testing.T) { | |
| 252 | + | svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")}) | |
| 253 | + | if err != nil { | |
| 254 | + | t.Fatal(err) | |
| 255 | + | } | |
| 256 | + | t.Cleanup(func() { _ = svc.CloseAll() }) | |
| 257 | + | res := resultssvc.New(resultssvc.Config{Auth: svc}) | |
| 258 | + | art := func(string) ermiteconnect.JobArtifactServiceClient { return nil } | |
| 259 | + | full := &Runner{Cache: svc, Results: res, Artifacts: art} | |
| 260 | + | for name, tc := range map[string]struct { | |
| 261 | + | rt *Runner | |
| 262 | + | feat bool | |
| 263 | + | }{ | |
| 264 | + | "no feature": {full, false}, | |
| 265 | + | "no service": {&Runner{Cache: svc, Artifacts: art}, true}, | |
| 266 | + | "no cache": {&Runner{Results: res, Artifacts: art}, true}, | |
| 267 | + | "no client": {&Runner{Cache: svc, Results: res}, true}, | |
| 268 | + | } { | |
| 269 | + | j := &jobRun{job: &ermite.Job{JobId: "j1", RunId: 42, Ref: "refs/heads/main", TimeoutMinutes: 5}, | |
| 270 | + | contexts: `{"github":{"repository":"o/r"}}`, features: jobFeatures{artifacts: tc.feat}} | |
| 271 | + | j.results = j.resultsOn(tc.rt) | |
| 272 | + | if j.results || j.resultsHandler(tc.rt) != nil { | |
| 273 | + | t.Errorf("%s: results on", name) | |
| 274 | + | } | |
| 275 | + | _, tok, _, results := j.runtimeFor(tc.rt) | |
| 276 | + | if results { | |
| 277 | + | t.Errorf("%s: results in the job spec", name) | |
| 278 | + | } | |
| 279 | + | if _, scp, _, err := svc.RuntimeToken(tok); err == nil && strings.Contains(scp, "Actions.Results") { | |
| 280 | + | t.Errorf("%s: results scope in the token", name) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | j := &jobRun{features: jobFeatures{artifacts: true}} | |
| 284 | + | if !j.resultsOn(full) { | |
| 285 | + | t.Fatal("everything there: results off") | |
| 286 | + | } | |
| 287 | + | } | |
M internal/host/config.go +5 -0
| @@ -77,6 +77,11 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient { | |||
| 77 | 77 | return NewClient(url, token, http.DefaultTransport) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | // ArtifactClient is the JobArtifactService client of a job token. | |
| 81 | + | func (c Config) ArtifactClient(url, token string) ermiteconnect.JobArtifactServiceClient { | |
| 82 | + | return NewArtifactClient(url, token, http.DefaultTransport, c.InsecureHTTP) | |
| 83 | + | } | |
| 84 | + | ||
| 80 | 85 | // environment is Environment, the default when a Config was built without | |
| 81 | 86 | // LoadConfig. | |
| 82 | 87 | func (c Config) environment() string { | |
M internal/host/job.go +5 -2
| @@ -79,6 +79,8 @@ type jobRun struct { | |||
| 79 | 79 | // features is what the server accepts; annLists are the annotations the | |
| 80 | 80 | // executor sent for steps that have not completed yet (see results.go). | |
| 81 | 81 | features jobFeatures | |
| 82 | + | // results says the job gets the results service (see resultsOn). | |
| 83 | + | results bool | |
| 82 | 84 | annLists map[int32][]*ermite.Annotation | |
| 83 | 85 | // annSeen and sumSeen are the steps that sent their annotations and their | |
| 84 | 86 | // summary: each is sent once. | |
| @@ -623,6 +625,7 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct | |||
| 623 | 625 | // Stop runs to completion even when the job context is done. | |
| 624 | 626 | defer j.stopSandbox(ctx, rt, sb) | |
| 625 | 627 | j.features = j.loadFeatures(ctx, rt) | |
| 628 | + | j.results = j.resultsOn(rt) | |
| 626 | 629 | ||
| 627 | 630 | conn, err := j.startSandbox(ctx, rt, sb, stopHard) | |
| 628 | 631 | if err != nil { | |
| @@ -679,7 +682,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo | |||
| 679 | 682 | case <-stopWatch: | |
| 680 | 683 | } | |
| 681 | 684 | }() | |
| 682 | - | return sb.Start(startCtx, rt.spec(j.job)) | |
| 685 | + | return sb.Start(startCtx, rt.spec(j.job, j.resultsHandler(rt))) | |
| 683 | 686 | } | |
| 684 | 687 | ||
| 685 | 688 | // drive sends the job to the executor and handles its frames until it exits. | |
| @@ -700,7 +703,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 700 | 703 | if p := j.job.Permissions; p != nil && j.features.secrets { | |
| 701 | 704 | run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()} | |
| 702 | 705 | } | |
| 703 | - | run.Job.CachePort, run.Job.CacheToken, run.Job.CacheMode = j.cacheFor(rt) | |
| 706 | + | run.Job.CachePort, run.Job.CacheToken, run.Job.CacheMode, run.Job.Results = j.runtimeFor(rt) | |
| 704 | 707 | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | |
| 705 | 708 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | |
| 706 | 709 | Log: j.logLine, | |
M internal/host/results.go +7 -2
| @@ -23,6 +23,9 @@ const ( | |||
| 23 | 23 | // FeatureSecrets: the server sends Job.secrets, vars, environment, | |
| 24 | 24 | // permissions and trusted. Without it Job.trusted is absent, not false. | |
| 25 | 25 | FeatureSecrets = "ci.secrets" | |
| 26 | + | // FeatureArtifacts: the server has JobArtifactService (artifacts v4). The | |
| 27 | + | // runner serves the results service to a job only then. | |
| 28 | + | FeatureArtifacts = "ci.artifacts" | |
| 26 | 29 | ) | |
| 27 | 30 | ||
| 28 | 31 | // Limits of Annotation and UploadSummaryRequest (runner.proto). The executor | |
| @@ -42,7 +45,7 @@ const ( | |||
| 42 | 45 | type ServerFeatures func(ctx context.Context) ([]string, error) | |
| 43 | 46 | ||
| 44 | 47 | // jobFeatures is what the server accepts for this job. | |
| 45 | - | type jobFeatures struct{ annotations, summaries, secrets bool } | |
| 48 | + | type jobFeatures struct{ annotations, summaries, secrets, artifacts bool } | |
| 46 | 49 | ||
| 47 | 50 | func featuresOf(list []string) jobFeatures { | |
| 48 | 51 | var f jobFeatures | |
| @@ -54,6 +57,8 @@ func featuresOf(list []string) jobFeatures { | |||
| 54 | 57 | f.summaries = true | |
| 55 | 58 | case FeatureSecrets: | |
| 56 | 59 | f.secrets = true | |
| 60 | + | case FeatureArtifacts: | |
| 61 | + | f.artifacts = true | |
| 57 | 62 | } | |
| 58 | 63 | } | |
| 59 | 64 | return f | |
| @@ -68,7 +73,7 @@ func (j *jobRun) loadFeatures(ctx context.Context, rt *Runner) jobFeatures { | |||
| 68 | 73 | defer cancel() | |
| 69 | 74 | list, err := rt.Features(cctx) | |
| 70 | 75 | if err != nil { | |
| 71 | - | j.log.Warn().Err(err).Msg("GetVersion failed: annotations and summaries are not sent for this job") | |
| 76 | + | j.log.Warn().Err(err).Msg("GetVersion failed: no annotations, summaries or artifacts for this job") | |
| 72 | 77 | return jobFeatures{} | |
| 73 | 78 | } | |
| 74 | 79 | return featuresOf(list) | |
M internal/host/rpc.go +11 -0
| @@ -106,6 +106,17 @@ func newClient(baseURL, token string, base http.RoundTripper, insecure bool) erm | |||
| 106 | 106 | return ermiteconnect.NewRunnerServiceClient(hc, strings.TrimRight(baseURL, "/")+APIPath) | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | // NewArtifactClient returns a JobArtifactService client that authenticates | |
| 110 | + | // with a job token, with the transport rules of NewClient. The results service | |
| 111 | + | // of the job's channel (resultssvc) calls it. | |
| 112 | + | func NewArtifactClient(baseURL, token string, base http.RoundTripper, insecure bool) ermiteconnect.JobArtifactServiceClient { | |
| 113 | + | hc := &http.Client{ | |
| 114 | + | Transport: bearer{token: token, base: base, allowInsecure: insecure}, | |
| 115 | + | CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, | |
| 116 | + | } | |
| 117 | + | return ermiteconnect.NewJobArtifactServiceClient(hc, strings.TrimRight(baseURL, "/")+APIPath) | |
| 118 | + | } | |
| 119 | + | ||
| 109 | 120 | // ServerFeaturesFunc returns the function that reads the features of the | |
| 110 | 121 | // server with GetVersion, which needs no authentication. It is called once | |
| 111 | 122 | // per job. | |
M internal/host/runner.go +13 -2
| @@ -23,6 +23,7 @@ import ( | |||
| 23 | 23 | "bnema.dev/ermite-runner/internal/actionscache" | |
| 24 | 24 | "bnema.dev/ermite-runner/internal/cachesvc" | |
| 25 | 25 | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 26 | + | "bnema.dev/ermite-runner/internal/resultssvc" | |
| 26 | 27 | "bnema.dev/ermite-runner/internal/sandbox" | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| @@ -50,6 +51,14 @@ type Runner struct { | |||
| 50 | 51 | // means the default transport. | |
| 51 | 52 | Archives *actionscache.Cache | |
| 52 | 53 | ArchiveHTTP *http.Client | |
| 54 | + | // Results, when not nil, is the results service of the runner (artifacts | |
| 55 | + | // v4). A job gets it when Cache is set too (it is served on the cache | |
| 56 | + | // channel and checks the cache's runtime tokens), Artifacts is set and the | |
| 57 | + | // server has the feature ci.artifacts. | |
| 58 | + | Results *resultssvc.Service | |
| 59 | + | // Artifacts builds the JobArtifactService client of a job from its job | |
| 60 | + | // token. | |
| 61 | + | Artifacts func(jobToken string) ermiteconnect.JobArtifactServiceClient | |
| 53 | 62 | ||
| 54 | 63 | // now returns the current time; tests replace it. | |
| 55 | 64 | now func() time.Time | |
| @@ -70,8 +79,10 @@ func (r *Runner) clock() time.Time { | |||
| 70 | 79 | // (sandbox.Config.Resolve in Start), that is from ERMITE_RUNNER_JOB_CPUS, | |
| 71 | 80 | // _JOB_MEMORY and _JOB_DISK, so that the limits are one setting of the runner | |
| 72 | 81 | // and not something a job or this package can change. | |
| 73 | - | func (r *Runner) spec(j *ermite.Job) sandbox.JobSpec { | |
| 74 | - | return sandbox.JobSpec{JobID: j.JobId} | |
| 82 | + | // | |
| 83 | + | // results is the job's results service, nil without one. | |
| 84 | + | func (r *Runner) spec(j *ermite.Job, results http.Handler) sandbox.JobSpec { | |
| 85 | + | return sandbox.JobSpec{JobID: j.JobId, Results: results} | |
| 75 | 86 | } | |
| 76 | 87 | ||
| 77 | 88 | // markLeaked records that a sandbox could not be cleaned up: no new job is | |
A internal/resultssvc/blob.go +417 -0
| @@ -0,0 +1,417 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/hmac" | |
| 8 | + | "encoding/base64" | |
| 9 | + | "encoding/xml" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "net/http" | |
| 14 | + | "net/url" | |
| 15 | + | "strconv" | |
| 16 | + | "strings" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | "connectrpc.com/connect" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Blob operations: the first segment after BlobPath, and the first part of | |
| 24 | + | // the signature. | |
| 25 | + | const ( | |
| 26 | + | opUpload = "upload" | |
| 27 | + | opDownload = "download" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // Limits of the blob requests. | |
| 31 | + | const ( | |
| 32 | + | // chunkSize is the largest data of one message to ermite | |
| 33 | + | // (UploadJobArtifactBlockRequest.data, DownloadJobArtifactResponse.chunk), | |
| 34 | + | // and the one buffer a block upload holds. | |
| 35 | + | chunkSize = 1 << 20 | |
| 36 | + | // maxBlockIDLen is the largest block id, decoded (Azure's limit). | |
| 37 | + | maxBlockIDLen = 64 | |
| 38 | + | // maxBlockListBody bounds a Put Block List body: 50,000 ids of 64 bytes in | |
| 39 | + | // base64 with their tags is about 6 MiB. | |
| 40 | + | maxBlockListBody = 8 << 20 | |
| 41 | + | // maxBlocks is the most ids a block list may name (Azure's limit, and | |
| 42 | + | // CommitJobArtifactRequest's). | |
| 43 | + | maxBlocks = 50000 | |
| 44 | + | // putBlobBlockID is the id of the one block of a single-shot Put Blob. | |
| 45 | + | putBlobBlockID = "ermite-put-blob" | |
| 46 | + | // downloadTries is how many times a download stream that breaks is reopened | |
| 47 | + | // in a row without receiving a byte. | |
| 48 | + | downloadTries = 3 | |
| 49 | + | ) | |
| 50 | + | ||
| 51 | + | // blobSig signs a blob URL: base64url(BlobMAC(op, id, job, se)). The SDK sends | |
| 52 | + | // no bearer token with blob requests (the URL is all it has), so the URL is the | |
| 53 | + | // credential. It is bound to the operation, the artifact, the job of the | |
| 54 | + | // channel and an expiry: a URL that leaks out of its sandbox is useless on the | |
| 55 | + | // channel of any other job. | |
| 56 | + | func (s *Service) blobSig(job Job, op string, id, se int64) string { | |
| 57 | + | return base64.RawURLEncoding.EncodeToString(s.auth.BlobMAC([]byte(op), []byte(strconv.FormatInt(id, 10)), []byte(job.ID), []byte(strconv.FormatInt(se, 10)))) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // blobOK checks the signature and the expiry of a blob URL, in constant time. | |
| 61 | + | func (s *Service) blobOK(job Job, op string, id int64, q url.Values) bool { | |
| 62 | + | if s.auth == nil { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | se, err := strconv.ParseInt(q.Get("se"), 10, 64) | |
| 66 | + | if err != nil || s.now().Unix() >= se { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | got, err := base64.RawURLEncoding.DecodeString(q.Get("sig")) | |
| 70 | + | if err != nil { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | want, _ := base64.RawURLEncoding.DecodeString(s.blobSig(job, op, id, se)) | |
| 74 | + | return hmac.Equal(got, want) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // blob serves the Azure block blob subset that @azure/storage-blob uses on the | |
| 78 | + | // signed URLs: Put Block, Put Block List and Put Blob on upload URLs, Get Blob | |
| 79 | + | // on download URLs. | |
| 80 | + | func (s *Service) blob(job Job, w http.ResponseWriter, r *http.Request, rest string) { | |
| 81 | + | op, idText, _ := strings.Cut(rest, "/") | |
| 82 | + | id, err := strconv.ParseInt(idText, 10, 64) | |
| 83 | + | if (op != opUpload && op != opDownload) || err != nil || id <= 0 { | |
| 84 | + | writeBlobError(w, http.StatusNotFound, "ResourceNotFound") | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | q := r.URL.Query() | |
| 88 | + | if !s.blobOK(job, op, id, q) { | |
| 89 | + | writeBlobError(w, http.StatusForbidden, "AuthenticationFailed") | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | comp := q.Get("comp") | |
| 93 | + | s.log.Debug().Str("method", r.Method).Str("op", op).Str("comp", comp).Int64("artifact", id).Str("job", job.ID).Msg("results blob") | |
| 94 | + | switch { | |
| 95 | + | case op == opUpload && r.Method == http.MethodPut && comp == "block": | |
| 96 | + | bid, err := base64.StdEncoding.DecodeString(q.Get("blockid")) | |
| 97 | + | if err != nil || len(bid) == 0 || len(bid) > maxBlockIDLen { | |
| 98 | + | writeBlobError(w, http.StatusBadRequest, "InvalidQueryParameterValue") | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | if err := s.putBlock(r.Context(), job, id, bid, r.Body); err != nil { | |
| 102 | + | s.blobFailed(w, err, "InvalidBlobOrBlock", id) | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | writeBlobCreated(w) | |
| 106 | + | case op == opUpload && r.Method == http.MethodPut && strings.EqualFold(comp, "blocklist"): | |
| 107 | + | ids, status, code := readBlockList(w, r) | |
| 108 | + | if code != "" { | |
| 109 | + | writeBlobError(w, status, code) | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | if err := s.commit(r.Context(), job, id, ids); err != nil { | |
| 113 | + | s.blobFailed(w, err, "InvalidBlockList", id) | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | writeBlobCreated(w) | |
| 117 | + | case op == opUpload && r.Method == http.MethodPut && comp == "": | |
| 118 | + | // Put Blob: the whole content in one request (the SDK's uploadData for | |
| 119 | + | // small buffers). It becomes one block, then a commit of that block. | |
| 120 | + | bid := []byte(putBlobBlockID) | |
| 121 | + | if err := s.putBlock(r.Context(), job, id, bid, r.Body); err != nil { | |
| 122 | + | s.blobFailed(w, err, "InvalidBlobOrBlock", id) | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | if err := s.commit(r.Context(), job, id, [][]byte{bid}); err != nil { | |
| 126 | + | s.blobFailed(w, err, "InvalidBlockList", id) | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | writeBlobCreated(w) | |
| 130 | + | case op == opDownload && r.Method == http.MethodGet && comp == "": | |
| 131 | + | s.download(job, w, r, id) | |
| 132 | + | default: | |
| 133 | + | writeBlobError(w, http.StatusMethodNotAllowed, "UnsupportedHttpVerb") | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // errBody is a failure to read the request body: the client's side, not | |
| 138 | + | // ermite's. | |
| 139 | + | type errBody struct{ err error } | |
| 140 | + | ||
| 141 | + | func (e errBody) Error() string { return "reading the request body: " + e.err.Error() } | |
| 142 | + | func (e errBody) Unwrap() error { return e.err } | |
| 143 | + | ||
| 144 | + | // putBlock streams a request body to ermite as one block, in messages of at | |
| 145 | + | // most chunkSize. Nothing is spooled: memory is the one buffer. A body that | |
| 146 | + | // fails to read cancels the stream, so that ermite stores nothing of the block | |
| 147 | + | // (a stream that breaks stores nothing; one that ends cleanly would store a | |
| 148 | + | // short block). | |
| 149 | + | func (s *Service) putBlock(ctx context.Context, job Job, id int64, blockID []byte, body io.Reader) error { | |
| 150 | + | ctx, cancel := context.WithCancel(ctx) | |
| 151 | + | defer cancel() | |
| 152 | + | buf := make([]byte, chunkSize) | |
| 153 | + | var st *connect.ClientStreamForClient[ermite.UploadJobArtifactBlockRequest, ermite.UploadJobArtifactBlockResponse] | |
| 154 | + | for { | |
| 155 | + | n, rerr := io.ReadFull(body, buf) | |
| 156 | + | if rerr != nil && !errors.Is(rerr, io.EOF) && !errors.Is(rerr, io.ErrUnexpectedEOF) { | |
| 157 | + | if st != nil { | |
| 158 | + | // Cancel first, then wait for the call to end without closing the | |
| 159 | + | // request side: closing it would end the stream cleanly. | |
| 160 | + | cancel() | |
| 161 | + | if conn, err := st.Conn(); err == nil { | |
| 162 | + | _ = conn.CloseResponse() | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return errBody{rerr} | |
| 166 | + | } | |
| 167 | + | if n > 0 || st == nil { | |
| 168 | + | msg := &ermite.UploadJobArtifactBlockRequest{Data: buf[:n]} | |
| 169 | + | if st == nil { | |
| 170 | + | // The stream opens on the first data read, so that a body that | |
| 171 | + | // fails at once never reaches ermite. | |
| 172 | + | st = job.Client.UploadJobArtifactBlock(ctx) | |
| 173 | + | msg.JobId, msg.ArtifactId, msg.BlockId = job.ID, id, blockID | |
| 174 | + | } | |
| 175 | + | if err := st.Send(msg); err != nil { | |
| 176 | + | // io.EOF: ermite ended the stream; its error comes with the | |
| 177 | + | // response. | |
| 178 | + | _, cerr := st.CloseAndReceive() | |
| 179 | + | if cerr == nil { | |
| 180 | + | cerr = err | |
| 181 | + | } | |
| 182 | + | return cerr | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | if rerr != nil { | |
| 186 | + | break | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | _, err := st.CloseAndReceive() | |
| 190 | + | return err | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // commit sets the content of the artifact to the blocks named. | |
| 194 | + | func (s *Service) commit(ctx context.Context, job Job, id int64, ids [][]byte) error { | |
| 195 | + | _, err := job.Client.CommitJobArtifact(ctx, connect.NewRequest(&ermite.CommitJobArtifactRequest{JobId: job.ID, ArtifactId: id, BlockIds: ids})) | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // readBlockList reads a Put Block List body: <BlockList> with <Latest>, | |
| 200 | + | // <Committed> or <Uncommitted> children holding base64 block ids. All three | |
| 201 | + | // name an uploaded block here: there is no committed blob to take a block | |
| 202 | + | // from. It returns a status and an Azure error code when the body is refused. | |
| 203 | + | // It reads tokens rather than decoding into a struct, so that a list over | |
| 204 | + | // maxBlocks is refused as soon as its id 50,001 opens. | |
| 205 | + | func readBlockList(w http.ResponseWriter, r *http.Request) ([][]byte, int, string) { | |
| 206 | + | const ( | |
| 207 | + | badXML = "InvalidXmlDocument" | |
| 208 | + | badBlock = "InvalidBlockList" | |
| 209 | + | ) | |
| 210 | + | dec := xml.NewDecoder(http.MaxBytesReader(w, r.Body, maxBlockListBody)) | |
| 211 | + | var ( | |
| 212 | + | ids [][]byte | |
| 213 | + | text []byte | |
| 214 | + | depth int | |
| 215 | + | root bool | |
| 216 | + | ) | |
| 217 | + | for { | |
| 218 | + | tok, err := dec.RawToken() | |
| 219 | + | if errors.Is(err, io.EOF) { | |
| 220 | + | break | |
| 221 | + | } | |
| 222 | + | if err != nil { | |
| 223 | + | var mb *http.MaxBytesError | |
| 224 | + | if errors.As(err, &mb) { | |
| 225 | + | return nil, http.StatusRequestEntityTooLarge, "RequestBodyTooLarge" | |
| 226 | + | } | |
| 227 | + | return nil, http.StatusBadRequest, badXML | |
| 228 | + | } | |
| 229 | + | switch t := tok.(type) { | |
| 230 | + | case xml.StartElement: | |
| 231 | + | depth++ | |
| 232 | + | switch { | |
| 233 | + | case depth == 1 && !root && t.Name.Local == "BlockList": | |
| 234 | + | root = true | |
| 235 | + | case depth == 2 && (t.Name.Local == "Latest" || t.Name.Local == "Committed" || t.Name.Local == "Uncommitted"): | |
| 236 | + | if len(ids) == maxBlocks { | |
| 237 | + | return nil, http.StatusBadRequest, badBlock | |
| 238 | + | } | |
| 239 | + | text = text[:0] | |
| 240 | + | default: | |
| 241 | + | return nil, http.StatusBadRequest, badXML | |
| 242 | + | } | |
| 243 | + | case xml.CharData: | |
| 244 | + | if depth == 2 { | |
| 245 | + | text = append(text, t...) | |
| 246 | + | } | |
| 247 | + | case xml.EndElement: | |
| 248 | + | if depth == 2 { | |
| 249 | + | id, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(text))) | |
| 250 | + | if err != nil || len(id) == 0 || len(id) > maxBlockIDLen { | |
| 251 | + | return nil, http.StatusBadRequest, badBlock | |
| 252 | + | } | |
| 253 | + | ids = append(ids, id) | |
| 254 | + | } | |
| 255 | + | depth-- | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | if !root || depth != 0 { | |
| 259 | + | return nil, http.StatusBadRequest, badXML | |
| 260 | + | } | |
| 261 | + | return ids, 0, "" | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // download streams the content of an artifact. If ermite's stream breaks | |
| 265 | + | // after the headers, it is reopened at the offset reached and the same | |
| 266 | + | // response goes on; when that fails downloadTries times in a row the response | |
| 267 | + | // is aborted, so the client sees a truncated body and not a clean end. | |
| 268 | + | func (s *Service) download(job Job, w http.ResponseWriter, r *http.Request, id int64) { | |
| 269 | + | ctx := r.Context() | |
| 270 | + | open := func(off int64) (*connect.ServerStreamForClient[ermite.DownloadJobArtifactResponse], *ermite.DownloadJobArtifactResponse, error) { | |
| 271 | + | st, err := job.Client.DownloadJobArtifact(ctx, connect.NewRequest(&ermite.DownloadJobArtifactRequest{JobId: job.ID, ArtifactId: id, Offset: off})) | |
| 272 | + | if err != nil { | |
| 273 | + | return nil, nil, err | |
| 274 | + | } | |
| 275 | + | if !st.Receive() { | |
| 276 | + | err := st.Err() | |
| 277 | + | _ = st.Close() | |
| 278 | + | if err == nil { | |
| 279 | + | err = connect.NewError(connect.CodeUnavailable, errors.New("the download stream ended before its first message")) | |
| 280 | + | } | |
| 281 | + | return nil, nil, err | |
| 282 | + | } | |
| 283 | + | first := st.Msg() | |
| 284 | + | if first.GetArtifact() == nil || first.GetArtifact().GetId() != id { | |
| 285 | + | _ = st.Close() | |
| 286 | + | return nil, nil, connect.NewError(connect.CodeInternal, errors.New("the download stream does not describe the artifact")) | |
| 287 | + | } | |
| 288 | + | return st, first, nil | |
| 289 | + | } | |
| 290 | + | st, first, err := open(0) | |
| 291 | + | if err != nil { | |
| 292 | + | s.blobFailed(w, err, "InvalidQueryParameterValue", id) | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | a := first.GetArtifact() | |
| 296 | + | size := a.GetSize() | |
| 297 | + | h := w.Header() | |
| 298 | + | mt := a.GetMediaType() | |
| 299 | + | if mt == "" { | |
| 300 | + | mt = "application/zip" | |
| 301 | + | } | |
| 302 | + | h.Set("Content-Type", mt) | |
| 303 | + | if mt != "application/zip" { | |
| 304 | + | // download-artifact v5+ names a file uploaded as is from it | |
| 305 | + | // (streamExtractExternal, download-artifact v8.0.2 dist/index.js:126608-126618). | |
| 306 | + | h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(a.GetName())) | |
| 307 | + | } | |
| 308 | + | h.Set("Content-Length", strconv.FormatInt(size, 10)) | |
| 309 | + | h.Set("x-ms-blob-type", "BlockBlob") | |
| 310 | + | if t := a.GetCreatedAt(); t != nil { | |
| 311 | + | h.Set("Last-Modified", t.AsTime().UTC().Format(http.TimeFormat)) | |
| 312 | + | } | |
| 313 | + | w.WriteHeader(http.StatusOK) | |
| 314 | + | ||
| 315 | + | var off int64 | |
| 316 | + | msg := first | |
| 317 | + | fails := 0 | |
| 318 | + | for { | |
| 319 | + | for { | |
| 320 | + | c := msg.GetChunk() | |
| 321 | + | if off+int64(len(c)) > size { | |
| 322 | + | s.log.Warn().Int64("artifact", id).Str("job", job.ID).Msg("results: the download stream is longer than the artifact") | |
| 323 | + | _ = st.Close() | |
| 324 | + | panic(http.ErrAbortHandler) | |
| 325 | + | } | |
| 326 | + | if len(c) > 0 { | |
| 327 | + | if _, err := w.Write(c); err != nil { | |
| 328 | + | _ = st.Close() // the client is gone | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | off += int64(len(c)) | |
| 332 | + | fails = 0 | |
| 333 | + | } | |
| 334 | + | if !st.Receive() { | |
| 335 | + | break | |
| 336 | + | } | |
| 337 | + | msg = st.Msg() | |
| 338 | + | } | |
| 339 | + | serr := st.Err() | |
| 340 | + | _ = st.Close() | |
| 341 | + | if off == size && serr == nil { | |
| 342 | + | return | |
| 343 | + | } | |
| 344 | + | if serr == nil { | |
| 345 | + | serr = fmt.Errorf("the stream ended at %d of %d bytes", off, size) | |
| 346 | + | } | |
| 347 | + | for { | |
| 348 | + | fails++ | |
| 349 | + | if fails > downloadTries || ctx.Err() != nil { | |
| 350 | + | s.log.Warn().Int64("artifact", id).Str("job", job.ID).Int64("offset", off).Err(serr).Msg("results: download aborted") | |
| 351 | + | panic(http.ErrAbortHandler) | |
| 352 | + | } | |
| 353 | + | s.log.Debug().Int64("artifact", id).Int64("offset", off).Err(serr).Msg("results: download stream broke, reopening") | |
| 354 | + | st, msg, serr = open(off) | |
| 355 | + | if serr == nil { | |
| 356 | + | break | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // blobFailed answers an error of a blob request, from ermite or from the | |
| 363 | + | // body. invalid is the code of a 400 (Azure's code for a bad block or block | |
| 364 | + | // list). | |
| 365 | + | func (s *Service) blobFailed(w http.ResponseWriter, err error, invalid string, id int64) { | |
| 366 | + | var be errBody | |
| 367 | + | if errors.As(err, &be) { | |
| 368 | + | var mb *http.MaxBytesError | |
| 369 | + | if errors.As(err, &mb) { | |
| 370 | + | writeBlobError(w, http.StatusRequestEntityTooLarge, "RequestBodyTooLarge") | |
| 371 | + | return | |
| 372 | + | } | |
| 373 | + | s.log.Debug().Int64("artifact", id).Err(err).Msg("results: blob body") | |
| 374 | + | writeBlobError(w, http.StatusBadRequest, "IncompleteBody") | |
| 375 | + | return | |
| 376 | + | } | |
| 377 | + | code := connect.CodeOf(err) | |
| 378 | + | s.log.Warn().Int64("artifact", id).Str("code", code.String()).Err(err).Msg("results: ermite refused") | |
| 379 | + | switch code { | |
| 380 | + | case connect.CodeResourceExhausted: | |
| 381 | + | writeBlobError(w, http.StatusRequestEntityTooLarge, "RequestBodyTooLarge") | |
| 382 | + | case connect.CodeNotFound: | |
| 383 | + | writeBlobError(w, http.StatusNotFound, "BlobNotFound") | |
| 384 | + | case connect.CodeFailedPrecondition, connect.CodeInvalidArgument: | |
| 385 | + | writeBlobError(w, http.StatusBadRequest, invalid) | |
| 386 | + | case connect.CodeUnauthenticated, connect.CodePermissionDenied: | |
| 387 | + | writeBlobError(w, http.StatusForbidden, "AuthenticationFailed") | |
| 388 | + | default: | |
| 389 | + | writeBlobError(w, http.StatusServiceUnavailable, "ServerBusy") | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // writeBlobCreated is Azure's 201 to a Put Block, Put Block List or Put Blob, | |
| 394 | + | // with the headers the SDK reads into its response object. | |
| 395 | + | func writeBlobCreated(w http.ResponseWriter) { | |
| 396 | + | h := w.Header() | |
| 397 | + | now := time.Now().UTC() | |
| 398 | + | h.Set("ETag", fmt.Sprintf(`"0x%X"`, now.UnixNano())) | |
| 399 | + | h.Set("Last-Modified", now.Format(http.TimeFormat)) | |
| 400 | + | h.Set("x-ms-request-id", strconv.FormatInt(now.UnixNano(), 16)) | |
| 401 | + | h.Set("x-ms-version", "2025-11-05") | |
| 402 | + | h.Set("x-ms-request-server-encrypted", "false") | |
| 403 | + | h.Set("Content-Length", "0") | |
| 404 | + | w.WriteHeader(http.StatusCreated) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // writeBlobError is an Azure storage error: the code in the XML body and in | |
| 408 | + | // x-ms-error-code, which the SDK reads. | |
| 409 | + | func writeBlobError(w http.ResponseWriter, status int, code string) { | |
| 410 | + | body := `<?xml version="1.0" encoding="utf-8"?><Error><Code>` + code + `</Code><Message>` + code + `</Message></Error>` | |
| 411 | + | h := w.Header() | |
| 412 | + | h.Set("Content-Type", "application/xml") | |
| 413 | + | h.Set("x-ms-error-code", code) | |
| 414 | + | h.Set("Content-Length", strconv.Itoa(len(body))) | |
| 415 | + | w.WriteHeader(status) | |
| 416 | + | _, _ = io.WriteString(w, body) | |
| 417 | + | } | |
A internal/resultssvc/fake_test.go +435 -0
| @@ -0,0 +1,435 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "net/http" | |
| 13 | + | "net/http/httptest" | |
| 14 | + | "sort" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "testing" | |
| 18 | + | "time" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | + | "connectrpc.com/connect" | |
| 23 | + | "google.golang.org/protobuf/proto" | |
| 24 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // fakeErmite is an in-memory JobArtifactService that follows the semantics of | |
| 28 | + | // proto/ermite/artifact.proto: job tokens bound to a (run, job, attempt), | |
| 29 | + | // pending, committed and finalized artifacts, blocks replaced on retry, commit | |
| 30 | + | // order, finalize replacing an earlier attempt's artifact of the same name, | |
| 31 | + | // lists by run, downloads from an offset in chunks of 1 MiB, and deletes. Its | |
| 32 | + | // hooks inject failures. | |
| 33 | + | type fakeErmite struct { | |
| 34 | + | ermiteconnect.UnimplementedJobArtifactServiceHandler | |
| 35 | + | ||
| 36 | + | mu sync.Mutex | |
| 37 | + | jobs map[string]fakeJob // by job token | |
| 38 | + | nextID int64 | |
| 39 | + | arts map[int64]*fakeArt | |
| 40 | + | now time.Time | |
| 41 | + | ||
| 42 | + | // maxArtifact, when > 0, is the most bytes of blocks one artifact may hold: | |
| 43 | + | // beyond, UploadJobArtifactBlock fails with resource_exhausted. | |
| 44 | + | maxArtifact int64 | |
| 45 | + | // failNext makes the next call of a procedure (by method name) fail. | |
| 46 | + | failNext map[string]error | |
| 47 | + | // cut, when set, is called before each chunk of a download stream with the | |
| 48 | + | // offset the stream was opened at and the bytes it has sent; an error ends | |
| 49 | + | // the stream. | |
| 50 | + | cut func(open, sent int64) error | |
| 51 | + | // blockStreams counts the UploadJobArtifactBlock calls that ended. | |
| 52 | + | blockStreams int | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type fakeJob struct { | |
| 56 | + | run int64 | |
| 57 | + | job string | |
| 58 | + | attempt int32 | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | type artState int | |
| 62 | + | ||
| 63 | + | const ( | |
| 64 | + | pending artState = iota | |
| 65 | + | committed | |
| 66 | + | finalized | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | type fakeArt struct { | |
| 70 | + | a *ermite.Artifact | |
| 71 | + | state artState | |
| 72 | + | blocks map[string][]byte | |
| 73 | + | list [][]byte | |
| 74 | + | content []byte | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func newFakeErmite() *fakeErmite { | |
| 78 | + | return &fakeErmite{jobs: map[string]fakeJob{}, arts: map[int64]*fakeArt{}, failNext: map[string]error{}, | |
| 79 | + | now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // serve starts the fake on an HTTP server and returns its URL. | |
| 83 | + | func (f *fakeErmite) serve(t *testing.T) string { | |
| 84 | + | t.Helper() | |
| 85 | + | path, h := ermiteconnect.NewJobArtifactServiceHandler(f) | |
| 86 | + | mux := http.NewServeMux() | |
| 87 | + | mux.Handle(path, h) | |
| 88 | + | srv := httptest.NewServer(mux) | |
| 89 | + | t.Cleanup(srv.Close) | |
| 90 | + | return srv.URL | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // addJob registers a job token. | |
| 94 | + | func (f *fakeErmite) addJob(token string, run int64, job string, attempt int32) { | |
| 95 | + | f.mu.Lock() | |
| 96 | + | defer f.mu.Unlock() | |
| 97 | + | f.jobs[token] = fakeJob{run, job, attempt} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // bearer is an http.RoundTripper that adds a job token, as the runner's ermite | |
| 101 | + | // client does. | |
| 102 | + | type bearer struct { | |
| 103 | + | token string | |
| 104 | + | base http.RoundTripper | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (b bearer) RoundTrip(r *http.Request) (*http.Response, error) { | |
| 108 | + | r = r.Clone(r.Context()) | |
| 109 | + | r.Header.Set("Authorization", "Bearer "+b.token) | |
| 110 | + | return b.base.RoundTrip(r) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // client is a JobArtifactService client of the fake with a job token. | |
| 114 | + | func client(url, token string) ermiteconnect.JobArtifactServiceClient { | |
| 115 | + | return ermiteconnect.NewJobArtifactServiceClient(&http.Client{Transport: bearer{token, http.DefaultTransport}}, url) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (f *fakeErmite) streams() int { | |
| 119 | + | f.mu.Lock() | |
| 120 | + | defer f.mu.Unlock() | |
| 121 | + | return f.blockStreams | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // caller checks the token and the job id of a request. f.mu must be held. | |
| 125 | + | func (f *fakeErmite) caller(h http.Header, jobID, method string) (fakeJob, error) { | |
| 126 | + | if err := f.failNext[method]; err != nil { | |
| 127 | + | delete(f.failNext, method) | |
| 128 | + | return fakeJob{}, err | |
| 129 | + | } | |
| 130 | + | tok, ok := strings.CutPrefix(h.Get("Authorization"), "Bearer ") | |
| 131 | + | j, known := f.jobs[tok] | |
| 132 | + | if !ok || !known { | |
| 133 | + | return fakeJob{}, connect.NewError(connect.CodeUnauthenticated, errors.New("unknown job token")) | |
| 134 | + | } | |
| 135 | + | if jobID != j.job { | |
| 136 | + | return fakeJob{}, connect.NewError(connect.CodePermissionDenied, errors.New("job_id is not the job of the token")) | |
| 137 | + | } | |
| 138 | + | return j, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func digestOf(b []byte) string { | |
| 142 | + | s := sha256.Sum256(b) | |
| 143 | + | return "sha256:" + hex.EncodeToString(s[:]) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (f *fakeErmite) CreateJobArtifact(_ context.Context, req *connect.Request[ermite.CreateJobArtifactRequest]) (*connect.Response[ermite.CreateJobArtifactResponse], error) { | |
| 147 | + | f.mu.Lock() | |
| 148 | + | defer f.mu.Unlock() | |
| 149 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "CreateJobArtifact") | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | name := req.Msg.GetName() | |
| 154 | + | if name == "" || len(name) > 256 || strings.ContainsAny(name, "\":<>|*?\\/") { | |
| 155 | + | return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid artifact name %q", name)) | |
| 156 | + | } | |
| 157 | + | for id, a := range f.arts { | |
| 158 | + | if a.a.GetRunId() != j.run || a.a.GetName() != name { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | if a.state == finalized && a.a.GetAttempt() == j.attempt { | |
| 162 | + | return nil, connect.NewError(connect.CodeAlreadyExists, errors.New(msgExists)) | |
| 163 | + | } | |
| 164 | + | if a.state != finalized && a.a.GetJobId() == j.job { | |
| 165 | + | delete(f.arts, id) // a new create is a new upload | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | mt := req.Msg.GetMediaType() | |
| 169 | + | if mt == "" { | |
| 170 | + | mt = "application/zip" | |
| 171 | + | } | |
| 172 | + | exp := f.now.Add(90 * 24 * time.Hour) | |
| 173 | + | if e := req.Msg.GetExpiresAt(); e != nil && e.AsTime().After(f.now) { | |
| 174 | + | exp = e.AsTime() | |
| 175 | + | } | |
| 176 | + | f.nextID++ | |
| 177 | + | f.arts[f.nextID] = &fakeArt{a: &ermite.Artifact{ | |
| 178 | + | Id: f.nextID, Repo: "bnema/app", RunId: j.run, Attempt: j.attempt, JobId: j.job, Name: name, MediaType: mt, | |
| 179 | + | CreatedAt: timestamppb.New(f.now), ExpiresAt: timestamppb.New(exp), | |
| 180 | + | }, blocks: map[string][]byte{}} | |
| 181 | + | return connect.NewResponse(&ermite.CreateJobArtifactResponse{ArtifactId: f.nextID, ExpiresAt: timestamppb.New(exp)}), nil | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (f *fakeErmite) UploadJobArtifactBlock(_ context.Context, st *connect.ClientStream[ermite.UploadJobArtifactBlockRequest]) (*connect.Response[ermite.UploadJobArtifactBlockResponse], error) { | |
| 185 | + | defer func() { | |
| 186 | + | f.mu.Lock() | |
| 187 | + | f.blockStreams++ | |
| 188 | + | f.mu.Unlock() | |
| 189 | + | }() | |
| 190 | + | if !st.Receive() { | |
| 191 | + | if err := st.Err(); err != nil { | |
| 192 | + | return nil, err | |
| 193 | + | } | |
| 194 | + | return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("empty stream")) | |
| 195 | + | } | |
| 196 | + | first := st.Msg() | |
| 197 | + | f.mu.Lock() | |
| 198 | + | j, err := f.caller(st.RequestHeader(), first.GetJobId(), "UploadJobArtifactBlock") | |
| 199 | + | var art *fakeArt | |
| 200 | + | var others int64 | |
| 201 | + | if err == nil { | |
| 202 | + | art = f.arts[first.GetArtifactId()] | |
| 203 | + | switch { | |
| 204 | + | case art == nil || art.a.GetJobId() != j.job || art.state == finalized: | |
| 205 | + | err = connect.NewError(connect.CodeNotFound, errors.New("not a pending artifact of this job")) | |
| 206 | + | case art.state == committed: | |
| 207 | + | err = connect.NewError(connect.CodeFailedPrecondition, errors.New("the artifact is committed")) | |
| 208 | + | case len(first.GetBlockId()) == 0 || len(first.GetBlockId()) > 64: | |
| 209 | + | err = connect.NewError(connect.CodeInvalidArgument, errors.New("block_id must be 1 to 64 bytes")) | |
| 210 | + | } | |
| 211 | + | if art != nil { | |
| 212 | + | for id, b := range art.blocks { | |
| 213 | + | if id != string(first.GetBlockId()) { | |
| 214 | + | others += int64(len(b)) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | limit := f.maxArtifact | |
| 220 | + | f.mu.Unlock() | |
| 221 | + | if err != nil { | |
| 222 | + | return nil, err | |
| 223 | + | } | |
| 224 | + | var data bytes.Buffer | |
| 225 | + | msg := first | |
| 226 | + | for { | |
| 227 | + | if len(msg.GetData()) > chunkSize { | |
| 228 | + | return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("data over 1 MiB")) | |
| 229 | + | } | |
| 230 | + | data.Write(msg.GetData()) | |
| 231 | + | if limit > 0 && others+int64(data.Len()) > limit { | |
| 232 | + | return nil, connect.NewError(connect.CodeResourceExhausted, fmt.Errorf("the artifact is over the size of one artifact (%d bytes)", limit)) | |
| 233 | + | } | |
| 234 | + | if !st.Receive() { | |
| 235 | + | break | |
| 236 | + | } | |
| 237 | + | msg = st.Msg() | |
| 238 | + | if msg.GetJobId() != "" || msg.GetArtifactId() != 0 || len(msg.GetBlockId()) != 0 { | |
| 239 | + | return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("ids in a message after the first")) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | if err := st.Err(); err != nil { | |
| 243 | + | return nil, err // a broken stream stores nothing | |
| 244 | + | } | |
| 245 | + | f.mu.Lock() | |
| 246 | + | defer f.mu.Unlock() | |
| 247 | + | if art.state != pending || f.arts[art.a.GetId()] != art { | |
| 248 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the artifact is no longer pending")) | |
| 249 | + | } | |
| 250 | + | art.blocks[string(first.GetBlockId())] = data.Bytes() | |
| 251 | + | return connect.NewResponse(&ermite.UploadJobArtifactBlockResponse{Size: int64(data.Len())}), nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func sameList(a, b [][]byte) bool { | |
| 255 | + | if len(a) != len(b) { | |
| 256 | + | return false | |
| 257 | + | } | |
| 258 | + | for i := range a { | |
| 259 | + | if !bytes.Equal(a[i], b[i]) { | |
| 260 | + | return false | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return true | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (f *fakeErmite) CommitJobArtifact(_ context.Context, req *connect.Request[ermite.CommitJobArtifactRequest]) (*connect.Response[ermite.CommitJobArtifactResponse], error) { | |
| 267 | + | f.mu.Lock() | |
| 268 | + | defer f.mu.Unlock() | |
| 269 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "CommitJobArtifact") | |
| 270 | + | if err != nil { | |
| 271 | + | return nil, err | |
| 272 | + | } | |
| 273 | + | art := f.arts[req.Msg.GetArtifactId()] | |
| 274 | + | if art == nil || art.a.GetJobId() != j.job || art.state == finalized { | |
| 275 | + | return nil, connect.NewError(connect.CodeNotFound, errors.New("not a pending artifact of this job")) | |
| 276 | + | } | |
| 277 | + | ids := req.Msg.GetBlockIds() | |
| 278 | + | if art.state == committed { | |
| 279 | + | if !sameList(ids, art.list) { | |
| 280 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("committed with another block list")) | |
| 281 | + | } | |
| 282 | + | return connect.NewResponse(&ermite.CommitJobArtifactResponse{Size: art.a.GetSize(), Digest: art.a.GetDigest()}), nil | |
| 283 | + | } | |
| 284 | + | if len(ids) > maxBlocks { | |
| 285 | + | return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("more than 50,000 block ids")) | |
| 286 | + | } | |
| 287 | + | var content []byte | |
| 288 | + | for _, id := range ids { | |
| 289 | + | b, ok := art.blocks[string(id)] | |
| 290 | + | if !ok { | |
| 291 | + | return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("block %x was never uploaded", id)) | |
| 292 | + | } | |
| 293 | + | content = append(content, b...) | |
| 294 | + | } | |
| 295 | + | art.content, art.list, art.blocks, art.state = content, ids, nil, committed | |
| 296 | + | art.a.Size, art.a.Digest = int64(len(content)), digestOf(content) | |
| 297 | + | return connect.NewResponse(&ermite.CommitJobArtifactResponse{Size: art.a.GetSize(), Digest: art.a.GetDigest()}), nil | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (f *fakeErmite) FinalizeJobArtifact(_ context.Context, req *connect.Request[ermite.FinalizeJobArtifactRequest]) (*connect.Response[ermite.FinalizeJobArtifactResponse], error) { | |
| 301 | + | f.mu.Lock() | |
| 302 | + | defer f.mu.Unlock() | |
| 303 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "FinalizeJobArtifact") | |
| 304 | + | if err != nil { | |
| 305 | + | return nil, err | |
| 306 | + | } | |
| 307 | + | var art *fakeArt | |
| 308 | + | for _, a := range f.arts { | |
| 309 | + | if a.a.GetJobId() == j.job && a.a.GetRunId() == j.run && a.a.GetName() == req.Msg.GetName() && (art == nil || a.a.GetId() > art.a.GetId()) { | |
| 310 | + | art = a | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | switch { | |
| 314 | + | case art == nil: | |
| 315 | + | return nil, connect.NewError(connect.CodeNotFound, errors.New("no pending artifact of that name")) | |
| 316 | + | case art.state == finalized: | |
| 317 | + | return connect.NewResponse(&ermite.FinalizeJobArtifactResponse{Artifact: proto.CloneOf(art.a)}), nil | |
| 318 | + | case art.state == pending: | |
| 319 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the artifact is not committed")) | |
| 320 | + | case req.Msg.GetDigest() != "" && req.Msg.GetDigest() != art.a.GetDigest(): | |
| 321 | + | return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("digest differs from the content")) | |
| 322 | + | } | |
| 323 | + | for id, a := range f.arts { | |
| 324 | + | if a == art || a.state != finalized || a.a.GetRunId() != j.run || a.a.GetName() != art.a.GetName() { | |
| 325 | + | continue | |
| 326 | + | } | |
| 327 | + | if a.a.GetAttempt() == j.attempt { | |
| 328 | + | return nil, connect.NewError(connect.CodeAlreadyExists, errors.New(msgExists)) | |
| 329 | + | } | |
| 330 | + | delete(f.arts, id) // a re-run replaces the earlier attempt's artifact | |
| 331 | + | } | |
| 332 | + | art.state = finalized | |
| 333 | + | return connect.NewResponse(&ermite.FinalizeJobArtifactResponse{Artifact: proto.CloneOf(art.a)}), nil | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (f *fakeErmite) ListJobArtifacts(_ context.Context, req *connect.Request[ermite.ListJobArtifactsRequest]) (*connect.Response[ermite.ListJobArtifactsResponse], error) { | |
| 337 | + | f.mu.Lock() | |
| 338 | + | defer f.mu.Unlock() | |
| 339 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "ListJobArtifacts") | |
| 340 | + | if err != nil { | |
| 341 | + | return nil, err | |
| 342 | + | } | |
| 343 | + | out := &ermite.ListJobArtifactsResponse{} | |
| 344 | + | for _, a := range f.arts { | |
| 345 | + | if a.state != finalized || a.a.GetRunId() != j.run || | |
| 346 | + | (req.Msg.GetName() != "" && a.a.GetName() != req.Msg.GetName()) || | |
| 347 | + | (req.Msg.GetArtifactId() != 0 && a.a.GetId() != req.Msg.GetArtifactId()) { | |
| 348 | + | continue | |
| 349 | + | } | |
| 350 | + | out.Artifacts = append(out.Artifacts, proto.CloneOf(a.a)) | |
| 351 | + | } | |
| 352 | + | sort.Slice(out.Artifacts, func(i, k int) bool { return out.Artifacts[i].GetId() < out.Artifacts[k].GetId() }) | |
| 353 | + | return connect.NewResponse(out), nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func (f *fakeErmite) DownloadJobArtifact(_ context.Context, req *connect.Request[ermite.DownloadJobArtifactRequest], st *connect.ServerStream[ermite.DownloadJobArtifactResponse]) error { | |
| 357 | + | f.mu.Lock() | |
| 358 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "DownloadJobArtifact") | |
| 359 | + | var art *fakeArt | |
| 360 | + | if err == nil { | |
| 361 | + | art = f.arts[req.Msg.GetArtifactId()] | |
| 362 | + | if art == nil || art.state != finalized || art.a.GetRunId() != j.run { | |
| 363 | + | err = connect.NewError(connect.CodeNotFound, errors.New("artifact not found")) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | var meta *ermite.Artifact | |
| 367 | + | var content []byte | |
| 368 | + | if err == nil { | |
| 369 | + | meta, content = proto.CloneOf(art.a), art.content | |
| 370 | + | } | |
| 371 | + | cut := f.cut | |
| 372 | + | f.mu.Unlock() | |
| 373 | + | if err != nil { | |
| 374 | + | return err | |
| 375 | + | } | |
| 376 | + | off := req.Msg.GetOffset() | |
| 377 | + | if off < 0 || off > int64(len(content)) { | |
| 378 | + | return connect.NewError(connect.CodeOutOfRange, errors.New("offset beyond the size")) | |
| 379 | + | } | |
| 380 | + | if err := st.Send(&ermite.DownloadJobArtifactResponse{Artifact: meta}); err != nil { | |
| 381 | + | return err | |
| 382 | + | } | |
| 383 | + | var sent int64 | |
| 384 | + | for p := content[off:]; len(p) > 0; { | |
| 385 | + | if cut != nil { | |
| 386 | + | if err := cut(off, sent); err != nil { | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | n := min(len(p), chunkSize) | |
| 391 | + | if err := st.Send(&ermite.DownloadJobArtifactResponse{Chunk: p[:n]}); err != nil { | |
| 392 | + | return err | |
| 393 | + | } | |
| 394 | + | sent += int64(n) | |
| 395 | + | p = p[n:] | |
| 396 | + | } | |
| 397 | + | return nil | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func (f *fakeErmite) DeleteJobArtifact(_ context.Context, req *connect.Request[ermite.DeleteJobArtifactRequest]) (*connect.Response[ermite.DeleteJobArtifactResponse], error) { | |
| 401 | + | f.mu.Lock() | |
| 402 | + | defer f.mu.Unlock() | |
| 403 | + | j, err := f.caller(req.Header(), req.Msg.GetJobId(), "DeleteJobArtifact") | |
| 404 | + | if err != nil { | |
| 405 | + | return nil, err | |
| 406 | + | } | |
| 407 | + | for id, a := range f.arts { | |
| 408 | + | if a.state == finalized && a.a.GetRunId() == j.run && a.a.GetName() == req.Msg.GetName() { | |
| 409 | + | delete(f.arts, id) | |
| 410 | + | return connect.NewResponse(&ermite.DeleteJobArtifactResponse{ArtifactId: id}), nil | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | return nil, connect.NewError(connect.CodeNotFound, errors.New("artifact not found")) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // content returns the content of a finalized or committed artifact. | |
| 417 | + | func (f *fakeErmite) content(id int64) ([]byte, bool) { | |
| 418 | + | f.mu.Lock() | |
| 419 | + | defer f.mu.Unlock() | |
| 420 | + | a := f.arts[id] | |
| 421 | + | if a == nil || a.state == pending { | |
| 422 | + | return nil, false | |
| 423 | + | } | |
| 424 | + | return a.content, true | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // pendingBlocks returns the number of blocks a pending artifact holds. | |
| 428 | + | func (f *fakeErmite) pendingBlocks(id int64) int { | |
| 429 | + | f.mu.Lock() | |
| 430 | + | defer f.mu.Unlock() | |
| 431 | + | if a := f.arts[id]; a != nil { | |
| 432 | + | return len(a.blocks) | |
| 433 | + | } | |
| 434 | + | return -1 | |
| 435 | + | } | |
A internal/resultssvc/resultssvc.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package resultssvc is the GitHub Actions results service of the runner, the | |
| 4 | + | // part of it that artifacts v4 use (plan CI-4 C). actions/upload-artifact@v4+ | |
| 5 | + | // and actions/download-artifact@v4+ bundle @actions/artifact v2, which talks | |
| 6 | + | // Twirp JSON (github.actions.results.api.v1.ArtifactService) to | |
| 7 | + | // ACTIONS_RESULTS_URL and moves the bytes with Azure block blob requests to the | |
| 8 | + | // signed URLs that service returns. | |
| 9 | + | // | |
| 10 | + | // The runner keeps no artifact: the service translates both protocols to | |
| 11 | + | // ermite's JobArtifactService, with the job token of the job, and streams the | |
| 12 | + | // blocks and the downloads through without spooling them. Run and job ids come | |
| 13 | + | // from the Job the channel serves, never from the bodies of the requests: a | |
| 14 | + | // job reaches the artifacts of its own run only, and ermite checks that too. | |
| 15 | + | // | |
| 16 | + | // A Handler serves the channel of one job, beside the cache (cachesvc): the | |
| 17 | + | // sandbox reaches both through the same relay. The Twirp calls carry | |
| 18 | + | // ACTIONS_RUNTIME_TOKEN, which must be a token of that job with the job's | |
| 19 | + | // Actions.Results scope; the blob URLs carry no bearer (the Azure SDK sends | |
| 20 | + | // none), so they are signed, bound to the job and expire with its token. | |
| 21 | + | package resultssvc | |
| 22 | + | ||
| 23 | + | import ( | |
| 24 | + | "crypto/sha256" | |
| 25 | + | "encoding/hex" | |
| 26 | + | "net/http" | |
| 27 | + | "strconv" | |
| 28 | + | "strings" | |
| 29 | + | "time" | |
| 30 | + | ||
| 31 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 32 | + | "github.com/bnema/zerowrap" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // Paths the service answers on. The toolkit appends "twirp/github.actions. | |
| 36 | + | // results.api.v1.ArtifactService/<method>" to ACTIONS_RESULTS_URL | |
| 37 | + | // (ArtifactHttpClient.request, upload-artifact v4.6.2 dist/upload/index.js:2753); | |
| 38 | + | // the blob URLs are the service's own, given out in signedUploadUrl and | |
| 39 | + | // signedUrl. | |
| 40 | + | const ( | |
| 41 | + | TwirpPath = "/twirp/github.actions.results.api.v1.ArtifactService/" | |
| 42 | + | // BlobPath is followed by "upload/<artifact id>" or "download/<artifact id>". | |
| 43 | + | BlobPath = "/_apis/results/blob/" | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | // Auth is what the service needs from the runner's runtime token signer | |
| 47 | + | // (cachesvc.Service implements it). | |
| 48 | + | type Auth interface { | |
| 49 | + | // RuntimeToken checks ACTIONS_RUNTIME_TOKEN (a JWT this runner made, | |
| 50 | + | // signature and expiry) and returns its job id ("j" claim), its | |
| 51 | + | // space-separated scp claim, and its expiry. | |
| 52 | + | RuntimeToken(token string) (jobID, scopes string, expiry time.Time, err error) | |
| 53 | + | // BlobMAC is an HMAC of the parts under a key domain reserved for blob URL | |
| 54 | + | // signatures. | |
| 55 | + | BlobMAC(parts ...[]byte) []byte | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Config configures a Service. | |
| 59 | + | type Config struct { | |
| 60 | + | Auth Auth | |
| 61 | + | // Now is the clock; nil means time.Now. | |
| 62 | + | Now func() time.Time | |
| 63 | + | Log zerowrap.Logger | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Service is the results service. It holds no state of its own: one Service | |
| 67 | + | // serves the channels of every job of the runner. | |
| 68 | + | type Service struct { | |
| 69 | + | auth Auth | |
| 70 | + | now func() time.Time | |
| 71 | + | log zerowrap.Logger | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // New returns a Service. | |
| 75 | + | func New(cfg Config) *Service { | |
| 76 | + | if cfg.Now == nil { | |
| 77 | + | cfg.Now = time.Now | |
| 78 | + | } | |
| 79 | + | return &Service{auth: cfg.Auth, now: cfg.Now, log: cfg.Log} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Job is the job a sandbox channel serves. | |
| 83 | + | type Job struct { | |
| 84 | + | // ID is the Job.job_id. | |
| 85 | + | ID string | |
| 86 | + | // RunID is the run of the job and Attempt its attempt. | |
| 87 | + | RunID int64 | |
| 88 | + | Attempt int32 | |
| 89 | + | // Client calls ermite with the job token (ermitejob_...), which never | |
| 90 | + | // reaches the sandbox. | |
| 91 | + | Client ermiteconnect.JobArtifactServiceClient | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Handler serves TwirpPath and BlobPath for the channel of one job; any other | |
| 95 | + | // path is a 404. | |
| 96 | + | func (s *Service) Handler(job Job) http.Handler { | |
| 97 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 98 | + | switch { | |
| 99 | + | case strings.HasPrefix(r.URL.Path, TwirpPath): | |
| 100 | + | s.twirp(job, w, r, strings.TrimPrefix(r.URL.Path, TwirpPath)) | |
| 101 | + | case strings.HasPrefix(r.URL.Path, BlobPath): | |
| 102 | + | s.blob(job, w, r, strings.TrimPrefix(r.URL.Path, BlobPath)) | |
| 103 | + | default: | |
| 104 | + | http.NotFound(w, r) | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // JobBackendID is the workflow_job_run_backend_id of a job: 32 lowercase hex | |
| 110 | + | // characters of SHA-256(jobID "/" decimal attempt). It has no ':' since the | |
| 111 | + | // toolkit splits the scope on ':' (getBackendIdsFromToken, upload-artifact | |
| 112 | + | // v4.6.2 dist/upload/index.js:3106-3141). | |
| 113 | + | func JobBackendID(jobID string, attempt int32) string { | |
| 114 | + | sum := sha256.Sum256([]byte(jobID + "/" + strconv.FormatInt(int64(attempt), 10))) | |
| 115 | + | return hex.EncodeToString(sum[:16]) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Scope is the scp entry of a job: "Actions.Results:<decimal run id>: | |
| 119 | + | // <JobBackendID>". The toolkit reads its run and job backend ids from it. | |
| 120 | + | func Scope(runID int64, jobID string, attempt int32) string { | |
| 121 | + | return "Actions.Results:" + strconv.FormatInt(runID, 10) + ":" + JobBackendID(jobID, attempt) | |
| 122 | + | } | |
A internal/resultssvc/rig_test.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/hmac" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "encoding/json" | |
| 11 | + | "errors" | |
| 12 | + | "io" | |
| 13 | + | "net/http" | |
| 14 | + | "net/http/httptest" | |
| 15 | + | "strconv" | |
| 16 | + | "strings" | |
| 17 | + | "sync" | |
| 18 | + | "testing" | |
| 19 | + | "time" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // clock is a settable clock. | |
| 23 | + | type clock struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | t time.Time | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (c *clock) now() time.Time { | |
| 29 | + | c.mu.Lock() | |
| 30 | + | defer c.mu.Unlock() | |
| 31 | + | return c.t | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (c *clock) add(d time.Duration) { | |
| 35 | + | c.mu.Lock() | |
| 36 | + | defer c.mu.Unlock() | |
| 37 | + | c.t = c.t.Add(d) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // jwtAuth is the Auth of the tests: HS256 three-segment JWTs as the runner | |
| 41 | + | // mints them (claims j, exp, iat, scp), and blob MACs under their own domain. | |
| 42 | + | type jwtAuth struct { | |
| 43 | + | key []byte | |
| 44 | + | now func() time.Time | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (a *jwtAuth) mac(domain string, parts ...[]byte) []byte { | |
| 48 | + | h := hmac.New(sha256.New, a.key) | |
| 49 | + | h.Write([]byte(domain)) | |
| 50 | + | for _, p := range parts { | |
| 51 | + | h.Write([]byte{0}) | |
| 52 | + | h.Write(p) | |
| 53 | + | } | |
| 54 | + | return h.Sum(nil) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (a *jwtAuth) tokenKey() []byte { return a.mac("token") } | |
| 58 | + | ||
| 59 | + | // mint makes a runtime token of job with the scopes and a life of ttl. | |
| 60 | + | func (a *jwtAuth) mint(job, scp string, ttl time.Duration) string { | |
| 61 | + | b64 := base64.RawURLEncoding | |
| 62 | + | now := a.now() | |
| 63 | + | h := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) | |
| 64 | + | c, _ := json.Marshal(map[string]any{"j": job, "scp": scp, "iat": now.Unix(), "exp": now.Add(ttl).Unix()}) | |
| 65 | + | p := b64.EncodeToString(c) | |
| 66 | + | m := hmac.New(sha256.New, a.tokenKey()) | |
| 67 | + | m.Write([]byte(h + "." + p)) | |
| 68 | + | return h + "." + p + "." + b64.EncodeToString(m.Sum(nil)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (a *jwtAuth) RuntimeToken(tok string) (string, string, time.Time, error) { | |
| 72 | + | bad := errors.New("invalid token") | |
| 73 | + | parts := strings.Split(tok, ".") | |
| 74 | + | if len(parts) != 3 { | |
| 75 | + | return "", "", time.Time{}, bad | |
| 76 | + | } | |
| 77 | + | m := hmac.New(sha256.New, a.tokenKey()) | |
| 78 | + | m.Write([]byte(parts[0] + "." + parts[1])) | |
| 79 | + | sig, err := base64.RawURLEncoding.DecodeString(parts[2]) | |
| 80 | + | if err != nil || !hmac.Equal(sig, m.Sum(nil)) { | |
| 81 | + | return "", "", time.Time{}, bad | |
| 82 | + | } | |
| 83 | + | pb, err := base64.RawURLEncoding.DecodeString(parts[1]) | |
| 84 | + | var c struct { | |
| 85 | + | J string `json:"j"` | |
| 86 | + | Scp string `json:"scp"` | |
| 87 | + | Exp int64 `json:"exp"` | |
| 88 | + | } | |
| 89 | + | if err != nil || json.Unmarshal(pb, &c) != nil || a.now().Unix() >= c.Exp { | |
| 90 | + | return "", "", time.Time{}, bad | |
| 91 | + | } | |
| 92 | + | return c.J, c.Scp, time.Unix(c.Exp, 0), nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (a *jwtAuth) BlobMAC(parts ...[]byte) []byte { return a.mac("blob", parts...) } | |
| 96 | + | ||
| 97 | + | // genericRead is the scope that GitHub puts before Actions.Results. | |
| 98 | + | const genericRead = "Actions.GenericRead:00000000-0000-0000-0000-000000000000" | |
| 99 | + | ||
| 100 | + | // rig is a Service in front of a fake ermite. | |
| 101 | + | type rig struct { | |
| 102 | + | t *testing.T | |
| 103 | + | fake *fakeErmite | |
| 104 | + | url string | |
| 105 | + | auth *jwtAuth | |
| 106 | + | clk *clock | |
| 107 | + | svc *Service | |
| 108 | + | nJobs int | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func newRig(t *testing.T) *rig { | |
| 112 | + | t.Helper() | |
| 113 | + | clk := &clock{t: time.Now()} | |
| 114 | + | f := newFakeErmite() | |
| 115 | + | auth := &jwtAuth{key: []byte("test key"), now: clk.now} | |
| 116 | + | return &rig{t: t, fake: f, url: f.serve(t), auth: auth, clk: clk, svc: New(Config{Auth: auth, Now: clk.now})} | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // tjob is a job of the rig: its Job, its runtime token and its channel. | |
| 120 | + | type tjob struct { | |
| 121 | + | Job | |
| 122 | + | token string | |
| 123 | + | srv *httptest.Server | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // job registers a job in the fake ermite and serves its channel. | |
| 127 | + | func (r *rig) job(run int64, id string, attempt int32) *tjob { | |
| 128 | + | r.t.Helper() | |
| 129 | + | r.nJobs++ | |
| 130 | + | et := "ermitejob_" + strconv.Itoa(r.nJobs) | |
| 131 | + | r.fake.addJob(et, run, id, attempt) | |
| 132 | + | j := Job{ID: id, RunID: run, Attempt: attempt, Client: client(r.url, et)} | |
| 133 | + | srv := httptest.NewServer(r.svc.Handler(j)) | |
| 134 | + | r.t.Cleanup(srv.Close) | |
| 135 | + | return &tjob{Job: j, token: r.auth.mint(id, genericRead+" "+Scope(run, id, attempt), time.Hour), srv: srv} | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // twirp posts a Twirp request with the job's token and returns the status and | |
| 139 | + | // the body. | |
| 140 | + | func (j *tjob) twirp(t *testing.T, method, body string) (int, []byte) { | |
| 141 | + | t.Helper() | |
| 142 | + | return j.twirpWith(t, j.token, method, body) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (j *tjob) twirpWith(t *testing.T, token, method, body string) (int, []byte) { | |
| 146 | + | t.Helper() | |
| 147 | + | req, _ := http.NewRequest(http.MethodPost, j.srv.URL+TwirpPath+method, strings.NewReader(body)) | |
| 148 | + | req.Header.Set("Content-Type", "application/json") | |
| 149 | + | if token != "" { | |
| 150 | + | req.Header.Set("Authorization", "Bearer "+token) | |
| 151 | + | } | |
| 152 | + | res, err := http.DefaultClient.Do(req) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | defer func() { _ = res.Body.Close() }() | |
| 157 | + | b, _ := io.ReadAll(res.Body) | |
| 158 | + | if ct := res.Header.Get("Content-Type"); ct != "application/json" { | |
| 159 | + | t.Errorf("%s: Content-Type %q", method, ct) | |
| 160 | + | } | |
| 161 | + | return res.StatusCode, b | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // do sends a request and returns the response with its body read. | |
| 165 | + | func do(t *testing.T, method, url string, body io.Reader) (*http.Response, []byte) { | |
| 166 | + | t.Helper() | |
| 167 | + | req, _ := http.NewRequest(method, url, body) | |
| 168 | + | res, err := http.DefaultClient.Do(req) | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | defer func() { _ = res.Body.Close() }() | |
| 173 | + | b, _ := io.ReadAll(res.Body) | |
| 174 | + | return res, b | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // create calls CreateArtifact and returns the signed upload URL. | |
| 178 | + | func (j *tjob) create(t *testing.T, name, mime string) string { | |
| 179 | + | t.Helper() | |
| 180 | + | body := `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"y","name":"` + name + `","version":7` | |
| 181 | + | if mime != "" { | |
| 182 | + | body += `,"mime_type":"` + mime + `"` | |
| 183 | + | } | |
| 184 | + | st, b := j.twirp(t, "CreateArtifact", body+"}") | |
| 185 | + | var out struct { | |
| 186 | + | OK bool `json:"ok"` | |
| 187 | + | URL string `json:"signedUploadUrl"` | |
| 188 | + | } | |
| 189 | + | if st != http.StatusOK || json.Unmarshal(b, &out) != nil || !out.OK { | |
| 190 | + | t.Fatalf("CreateArtifact: %d %s", st, b) | |
| 191 | + | } | |
| 192 | + | return out.URL | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // upload uploads content as blocks of blockSize and finalizes it; it returns | |
| 196 | + | // the artifact id. | |
| 197 | + | func (j *tjob) upload(t *testing.T, name, mime string, content []byte, blockSize int) string { | |
| 198 | + | t.Helper() | |
| 199 | + | u := j.create(t, name, mime) | |
| 200 | + | var list strings.Builder | |
| 201 | + | list.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><BlockList>`) | |
| 202 | + | for i := 0; i == 0 || i*blockSize < len(content); i++ { | |
| 203 | + | part := content[i*blockSize : min(len(content), (i+1)*blockSize)] | |
| 204 | + | id := base64.StdEncoding.EncodeToString([]byte(name + "-block-" + strconv.Itoa(i))) | |
| 205 | + | res, b := do(t, http.MethodPut, u+"&comp=block&blockid="+id, bytes.NewReader(part)) | |
| 206 | + | if res.StatusCode != http.StatusCreated { | |
| 207 | + | t.Fatalf("Put Block: %d %s", res.StatusCode, b) | |
| 208 | + | } | |
| 209 | + | list.WriteString("<Latest>" + id + "</Latest>") | |
| 210 | + | } | |
| 211 | + | list.WriteString("</BlockList>") | |
| 212 | + | res, b := do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(list.String())) | |
| 213 | + | if res.StatusCode != http.StatusCreated { | |
| 214 | + | t.Fatalf("Put Block List: %d %s", res.StatusCode, b) | |
| 215 | + | } | |
| 216 | + | return j.finalize(t, name, content) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (j *tjob) finalize(t *testing.T, name string, content []byte) string { | |
| 220 | + | t.Helper() | |
| 221 | + | st, b := j.twirp(t, "FinalizeArtifact", `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"y","name":"`+name+ | |
| 222 | + | `","size":"`+strconv.Itoa(len(content))+`","hash":"`+digestOf(content)+`"}`) | |
| 223 | + | var out struct { | |
| 224 | + | OK bool `json:"ok"` | |
| 225 | + | ID string `json:"artifactId"` | |
| 226 | + | } | |
| 227 | + | if st != http.StatusOK || json.Unmarshal(b, &out) != nil || !out.OK || out.ID == "" { | |
| 228 | + | t.Fatalf("FinalizeArtifact: %d %s", st, b) | |
| 229 | + | } | |
| 230 | + | return out.ID | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // signedURL calls GetSignedArtifactURL. | |
| 234 | + | func (j *tjob) signedURL(t *testing.T, name string) string { | |
| 235 | + | t.Helper() | |
| 236 | + | st, b := j.twirp(t, "GetSignedArtifactURL", `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"uploader","name":"`+name+`"}`) | |
| 237 | + | var out struct { | |
| 238 | + | URL string `json:"signedUrl"` | |
| 239 | + | } | |
| 240 | + | if st != http.StatusOK || json.Unmarshal(b, &out) != nil || out.URL == "" { | |
| 241 | + | t.Fatalf("GetSignedArtifactURL: %d %s", st, b) | |
| 242 | + | } | |
| 243 | + | return out.URL | |
| 244 | + | } | |
A internal/resultssvc/service_test.go +574 -0
| @@ -0,0 +1,574 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "encoding/hex" | |
| 11 | + | "encoding/json" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 14 | + | "io" | |
| 15 | + | "net/http" | |
| 16 | + | "net/http/httptest" | |
| 17 | + | "net/url" | |
| 18 | + | "regexp" | |
| 19 | + | "strconv" | |
| 20 | + | "strings" | |
| 21 | + | "sync/atomic" | |
| 22 | + | "testing" | |
| 23 | + | "time" | |
| 24 | + | ||
| 25 | + | "connectrpc.com/connect" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | func TestJobBackendIDAndScope(t *testing.T) { | |
| 29 | + | sum := sha256.Sum256([]byte("job-1/2")) | |
| 30 | + | if got, want := JobBackendID("job-1", 2), hex.EncodeToString(sum[:])[:32]; got != want { | |
| 31 | + | t.Fatalf("JobBackendID = %q, want %q", got, want) | |
| 32 | + | } | |
| 33 | + | if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(JobBackendID("a:b", 1)) { | |
| 34 | + | t.Fatal("not 32 lowercase hex characters") | |
| 35 | + | } | |
| 36 | + | if JobBackendID("job-1", 1) == JobBackendID("job-1", 2) { | |
| 37 | + | t.Fatal("attempts must differ") | |
| 38 | + | } | |
| 39 | + | if got, want := Scope(42, "job-1", 2), "Actions.Results:42:"+JobBackendID("job-1", 2); got != want { | |
| 40 | + | t.Fatalf("Scope = %q, want %q", got, want) | |
| 41 | + | } | |
| 42 | + | // The toolkit splits the scope on ':' and wants three parts. | |
| 43 | + | if n := len(strings.Split(Scope(42, "a:b", 1), ":")); n != 3 { | |
| 44 | + | t.Fatalf("scope has %d parts", n) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Golden request bodies, as the real upload-artifact v4.6.2 / v7.0.2 and | |
| 49 | + | // download-artifact v4.3.0 sent them in the C0 spike. | |
| 50 | + | const ( | |
| 51 | + | goldenCreateV4 = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single","version":4}` | |
| 52 | + | goldenCreateV7 = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"report.html","version":7,"mime_type":"text/html","expires_at":"2026-11-14T12:00:00.000Z"}` | |
| 53 | + | goldenFinalize = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single","size":"146","hash":"%s"}` | |
| 54 | + | goldenListName = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test","name_filter":"single"}` | |
| 55 | + | goldenListID = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test","id_filter":"%s"}` | |
| 56 | + | goldenListAll = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"test"}` | |
| 57 | + | goldenSignedURL = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single"}` | |
| 58 | + | goldenDelete = `{"workflow_run_backend_id":"11111111-aaaa-4000-8000-000000000001","workflow_job_run_backend_id":"build","name":"single"}` | |
| 59 | + | goldenBlockList1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><BlockList><Latest>M2VhNWQ3MWYtZGEyYi00YmQ5LThiNDAtY2Y4MGM3OTBmY2NjMDAwMDAwMDAwMDAw</Latest></BlockList>` | |
| 60 | + | goldenBlockID1 = "M2VhNWQ3MWYtZGEyYi00YmQ5LThiNDAtY2Y4MGM3OTBmY2NjMDAwMDAwMDAwMDAw" | |
| 61 | + | ) | |
| 62 | + | ||
| 63 | + | func TestTwirpGolden(t *testing.T) { | |
| 64 | + | r := newRig(t) | |
| 65 | + | build := r.job(7, "build", 1) | |
| 66 | + | test := r.job(7, "test", 1) | |
| 67 | + | content := bytes.Repeat([]byte("zip!"), 37)[:146] | |
| 68 | + | ||
| 69 | + | st, b := build.twirp(t, "CreateArtifact", goldenCreateV4) | |
| 70 | + | var created map[string]any | |
| 71 | + | if st != http.StatusOK || json.Unmarshal(b, &created) != nil { | |
| 72 | + | t.Fatalf("create: %d %s", st, b) | |
| 73 | + | } | |
| 74 | + | if len(created) != 2 || created["ok"] != true { | |
| 75 | + | t.Fatalf("create reply: %s", b) | |
| 76 | + | } | |
| 77 | + | u, err := url.Parse(created["signedUploadUrl"].(string)) | |
| 78 | + | if err != nil || u.Scheme != "http" || u.Host != strings.TrimPrefix(build.srv.URL, "http://") || u.Path != BlobPath+"upload/1" || | |
| 79 | + | u.Query().Get("se") != strconv.FormatInt(r.clk.now().Add(time.Hour).Unix(), 10) || u.Query().Get("sig") == "" { | |
| 80 | + | t.Fatalf("signedUploadUrl: %v", created["signedUploadUrl"]) | |
| 81 | + | } | |
| 82 | + | res, _ := do(t, http.MethodPut, u.String()+"&comp=block&blockid="+url.QueryEscape(goldenBlockID1), bytes.NewReader(content)) | |
| 83 | + | if res.StatusCode != http.StatusCreated { | |
| 84 | + | t.Fatalf("block: %d", res.StatusCode) | |
| 85 | + | } | |
| 86 | + | res, _ = do(t, http.MethodPut, u.String()+"&comp=blocklist", strings.NewReader(goldenBlockList1)) | |
| 87 | + | if res.StatusCode != http.StatusCreated { | |
| 88 | + | t.Fatalf("blocklist: %d", res.StatusCode) | |
| 89 | + | } | |
| 90 | + | st, b = build.twirp(t, "FinalizeArtifact", fmt.Sprintf(goldenFinalize, digestOf(content))) | |
| 91 | + | if st != http.StatusOK || string(b) != `{"artifactId":"1","ok":true}`+"\n" { | |
| 92 | + | t.Fatalf("finalize: %d %s", st, b) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | wantItem := `{"workflowRunBackendId":"7","workflowJobRunBackendId":"` + JobBackendID("build", 1) + | |
| 96 | + | `","databaseId":"1","name":"single","size":"146","createdAt":"2026-10-15T12:00:00Z","digest":"` + digestOf(content) + `"}` | |
| 97 | + | for _, body := range []string{goldenListName, fmt.Sprintf(goldenListID, "1"), strings.Replace(fmt.Sprintf(goldenListID, "1"), `"1"`, "1", 1), goldenListAll} { | |
| 98 | + | st, b = test.twirp(t, "ListArtifacts", body) | |
| 99 | + | if st != http.StatusOK || string(b) != `{"artifacts":[`+wantItem+`]}`+"\n" { | |
| 100 | + | t.Fatalf("list %s: %d %s", body, st, b) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | st, b = test.twirp(t, "ListArtifacts", fmt.Sprintf(goldenListID, "2")) | |
| 104 | + | if st != http.StatusOK || string(b) != `{"artifacts":[]}`+"\n" { | |
| 105 | + | t.Fatalf("empty list: %d %s", st, b) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // The body names the uploader's job: ignored, the URL is for test's channel. | |
| 109 | + | st, b = test.twirp(t, "GetSignedArtifactURL", goldenSignedURL) | |
| 110 | + | var signed struct { | |
| 111 | + | URL string `json:"signedUrl"` | |
| 112 | + | } | |
| 113 | + | if st != http.StatusOK || json.Unmarshal(b, &signed) != nil || !strings.HasPrefix(signed.URL, test.srv.URL+BlobPath+"download/1?se=") { | |
| 114 | + | t.Fatalf("signed URL: %d %s", st, b) | |
| 115 | + | } | |
| 116 | + | res, got := do(t, http.MethodGet, signed.URL, nil) | |
| 117 | + | if res.StatusCode != http.StatusOK || !bytes.Equal(got, content) { | |
| 118 | + | t.Fatalf("download: %d %d bytes", res.StatusCode, len(got)) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | st, b = build.twirp(t, "DeleteArtifact", goldenDelete) | |
| 122 | + | if st != http.StatusOK || string(b) != `{"artifactId":"1","ok":true}`+"\n" { | |
| 123 | + | t.Fatalf("delete: %d %s", st, b) | |
| 124 | + | } | |
| 125 | + | st, b = test.twirp(t, "GetSignedArtifactURL", goldenSignedURL) | |
| 126 | + | if st != http.StatusNotFound || !strings.Contains(string(b), `"code":"not_found"`) { | |
| 127 | + | t.Fatalf("signed URL of a deleted artifact: %d %s", st, b) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // v7: mime_type and expires_at reach ermite. | |
| 131 | + | st, b = build.twirp(t, "CreateArtifact", goldenCreateV7) | |
| 132 | + | if st != http.StatusOK { | |
| 133 | + | t.Fatalf("create v7: %d %s", st, b) | |
| 134 | + | } | |
| 135 | + | r.fake.mu.Lock() | |
| 136 | + | a := r.fake.arts[2].a | |
| 137 | + | r.fake.mu.Unlock() | |
| 138 | + | if a.GetMediaType() != "text/html" || !a.GetExpiresAt().AsTime().Equal(time.Date(2026, 11, 14, 12, 0, 0, 0, time.UTC)) || a.GetJobId() != "build" || a.GetRunId() != 7 { | |
| 139 | + | t.Fatalf("created: %v", a) | |
| 140 | + | } | |
| 141 | + | st, b = build.twirp(t, "CreateArtifact", `{"name":"x","expires_at":"tomorrow"}`) | |
| 142 | + | if st != http.StatusBadRequest || !strings.Contains(string(b), `"invalid_argument"`) { | |
| 143 | + | t.Fatalf("bad expires_at: %d %s", st, b) | |
| 144 | + | } | |
| 145 | + | st, b = build.twirp(t, "FinalizeArtifact", `{"name":"x","size":"many"}`) | |
| 146 | + | if st != http.StatusBadRequest { | |
| 147 | + | t.Fatalf("bad size: %d %s", st, b) | |
| 148 | + | } | |
| 149 | + | st, b = build.twirp(t, "ListArtifacts", `not json`) | |
| 150 | + | if st != http.StatusBadRequest || !strings.Contains(string(b), `"malformed"`) { | |
| 151 | + | t.Fatalf("bad JSON: %d %s", st, b) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestTwirpErrorMapping(t *testing.T) { | |
| 156 | + | r := newRig(t) | |
| 157 | + | j := r.job(1, "job", 1) | |
| 158 | + | cases := []struct { | |
| 159 | + | code connect.Code | |
| 160 | + | msg string | |
| 161 | + | status int | |
| 162 | + | twirp string | |
| 163 | + | want string | |
| 164 | + | }{ | |
| 165 | + | {connect.CodeInvalidArgument, "bad name", 400, "invalid_argument", "bad name"}, | |
| 166 | + | {connect.CodeNotFound, "gone", 404, "not_found", "gone"}, | |
| 167 | + | {connect.CodeAlreadyExists, "", 409, "already_exists", msgExists}, | |
| 168 | + | {connect.CodeAlreadyExists, "taken", 409, "already_exists", "taken"}, | |
| 169 | + | {connect.CodeFailedPrecondition, "job ended", 412, "failed_precondition", "job ended"}, | |
| 170 | + | {connect.CodeResourceExhausted, "over the 10 GiB quota", 412, "failed_precondition", "over the 10 GiB quota"}, | |
| 171 | + | {connect.CodeUnauthenticated, "token", 401, "unauthenticated", "token"}, | |
| 172 | + | {connect.CodePermissionDenied, "denied", 401, "unauthenticated", "denied"}, | |
| 173 | + | {connect.CodeUnavailable, "down", 503, "unavailable", "down"}, | |
| 174 | + | {connect.CodeDeadlineExceeded, "slow", 503, "unavailable", "slow"}, | |
| 175 | + | {connect.CodeInternal, "bug", 503, "unavailable", "bug"}, | |
| 176 | + | {connect.CodeUnknown, "what", 503, "unavailable", "what"}, | |
| 177 | + | {connect.CodeCanceled, "stop", 503, "unavailable", "stop"}, | |
| 178 | + | {connect.CodeUnimplemented, "old", 503, "unavailable", "old"}, | |
| 179 | + | } | |
| 180 | + | for _, c := range cases { | |
| 181 | + | t.Run(c.code.String()+"/"+c.msg, func(t *testing.T) { | |
| 182 | + | r.fake.mu.Lock() | |
| 183 | + | r.fake.failNext["CreateJobArtifact"] = connect.NewError(c.code, errors.New(c.msg)) | |
| 184 | + | r.fake.mu.Unlock() | |
| 185 | + | st, b := j.twirp(t, "CreateArtifact", goldenCreateV4) | |
| 186 | + | var e map[string]string | |
| 187 | + | if err := json.Unmarshal(b, &e); err != nil || st != c.status || e["code"] != c.twirp || e["msg"] != c.want || len(e) != 2 { | |
| 188 | + | t.Fatalf("got %d %s, want %d %s %q", st, b, c.status, c.twirp, c.want) | |
| 189 | + | } | |
| 190 | + | }) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestTwirpAuth(t *testing.T) { | |
| 195 | + | r := newRig(t) | |
| 196 | + | j := r.job(1, "job", 1) | |
| 197 | + | other := r.job(1, "other", 1) | |
| 198 | + | nextAttempt := Job{ID: "job", RunID: 1, Attempt: 2} | |
| 199 | + | for name, tok := range map[string]string{ | |
| 200 | + | "no bearer": "", | |
| 201 | + | "garbage": "abc", | |
| 202 | + | "other job's token": other.token, | |
| 203 | + | "no results scope": r.auth.mint("job", genericRead, time.Hour), | |
| 204 | + | "scope of another run": r.auth.mint("job", Scope(2, "job", 1), time.Hour), | |
| 205 | + | "scope of next try": r.auth.mint("job", Scope(nextAttempt.RunID, nextAttempt.ID, nextAttempt.Attempt), time.Hour), | |
| 206 | + | "scope as a prefix": r.auth.mint("job", Scope(1, "job", 1)+"0", time.Hour), | |
| 207 | + | "forged": (&jwtAuth{key: []byte("other"), now: r.clk.now}).mint("job", Scope(1, "job", 1), time.Hour), | |
| 208 | + | "expired": r.auth.mint("job", Scope(1, "job", 1), -time.Second), | |
| 209 | + | } { | |
| 210 | + | st, b := j.twirpWith(t, tok, "ListArtifacts", goldenListAll) | |
| 211 | + | if st != http.StatusUnauthorized || !strings.Contains(string(b), `"code":"unauthenticated"`) { | |
| 212 | + | t.Errorf("%s: %d %s", name, st, b) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | if st, b := j.twirp(t, "ListArtifacts", goldenListAll); st != http.StatusOK { | |
| 216 | + | t.Fatalf("own token: %d %s", st, b) | |
| 217 | + | } | |
| 218 | + | if st, b := j.twirp(t, "Nope", "{}"); st != http.StatusNotFound || !strings.Contains(string(b), `"bad_route"`) { | |
| 219 | + | t.Errorf("unknown method: %d %s", st, b) | |
| 220 | + | } | |
| 221 | + | res, b := do(t, http.MethodGet, j.srv.URL+TwirpPath+"ListArtifacts", nil) | |
| 222 | + | if res.StatusCode != http.StatusMethodNotAllowed || !strings.Contains(string(b), `"bad_route"`) { | |
| 223 | + | t.Errorf("GET: %d %s", res.StatusCode, b) | |
| 224 | + | } | |
| 225 | + | if res, _ := do(t, http.MethodGet, j.srv.URL+"/_apis/artifactcache/cache", nil); res.StatusCode != http.StatusNotFound { | |
| 226 | + | t.Errorf("other path: %d", res.StatusCode) | |
| 227 | + | } | |
| 228 | + | st, b := j.twirp(t, "ListArtifacts", `{"name_filter":"`+strings.Repeat("x", maxTwirpBody)+`"}`) | |
| 229 | + | if st != http.StatusBadRequest { | |
| 230 | + | t.Errorf("huge body: %d %.100s", st, b) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func checkBlobError(t *testing.T, what string, res *http.Response, body []byte, status int, code string) { | |
| 235 | + | t.Helper() | |
| 236 | + | want := `<?xml version="1.0" encoding="utf-8"?><Error><Code>` + code + `</Code><Message>` + code + `</Message></Error>` | |
| 237 | + | if res.StatusCode != status || res.Header.Get("x-ms-error-code") != code || string(body) != want { | |
| 238 | + | t.Errorf("%s: %d %s %s, want %d %s", what, res.StatusCode, res.Header.Get("x-ms-error-code"), body, status, code) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestBlobUpload(t *testing.T) { | |
| 243 | + | r := newRig(t) | |
| 244 | + | j := r.job(1, "job", 1) | |
| 245 | + | other := r.job(1, "other", 1) | |
| 246 | + | ||
| 247 | + | t.Run("blocks", func(t *testing.T) { | |
| 248 | + | u := j.create(t, "blocks", "") | |
| 249 | + | res, b := do(t, http.MethodPut, u+"&comp=block&blockid="+url.QueryEscape(base64.StdEncoding.EncodeToString([]byte("b1"))), strings.NewReader("hello ")) | |
| 250 | + | if res.StatusCode != http.StatusCreated || len(b) != 0 || res.Header.Get("Content-Length") != "0" { | |
| 251 | + | t.Fatalf("block: %d %q", res.StatusCode, b) | |
| 252 | + | } | |
| 253 | + | for _, h := range []string{"ETag", "Last-Modified", "x-ms-request-id", "x-ms-version", "x-ms-request-server-encrypted"} { | |
| 254 | + | if res.Header.Get(h) == "" { | |
| 255 | + | t.Errorf("no %s", h) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | // A retry of a block replaces it. | |
| 259 | + | do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString([]byte("b2")), strings.NewReader("wrong")) | |
| 260 | + | do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString([]byte("b2")), strings.NewReader("world")) | |
| 261 | + | list := `<BlockList><Uncommitted>` + base64.StdEncoding.EncodeToString([]byte("b1")) + `</Uncommitted><Latest>` + | |
| 262 | + | base64.StdEncoding.EncodeToString([]byte("b2")) + `</Latest><Committed>` + base64.StdEncoding.EncodeToString([]byte("b1")) + `</Committed></BlockList>` | |
| 263 | + | if res, b := do(t, http.MethodPut, u+"&comp=BlockList", strings.NewReader(list)); res.StatusCode != http.StatusCreated { | |
| 264 | + | t.Fatalf("blocklist: %d %s", res.StatusCode, b) | |
| 265 | + | } | |
| 266 | + | id, _ := strconv.ParseInt(j.finalize(t, "blocks", []byte("hello worldhello ")), 10, 64) | |
| 267 | + | if c, _ := r.fake.content(id); string(c) != "hello worldhello " { | |
| 268 | + | t.Fatalf("content %q", c) | |
| 269 | + | } | |
| 270 | + | }) | |
| 271 | + | ||
| 272 | + | t.Run("empty block list", func(t *testing.T) { | |
| 273 | + | u := j.create(t, "empty", "") | |
| 274 | + | if res, b := do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><BlockList/>`)); res.StatusCode != http.StatusCreated { | |
| 275 | + | t.Fatalf("blocklist: %d %s", res.StatusCode, b) | |
| 276 | + | } | |
| 277 | + | id, _ := strconv.ParseInt(j.finalize(t, "empty", nil), 10, 64) | |
| 278 | + | if c, ok := r.fake.content(id); !ok || len(c) != 0 { | |
| 279 | + | t.Fatalf("content %q %v", c, ok) | |
| 280 | + | } | |
| 281 | + | }) | |
| 282 | + | ||
| 283 | + | t.Run("put blob", func(t *testing.T) { | |
| 284 | + | u := j.create(t, "single-shot", "") | |
| 285 | + | if res, b := do(t, http.MethodPut, u, strings.NewReader("all at once")); res.StatusCode != http.StatusCreated { | |
| 286 | + | t.Fatalf("put blob: %d %s", res.StatusCode, b) | |
| 287 | + | } | |
| 288 | + | id, _ := strconv.ParseInt(j.finalize(t, "single-shot", []byte("all at once")), 10, 64) | |
| 289 | + | if c, _ := r.fake.content(id); string(c) != "all at once" { | |
| 290 | + | t.Fatalf("content %q", c) | |
| 291 | + | } | |
| 292 | + | u = j.create(t, "single-shot-empty", "") | |
| 293 | + | if res, b := do(t, http.MethodPut, u, nil); res.StatusCode != http.StatusCreated { | |
| 294 | + | t.Fatalf("empty put blob: %d %s", res.StatusCode, b) | |
| 295 | + | } | |
| 296 | + | j.finalize(t, "single-shot-empty", nil) | |
| 297 | + | }) | |
| 298 | + | ||
| 299 | + | t.Run("refusals", func(t *testing.T) { | |
| 300 | + | u := j.create(t, "refused", "") | |
| 301 | + | pu, _ := url.Parse(u) | |
| 302 | + | q := pu.Query() | |
| 303 | + | blk := "&comp=block&blockid=" + base64.StdEncoding.EncodeToString([]byte("b")) | |
| 304 | + | tamper := func(k, v string) string { | |
| 305 | + | q2 := url.Values{"se": {q.Get("se")}, "sig": {q.Get("sig")}} | |
| 306 | + | q2.Set(k, v) | |
| 307 | + | return j.srv.URL + pu.Path + "?" + q2.Encode() | |
| 308 | + | } | |
| 309 | + | sig := []byte(q.Get("sig")) | |
| 310 | + | sig[3] ^= 1 | |
| 311 | + | se, _ := strconv.ParseInt(q.Get("se"), 10, 64) | |
| 312 | + | for name, target := range map[string]string{ | |
| 313 | + | "tampered signature": tamper("sig", string(sig)) + blk, | |
| 314 | + | "no signature": j.srv.URL + pu.Path + "?se=" + q.Get("se") + blk, | |
| 315 | + | "later expiry": tamper("se", strconv.FormatInt(se+1, 10)) + blk, | |
| 316 | + | "another artifact": j.srv.URL + BlobPath + "upload/99?" + q.Encode() + blk, | |
| 317 | + | "other job's URL": other.srv.URL + pu.Path + "?" + q.Encode() + blk, | |
| 318 | + | "as a download URL": j.srv.URL + BlobPath + "download/" + strings.TrimPrefix(pu.Path, BlobPath+"upload/") + "?" + q.Encode(), | |
| 319 | + | } { | |
| 320 | + | method := http.MethodPut | |
| 321 | + | if strings.Contains(target, "/download/") { | |
| 322 | + | method = http.MethodGet | |
| 323 | + | } | |
| 324 | + | res, b := do(t, method, target, strings.NewReader("x")) | |
| 325 | + | checkBlobError(t, name, res, b, http.StatusForbidden, "AuthenticationFailed") | |
| 326 | + | } | |
| 327 | + | res, b := do(t, http.MethodPut, u+"&comp=block&blockid=%%%", strings.NewReader("x")) | |
| 328 | + | checkBlobError(t, "bad block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") | |
| 329 | + | res, b = do(t, http.MethodPut, u+"&comp=block&blockid="+base64.StdEncoding.EncodeToString(make([]byte, 65)), strings.NewReader("x")) | |
| 330 | + | checkBlobError(t, "long block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") | |
| 331 | + | res, b = do(t, http.MethodPut, u+"&comp=block", strings.NewReader("x")) | |
| 332 | + | checkBlobError(t, "no block id", res, b, http.StatusBadRequest, "InvalidQueryParameterValue") | |
| 333 | + | res, b = do(t, http.MethodDelete, u, nil) | |
| 334 | + | checkBlobError(t, "DELETE", res, b, http.StatusMethodNotAllowed, "UnsupportedHttpVerb") | |
| 335 | + | res, b = do(t, http.MethodPut, u+"&comp=appendblock", strings.NewReader("x")) | |
| 336 | + | checkBlobError(t, "append block", res, b, http.StatusMethodNotAllowed, "UnsupportedHttpVerb") | |
| 337 | + | res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(`<BlockList><Latest>`+base64.StdEncoding.EncodeToString([]byte("never"))+`</Latest></BlockList>`)) | |
| 338 | + | checkBlobError(t, "unknown block", res, b, http.StatusBadRequest, "InvalidBlockList") | |
| 339 | + | // The large bodies go straight to the handler: over the loopback, a | |
| 340 | + | // megabyte that the server reads in small parts can take seconds. | |
| 341 | + | res, b = direct(t, r.svc.Handler(j.Job), u+"&comp=blocklist", strings.NewReader(`<BlockList><Latest>`+strings.Repeat(`AA==</Latest><Latest>`, maxBlocks)+`AA==</Latest></BlockList>`)) | |
| 342 | + | checkBlobError(t, "too many blocks", res, b, http.StatusBadRequest, "InvalidBlockList") | |
| 343 | + | res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(`<Other/>`)) | |
| 344 | + | checkBlobError(t, "not a block list", res, b, http.StatusBadRequest, "InvalidXmlDocument") | |
| 345 | + | res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(`<BlockList><Latest>AA==`)) | |
| 346 | + | checkBlobError(t, "truncated XML", res, b, http.StatusBadRequest, "InvalidXmlDocument") | |
| 347 | + | res, b = direct(t, r.svc.Handler(j.Job), u+"&comp=blocklist", io.MultiReader(strings.NewReader(`<BlockList><!--`), bytes.NewReader(make([]byte, maxBlockListBody)))) | |
| 348 | + | checkBlobError(t, "huge block list", res, b, http.StatusRequestEntityTooLarge, "RequestBodyTooLarge") | |
| 349 | + | res, b = do(t, http.MethodGet, j.srv.URL+BlobPath+"elsewhere/1", nil) | |
| 350 | + | checkBlobError(t, "unknown op", res, b, http.StatusNotFound, "ResourceNotFound") | |
| 351 | + | ||
| 352 | + | // The token's expiry ends the URL. | |
| 353 | + | r.clk.add(time.Hour) | |
| 354 | + | res, b = do(t, http.MethodPut, u+blk, strings.NewReader("x")) | |
| 355 | + | checkBlobError(t, "expired", res, b, http.StatusForbidden, "AuthenticationFailed") | |
| 356 | + | r.clk.add(-time.Hour) | |
| 357 | + | }) | |
| 358 | + | ||
| 359 | + | t.Run("ermite errors", func(t *testing.T) { | |
| 360 | + | u := j.create(t, "quota", "") | |
| 361 | + | r.fake.mu.Lock() | |
| 362 | + | r.fake.maxArtifact = 3 << 20 | |
| 363 | + | r.fake.mu.Unlock() | |
| 364 | + | defer func() { | |
| 365 | + | r.fake.mu.Lock() | |
| 366 | + | r.fake.maxArtifact = 0 | |
| 367 | + | r.fake.mu.Unlock() | |
| 368 | + | }() | |
| 369 | + | res, b := do(t, http.MethodPut, u+"&comp=block&blockid=AA==", bytes.NewReader(make([]byte, 5<<20))) | |
| 370 | + | checkBlobError(t, "over quota", res, b, http.StatusRequestEntityTooLarge, "RequestBodyTooLarge") | |
| 371 | + | for _, c := range []struct { | |
| 372 | + | code connect.Code | |
| 373 | + | status int | |
| 374 | + | azure string | |
| 375 | + | }{ | |
| 376 | + | {connect.CodeNotFound, 404, "BlobNotFound"}, | |
| 377 | + | {connect.CodeFailedPrecondition, 400, "InvalidBlobOrBlock"}, | |
| 378 | + | {connect.CodeInvalidArgument, 400, "InvalidBlobOrBlock"}, | |
| 379 | + | {connect.CodeResourceExhausted, 413, "RequestBodyTooLarge"}, | |
| 380 | + | {connect.CodePermissionDenied, 403, "AuthenticationFailed"}, | |
| 381 | + | {connect.CodeUnauthenticated, 403, "AuthenticationFailed"}, | |
| 382 | + | {connect.CodeUnavailable, 503, "ServerBusy"}, | |
| 383 | + | {connect.CodeInternal, 503, "ServerBusy"}, | |
| 384 | + | } { | |
| 385 | + | r.fake.mu.Lock() | |
| 386 | + | r.fake.failNext["UploadJobArtifactBlock"] = connect.NewError(c.code, errors.New("injected")) | |
| 387 | + | r.fake.mu.Unlock() | |
| 388 | + | res, b := do(t, http.MethodPut, u+"&comp=block&blockid=AA==", strings.NewReader("x")) | |
| 389 | + | checkBlobError(t, "block "+c.code.String(), res, b, c.status, c.azure) | |
| 390 | + | } | |
| 391 | + | r.fake.mu.Lock() | |
| 392 | + | r.fake.failNext["CommitJobArtifact"] = connect.NewError(connect.CodeInvalidArgument, errors.New("injected")) | |
| 393 | + | r.fake.mu.Unlock() | |
| 394 | + | res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader("<BlockList/>")) | |
| 395 | + | checkBlobError(t, "commit invalid_argument", res, b, http.StatusBadRequest, "InvalidBlockList") | |
| 396 | + | // A different list after a commit: failed_precondition. | |
| 397 | + | if res, _ := do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader("<BlockList/>")); res.StatusCode != http.StatusCreated { | |
| 398 | + | t.Fatalf("commit: %d", res.StatusCode) | |
| 399 | + | } | |
| 400 | + | res, b = do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader("<BlockList><Latest>AA==</Latest></BlockList>")) | |
| 401 | + | checkBlobError(t, "commit failed_precondition", res, b, http.StatusBadRequest, "InvalidBlockList") | |
| 402 | + | res, b = do(t, http.MethodPut, u+"&comp=block&blockid=AA==", strings.NewReader("x")) | |
| 403 | + | checkBlobError(t, "block after commit", res, b, http.StatusBadRequest, "InvalidBlobOrBlock") | |
| 404 | + | }) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // direct serves a PUT with h, without a network. | |
| 408 | + | func direct(t *testing.T, h http.Handler, target string, body io.Reader) (*http.Response, []byte) { | |
| 409 | + | t.Helper() | |
| 410 | + | rec := httptest.NewRecorder() | |
| 411 | + | h.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, target, body)) | |
| 412 | + | res := rec.Result() | |
| 413 | + | b, _ := io.ReadAll(res.Body) | |
| 414 | + | return res, b | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // failingBody yields data, then fails. | |
| 418 | + | type failingBody struct { | |
| 419 | + | data io.Reader | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func (f *failingBody) Read(p []byte) (int, error) { | |
| 423 | + | n, err := f.data.Read(p) | |
| 424 | + | if err == io.EOF { | |
| 425 | + | return n, errors.New("connection reset by sandbox") | |
| 426 | + | } | |
| 427 | + | return n, err | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestBlockBodyErrorCancelsStream(t *testing.T) { | |
| 431 | + | r := newRig(t) | |
| 432 | + | j := r.job(1, "job", 1) | |
| 433 | + | u, _ := url.Parse(j.create(t, "cut", "")) | |
| 434 | + | id, _ := strconv.ParseInt(strings.TrimPrefix(u.Path, BlobPath+"upload/"), 10, 64) | |
| 435 | + | h := r.svc.Handler(j.Job) | |
| 436 | + | put := func(size int) *httptest.ResponseRecorder { | |
| 437 | + | req := httptest.NewRequest(http.MethodPut, u.String()+"&comp=block&blockid=AA==", &failingBody{bytes.NewReader(make([]byte, size))}) | |
| 438 | + | rec := httptest.NewRecorder() | |
| 439 | + | h.ServeHTTP(rec, req) | |
| 440 | + | return rec | |
| 441 | + | } | |
| 442 | + | // The body fails after 2.5 MiB: two messages went to ermite, then the | |
| 443 | + | // stream is cancelled. | |
| 444 | + | if rec := put(5 << 19); rec.Code == http.StatusCreated { | |
| 445 | + | t.Fatalf("a failed body got %d", rec.Code) | |
| 446 | + | } | |
| 447 | + | deadline := time.Now().Add(5 * time.Second) | |
| 448 | + | for r.fake.streams() < 1 { | |
| 449 | + | if time.Now().After(deadline) { | |
| 450 | + | t.Fatal("ermite never saw the stream end") | |
| 451 | + | } | |
| 452 | + | time.Sleep(5 * time.Millisecond) | |
| 453 | + | } | |
| 454 | + | if n := r.fake.pendingBlocks(id); n != 0 { | |
| 455 | + | t.Fatalf("ermite stored %d blocks of a broken upload", n) | |
| 456 | + | } | |
| 457 | + | // A body that fails before any byte never opens a stream. | |
| 458 | + | if rec := put(0); rec.Code == http.StatusCreated { | |
| 459 | + | t.Fatalf("a failed body got %d", rec.Code) | |
| 460 | + | } | |
| 461 | + | time.Sleep(50 * time.Millisecond) | |
| 462 | + | if n := r.fake.streams(); n != 1 { | |
| 463 | + | t.Fatalf("%d streams", n) | |
| 464 | + | } | |
| 465 | + | // And a good block is stored. | |
| 466 | + | res, _ := do(t, http.MethodPut, u.String()+"&comp=block&blockid=AA==", bytes.NewReader(make([]byte, 5<<19))) | |
| 467 | + | if res.StatusCode != http.StatusCreated || r.fake.pendingBlocks(id) != 1 { | |
| 468 | + | t.Fatalf("good block: %d, %d blocks", res.StatusCode, r.fake.pendingBlocks(id)) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func random(t *testing.T, n int) []byte { | |
| 473 | + | t.Helper() | |
| 474 | + | b := make([]byte, n) | |
| 475 | + | if _, err := rand.Read(b); err != nil { | |
| 476 | + | t.Fatal(err) | |
| 477 | + | } | |
| 478 | + | return b | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func TestDownload(t *testing.T) { | |
| 482 | + | r := newRig(t) | |
| 483 | + | up := r.job(1, "up", 1) | |
| 484 | + | down := r.job(1, "down", 1) | |
| 485 | + | big := random(t, 3<<20+12345) | |
| 486 | + | up.upload(t, "big", "", big, 8<<20) | |
| 487 | + | up.upload(t, "report é.html", "text/html", []byte("<h1>raw</h1>"), 8<<20) | |
| 488 | + | up.upload(t, "zero", "", nil, 8<<20) | |
| 489 | + | ||
| 490 | + | t.Run("zip", func(t *testing.T) { | |
| 491 | + | res, b := do(t, http.MethodGet, down.signedURL(t, "big"), nil) | |
| 492 | + | if res.StatusCode != http.StatusOK || !bytes.Equal(b, big) || res.Header.Get("Content-Type") != "application/zip" || | |
| 493 | + | res.Header.Get("Content-Length") != strconv.Itoa(len(big)) || res.Header.Get("Content-Disposition") != "" { | |
| 494 | + | t.Fatalf("%d %d bytes %v", res.StatusCode, len(b), res.Header) | |
| 495 | + | } | |
| 496 | + | }) | |
| 497 | + | t.Run("archive false", func(t *testing.T) { | |
| 498 | + | res, b := do(t, http.MethodGet, down.signedURL(t, "report é.html"), nil) | |
| 499 | + | if res.StatusCode != http.StatusOK || string(b) != "<h1>raw</h1>" || res.Header.Get("Content-Type") != "text/html" || | |
| 500 | + | res.Header.Get("Content-Disposition") != "attachment; filename*=UTF-8''report%20%C3%A9.html" { | |
| 501 | + | t.Fatalf("%d %q %v", res.StatusCode, b, res.Header) | |
| 502 | + | } | |
| 503 | + | }) | |
| 504 | + | t.Run("empty", func(t *testing.T) { | |
| 505 | + | res, b := do(t, http.MethodGet, down.signedURL(t, "zero"), nil) | |
| 506 | + | if res.StatusCode != http.StatusOK || len(b) != 0 || res.Header.Get("Content-Length") != "0" { | |
| 507 | + | t.Fatalf("%d %q %v", res.StatusCode, b, res.Header) | |
| 508 | + | } | |
| 509 | + | }) | |
| 510 | + | t.Run("not found", func(t *testing.T) { | |
| 511 | + | u := down.signedURL(t, "big") | |
| 512 | + | down.twirp(t, "DeleteArtifact", `{"name":"big"}`) | |
| 513 | + | res, b := do(t, http.MethodGet, u, nil) | |
| 514 | + | checkBlobError(t, "deleted", res, b, http.StatusNotFound, "BlobNotFound") | |
| 515 | + | up.upload(t, "big", "", big, 8<<20) | |
| 516 | + | }) | |
| 517 | + | t.Run("resume", func(t *testing.T) { | |
| 518 | + | var cuts atomic.Int32 | |
| 519 | + | r.fake.mu.Lock() | |
| 520 | + | r.fake.cut = func(open, sent int64) error { | |
| 521 | + | // Each of the first two streams breaks after a chunk. | |
| 522 | + | if sent >= 1<<20 && cuts.Load() < 2 { | |
| 523 | + | cuts.Add(1) | |
| 524 | + | return connect.NewError(connect.CodeUnavailable, errors.New("cut")) | |
| 525 | + | } | |
| 526 | + | return nil | |
| 527 | + | } | |
| 528 | + | r.fake.mu.Unlock() | |
| 529 | + | res, b := do(t, http.MethodGet, down.signedURL(t, "big"), nil) | |
| 530 | + | if res.StatusCode != http.StatusOK || !bytes.Equal(b, big) || cuts.Load() != 2 { | |
| 531 | + | t.Fatalf("%d %d bytes of %d, %d cuts", res.StatusCode, len(b), len(big), cuts.Load()) | |
| 532 | + | } | |
| 533 | + | }) | |
| 534 | + | t.Run("resume exhausted", func(t *testing.T) { | |
| 535 | + | r.fake.mu.Lock() | |
| 536 | + | r.fake.cut = func(open, sent int64) error { | |
| 537 | + | if open > 0 || sent >= 1<<20 { | |
| 538 | + | return connect.NewError(connect.CodeUnavailable, errors.New("cut")) | |
| 539 | + | } | |
| 540 | + | return nil | |
| 541 | + | } | |
| 542 | + | r.fake.mu.Unlock() | |
| 543 | + | defer func() { | |
| 544 | + | r.fake.mu.Lock() | |
| 545 | + | r.fake.cut = nil | |
| 546 | + | r.fake.mu.Unlock() | |
| 547 | + | }() | |
| 548 | + | res, err := http.Get(down.signedURL(t, "big")) | |
| 549 | + | if err != nil { | |
| 550 | + | t.Fatal(err) | |
| 551 | + | } | |
| 552 | + | defer func() { _ = res.Body.Close() }() | |
| 553 | + | b, err := io.ReadAll(res.Body) | |
| 554 | + | if res.StatusCode != http.StatusOK || err == nil || len(b) != 1<<20 { | |
| 555 | + | t.Fatalf("%d, %d bytes, %v: want a truncated body", res.StatusCode, len(b), err) | |
| 556 | + | } | |
| 557 | + | }) | |
| 558 | + | t.Run("other run", func(t *testing.T) { | |
| 559 | + | intruder := r.job(2, "intruder", 1) | |
| 560 | + | st, b := intruder.twirp(t, "GetSignedArtifactURL", `{"name":"big"}`) | |
| 561 | + | if st != http.StatusNotFound { | |
| 562 | + | t.Fatalf("%d %s", st, b) | |
| 563 | + | } | |
| 564 | + | if st, b := intruder.twirp(t, "ListArtifacts", `{}`); st != http.StatusOK || string(b) != `{"artifacts":[]}`+"\n" { | |
| 565 | + | t.Fatalf("%d %s", st, b) | |
| 566 | + | } | |
| 567 | + | // A URL of the run's artifact, signed for the intruder's channel (as if | |
| 568 | + | // it had guessed the id), is still refused by ermite. | |
| 569 | + | se := r.clk.now().Add(time.Hour).Unix() | |
| 570 | + | u := intruder.srv.URL + BlobPath + "download/2?se=" + strconv.FormatInt(se, 10) + "&sig=" + r.svc.blobSig(intruder.Job, opDownload, 2, se) | |
| 571 | + | res, b := do(t, http.MethodGet, u, nil) | |
| 572 | + | checkBlobError(t, "other run", res, b, http.StatusNotFound, "BlobNotFound") | |
| 573 | + | }) | |
| 574 | + | } | |
A internal/resultssvc/toolkit_test.go +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "bytes" | |
| 8 | + | "context" | |
| 9 | + | "crypto/rand" | |
| 10 | + | "net" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "regexp" | |
| 15 | + | "strconv" | |
| 16 | + | "strings" | |
| 17 | + | "testing" | |
| 18 | + | "time" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite-runner/internal/cachesvc" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // The checkouts of actions/upload-artifact and actions/download-artifact | |
| 24 | + | // (v4 or later: action.yml and the built dist/) that TestArtifactActions runs | |
| 25 | + | // against the service: the protocol as @actions/artifact speaks it, not as | |
| 26 | + | // the other tests of this package spell it. The test needs node 20 or later | |
| 27 | + | // (ERMITE_TEST_NODE, else node from PATH) and no network; without the | |
| 28 | + | // checkouts it is skipped. | |
| 29 | + | const ( | |
| 30 | + | envUploadDist = "ERMITE_TEST_UPLOAD_ARTIFACT_DIST" | |
| 31 | + | envDownloadDist = "ERMITE_TEST_DOWNLOAD_ARTIFACT_DIST" | |
| 32 | + | envNode = "ERMITE_TEST_NODE" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // chJob is a job of TestArtifactActions: its Job, the URL of its channel and | |
| 36 | + | // its runtime token. | |
| 37 | + | type chJob struct { | |
| 38 | + | Job | |
| 39 | + | url string | |
| 40 | + | token string | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // manifest is what the test needs of an action.yml: the script and the input | |
| 44 | + | // defaults. | |
| 45 | + | type manifest struct { | |
| 46 | + | main string | |
| 47 | + | defaults map[string]string | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // readManifest reads action.yml with a few lines of parsing: the inputs' | |
| 51 | + | // "default:" keys and runs.main, which is all these manifests hold that the | |
| 52 | + | // test uses. | |
| 53 | + | func readManifest(t *testing.T, dir string) manifest { | |
| 54 | + | t.Helper() | |
| 55 | + | f, err := os.Open(filepath.Join(dir, "action.yml")) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | defer func() { _ = f.Close() }() | |
| 60 | + | m := manifest{defaults: map[string]string{}} | |
| 61 | + | unquote := func(v string) string { | |
| 62 | + | v = strings.TrimSpace(v) | |
| 63 | + | if len(v) >= 2 && (v[0] == '\'' || v[0] == '"') && v[len(v)-1] == v[0] { | |
| 64 | + | v = v[1 : len(v)-1] | |
| 65 | + | } | |
| 66 | + | return v | |
| 67 | + | } | |
| 68 | + | section, input := "", "" | |
| 69 | + | sc := bufio.NewScanner(f) | |
| 70 | + | for sc.Scan() { | |
| 71 | + | line := sc.Text() | |
| 72 | + | trimmed := strings.TrimSpace(line) | |
| 73 | + | indent := len(line) - len(strings.TrimLeft(line, " ")) | |
| 74 | + | switch { | |
| 75 | + | case trimmed == "" || strings.HasPrefix(trimmed, "#"): | |
| 76 | + | case indent == 0: | |
| 77 | + | section, _, _ = strings.Cut(trimmed, ":") | |
| 78 | + | case section == "inputs" && indent == 2 && strings.HasSuffix(trimmed, ":"): | |
| 79 | + | input = strings.TrimSuffix(trimmed, ":") | |
| 80 | + | m.defaults[input] = "" | |
| 81 | + | case section == "inputs" && indent == 4 && strings.HasPrefix(trimmed, "default:"): | |
| 82 | + | m.defaults[input] = unquote(strings.TrimPrefix(trimmed, "default:")) | |
| 83 | + | case section == "runs" && indent == 2 && strings.HasPrefix(trimmed, "main:"): | |
| 84 | + | m.main = unquote(strings.TrimPrefix(trimmed, "main:")) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if m.main == "" { | |
| 88 | + | t.Fatalf("%s/action.yml: no runs.main", dir) | |
| 89 | + | } | |
| 90 | + | return m | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // TestArtifactActions runs the real upload-artifact and download-artifact | |
| 94 | + | // against the service in front of the fake ermite: round trips of one file, | |
| 95 | + | // of several blocks and of an empty file, merge-multiple, overwrite, a | |
| 96 | + | // download by another job of the run, a token refused on another job's | |
| 97 | + | // channel, and a job of another run that sees nothing. Tokens and channels are | |
| 98 | + | // cachesvc's, as in production. | |
| 99 | + | func TestArtifactActions(t *testing.T) { | |
| 100 | + | upDir, downDir := os.Getenv(envUploadDist), os.Getenv(envDownloadDist) | |
| 101 | + | if upDir == "" || downDir == "" { | |
| 102 | + | t.Skip(envUploadDist + " and " + envDownloadDist + " are not set (checkouts of actions/upload-artifact and actions/download-artifact)") | |
| 103 | + | } | |
| 104 | + | node := os.Getenv(envNode) | |
| 105 | + | if node == "" { | |
| 106 | + | var err error | |
| 107 | + | if node, err = exec.LookPath("node"); err != nil { | |
| 108 | + | t.Skip("node not found") | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | out, err := exec.Command(node, "--version").Output() | |
| 112 | + | major, _ := strconv.Atoi(strings.TrimPrefix(strings.SplitN(strings.TrimSpace(string(out)), ".", 2)[0], "v")) | |
| 113 | + | if err != nil || major < 20 { | |
| 114 | + | t.Skipf("node %q is not 20 or later", strings.TrimSpace(string(out))) | |
| 115 | + | } | |
| 116 | + | upload, download := readManifest(t, upDir), readManifest(t, downDir) | |
| 117 | + | ||
| 118 | + | // The production path: cachesvc mints and checks the runtime tokens and | |
| 119 | + | // serves each job's channel, with the results service of that job beside | |
| 120 | + | // the cache. | |
| 121 | + | cache, err := cachesvc.New(cachesvc.Config{Dir: t.TempDir()}) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | t.Cleanup(func() { _ = cache.CloseAll() }) | |
| 126 | + | f := newFakeErmite() | |
| 127 | + | ermiteURL := f.serve(t) | |
| 128 | + | results := New(Config{Auth: cache}) | |
| 129 | + | root := t.TempDir() | |
| 130 | + | const run = 1001 | |
| 131 | + | nJobs := 0 | |
| 132 | + | // newJob registers a job with ermite and serves its channel on a TCP | |
| 133 | + | // listener of its own. | |
| 134 | + | newJob := func(run int64, id string) *chJob { | |
| 135 | + | t.Helper() | |
| 136 | + | nJobs++ | |
| 137 | + | et := "ermitejob_" + strconv.Itoa(nJobs) | |
| 138 | + | f.addJob(et, run, id, 1) | |
| 139 | + | j := Job{ID: id, RunID: run, Attempt: 1, Client: client(ermiteURL, et)} | |
| 140 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if err := cache.Serve("ch-"+id, ln, id, cachesvc.WithResults(results.Handler(j))); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | t.Cleanup(func() { cache.Stop("ch-" + id) }) | |
| 148 | + | return &chJob{Job: j, url: "http://" + ln.Addr().String(), token: cache.Token(id, cachesvc.Scope{}, time.Hour, Scope(run, id, 1))} | |
| 149 | + | } | |
| 150 | + | build, test := newJob(run, "build"), newJob(run, "test") | |
| 151 | + | intruder := newJob(run+1, "intruder") | |
| 152 | + | ||
| 153 | + | ws := func(j *chJob) string { return filepath.Join(root, "ws-"+j.ID) } | |
| 154 | + | write := func(j *chJob, rel string, data []byte) { | |
| 155 | + | t.Helper() | |
| 156 | + | p := filepath.Join(ws(j), rel) | |
| 157 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | if err := os.WriteFile(p, data, 0o644); err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | read := func(j *chJob, rel string) []byte { | |
| 165 | + | t.Helper() | |
| 166 | + | b, err := os.ReadFile(filepath.Join(ws(j), rel)) | |
| 167 | + | if err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | return b | |
| 171 | + | } | |
| 172 | + | outputRE := regexp.MustCompile(`(?ms)^(\S+)<<(\S+)\n(.*?)\n\S+$`) | |
| 173 | + | ||
| 174 | + | // step runs the main script of an action as the runner would run it for | |
| 175 | + | // job j, and returns whether it succeeded, its outputs and its log. | |
| 176 | + | step := func(dir string, m manifest, j *chJob, inputs map[string]string) (bool, map[string]string, string) { | |
| 177 | + | t.Helper() | |
| 178 | + | tmp := filepath.Join(root, "tmp-"+j.ID) | |
| 179 | + | for _, d := range []string{ws(j), tmp} { | |
| 180 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | files := map[string]string{"output": "", "env": "", "summary": "", "event.json": "{}"} | |
| 185 | + | for name, content := range files { | |
| 186 | + | if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0o644); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | runID := strconv.FormatInt(j.RunID, 10) | |
| 191 | + | env := []string{ | |
| 192 | + | "PATH=" + os.Getenv("PATH"), "HOME=" + tmp, | |
| 193 | + | "GITHUB_SERVER_URL=https://ermite.ghe.localhost", "GITHUB_REPOSITORY=bnema/app", | |
| 194 | + | "GITHUB_RUN_ID=" + runID, "GITHUB_RUN_ATTEMPT=" + strconv.Itoa(int(j.Attempt)), "GITHUB_JOB=" + j.ID, | |
| 195 | + | "GITHUB_WORKSPACE=" + ws(j), "RUNNER_TEMP=" + tmp, "RUNNER_OS=Linux", | |
| 196 | + | "GITHUB_OUTPUT=" + filepath.Join(tmp, "output"), "GITHUB_ENV=" + filepath.Join(tmp, "env"), | |
| 197 | + | "GITHUB_STEP_SUMMARY=" + filepath.Join(tmp, "summary"), "GITHUB_EVENT_PATH=" + filepath.Join(tmp, "event.json"), | |
| 198 | + | "ACTIONS_RESULTS_URL=" + j.url + "/", "ACTIONS_RUNTIME_TOKEN=" + j.token, | |
| 199 | + | } | |
| 200 | + | for name, def := range m.defaults { | |
| 201 | + | v, ok := inputs[name] | |
| 202 | + | if !ok { | |
| 203 | + | v = strings.NewReplacer("${{ github.repository }}", "bnema/app", "${{ github.run_id }}", runID, "${{ github.token }}", "").Replace(def) | |
| 204 | + | } | |
| 205 | + | env = append(env, "INPUT_"+strings.ToUpper(strings.ReplaceAll(name, " ", "_"))+"="+v) | |
| 206 | + | } | |
| 207 | + | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) | |
| 208 | + | defer cancel() | |
| 209 | + | // node itself, not a shell: a shell would drop the INPUT_ variables | |
| 210 | + | // whose names have a '-'. | |
| 211 | + | cmd := exec.CommandContext(ctx, node, filepath.Join(dir, m.main)) | |
| 212 | + | cmd.Dir, cmd.Env = ws(j), env | |
| 213 | + | var buf bytes.Buffer | |
| 214 | + | cmd.Stdout, cmd.Stderr = &buf, &buf | |
| 215 | + | cmd.WaitDelay = 5 * time.Second | |
| 216 | + | err := cmd.Run() | |
| 217 | + | raw, _ := os.ReadFile(filepath.Join(tmp, "output")) | |
| 218 | + | outs := map[string]string{} | |
| 219 | + | for _, m := range outputRE.FindAllStringSubmatch(string(raw), -1) { | |
| 220 | + | outs[m[1]] = m[3] | |
| 221 | + | } | |
| 222 | + | return err == nil, outs, buf.String() | |
| 223 | + | } | |
| 224 | + | up := func(j *chJob, inputs map[string]string) (bool, map[string]string, string) { | |
| 225 | + | t.Helper() | |
| 226 | + | return step(upDir, upload, j, inputs) | |
| 227 | + | } | |
| 228 | + | down := func(j *chJob, inputs map[string]string) (bool, string) { | |
| 229 | + | t.Helper() | |
| 230 | + | ok, _, log := step(downDir, download, j, inputs) | |
| 231 | + | return ok, log | |
| 232 | + | } | |
| 233 | + | random := func(n int) []byte { | |
| 234 | + | b := make([]byte, n) | |
| 235 | + | _, _ = rand.Read(b) | |
| 236 | + | return b | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | t.Run("single file", func(t *testing.T) { | |
| 240 | + | write(build, "one.txt", []byte("hello artifacts\n")) | |
| 241 | + | ok, outs, log := up(build, map[string]string{"name": "single", "path": "one.txt"}) | |
| 242 | + | if !ok || outs["artifact-id"] == "" { | |
| 243 | + | t.Fatalf("upload (%v):\n%s", outs, log) | |
| 244 | + | } | |
| 245 | + | // Another job of the run downloads it. | |
| 246 | + | if ok, log := down(test, map[string]string{"name": "single", "path": "got-single"}); !ok { | |
| 247 | + | t.Fatalf("download:\n%s", log) | |
| 248 | + | } | |
| 249 | + | if got := read(test, "got-single/one.txt"); string(got) != "hello artifacts\n" { | |
| 250 | + | t.Fatalf("downloaded %q", got) | |
| 251 | + | } | |
| 252 | + | }) | |
| 253 | + | ||
| 254 | + | t.Run("several blocks", func(t *testing.T) { | |
| 255 | + | big := random(20<<20 + 123) | |
| 256 | + | write(build, "big/rand.bin", big) | |
| 257 | + | if ok, _, log := up(build, map[string]string{"name": "big", "path": "big/rand.bin", "compression-level": "0"}); !ok { | |
| 258 | + | t.Fatalf("upload:\n%s", log) | |
| 259 | + | } | |
| 260 | + | if ok, log := down(test, map[string]string{"name": "big", "path": "got-big"}); !ok { | |
| 261 | + | t.Fatalf("download:\n%s", log) | |
| 262 | + | } | |
| 263 | + | if !bytes.Equal(read(test, "got-big/rand.bin"), big) { | |
| 264 | + | t.Fatal("the downloaded file differs") | |
| 265 | + | } | |
| 266 | + | }) | |
| 267 | + | ||
| 268 | + | t.Run("empty file", func(t *testing.T) { | |
| 269 | + | write(build, "empty/zero.txt", nil) | |
| 270 | + | if ok, _, log := up(build, map[string]string{"name": "empty", "path": "empty/zero.txt"}); !ok { | |
| 271 | + | t.Fatalf("upload:\n%s", log) | |
| 272 | + | } | |
| 273 | + | if ok, log := down(test, map[string]string{"name": "empty", "path": "got-empty"}); !ok { | |
| 274 | + | t.Fatalf("download:\n%s", log) | |
| 275 | + | } | |
| 276 | + | if got := read(test, "got-empty/zero.txt"); len(got) != 0 { | |
| 277 | + | t.Fatalf("downloaded %q", got) | |
| 278 | + | } | |
| 279 | + | }) | |
| 280 | + | ||
| 281 | + | t.Run("merge multiple", func(t *testing.T) { | |
| 282 | + | for i := range 3 { | |
| 283 | + | n := strconv.Itoa(i) | |
| 284 | + | write(build, "part"+n+"/p"+n+".txt", []byte("part "+n+"\n")) | |
| 285 | + | if ok, _, log := up(build, map[string]string{"name": "part-" + n, "path": "part" + n + "/"}); !ok { | |
| 286 | + | t.Fatalf("upload %d:\n%s", i, log) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | if ok, log := down(test, map[string]string{"pattern": "part-*", "merge-multiple": "true", "path": "merged"}); !ok { | |
| 290 | + | t.Fatalf("download:\n%s", log) | |
| 291 | + | } | |
| 292 | + | for i := range 3 { | |
| 293 | + | n := strconv.Itoa(i) | |
| 294 | + | if got := read(test, "merged/p"+n+".txt"); string(got) != "part "+n+"\n" { | |
| 295 | + | t.Fatalf("p%d.txt: %q", i, got) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | }) | |
| 299 | + | ||
| 300 | + | t.Run("overwrite", func(t *testing.T) { | |
| 301 | + | write(build, "one.txt", []byte("hello artifacts v2\n")) | |
| 302 | + | ok, _, log := up(build, map[string]string{"name": "single", "path": "one.txt"}) | |
| 303 | + | if ok || !strings.Contains(log, "409") { | |
| 304 | + | t.Fatalf("a second upload of the name without overwrite must fail with a 409:\n%s", log) | |
| 305 | + | } | |
| 306 | + | if ok, _, log := up(build, map[string]string{"name": "single", "path": "one.txt", "overwrite": "true"}); !ok { | |
| 307 | + | t.Fatalf("overwrite:\n%s", log) | |
| 308 | + | } | |
| 309 | + | if ok, log := down(test, map[string]string{"name": "single", "path": "got-single-2"}); !ok { | |
| 310 | + | t.Fatalf("download:\n%s", log) | |
| 311 | + | } | |
| 312 | + | if got := read(test, "got-single-2/one.txt"); string(got) != "hello artifacts v2\n" { | |
| 313 | + | t.Fatalf("downloaded %q", got) | |
| 314 | + | } | |
| 315 | + | }) | |
| 316 | + | ||
| 317 | + | t.Run("archive false", func(t *testing.T) { | |
| 318 | + | if _, ok := upload.defaults["archive"]; !ok { | |
| 319 | + | t.Skip("upload-artifact before v7 has no archive input") | |
| 320 | + | } | |
| 321 | + | write(build, "raw/report.html", []byte("<h1>raw</h1>\n")) | |
| 322 | + | if ok, _, log := up(build, map[string]string{"path": "raw/report.html", "archive": "false"}); !ok { | |
| 323 | + | t.Fatalf("upload:\n%s", log) | |
| 324 | + | } | |
| 325 | + | if ok, log := down(test, map[string]string{"name": "report.html", "path": "got-raw"}); !ok { | |
| 326 | + | t.Fatalf("download:\n%s", log) | |
| 327 | + | } | |
| 328 | + | if got := read(test, "got-raw/report.html"); string(got) != "<h1>raw</h1>\n" { | |
| 329 | + | t.Fatalf("downloaded %q", got) | |
| 330 | + | } | |
| 331 | + | }) | |
| 332 | + | ||
| 333 | + | t.Run("token of another channel", func(t *testing.T) { | |
| 334 | + | // test's token on build's channel: the channel accepts only build's. | |
| 335 | + | stray := &chJob{Job: test.Job, url: build.url, token: test.token} | |
| 336 | + | ok, log := down(stray, map[string]string{"name": "single", "path": "stray"}) | |
| 337 | + | if ok || !strings.Contains(log, "401") { | |
| 338 | + | t.Fatalf("a token used on another job's channel must be refused with a 401:\n%s", log) | |
| 339 | + | } | |
| 340 | + | }) | |
| 341 | + | ||
| 342 | + | t.Run("another run", func(t *testing.T) { | |
| 343 | + | if ok, log := down(intruder, map[string]string{"name": "single", "path": "x"}); ok { | |
| 344 | + | t.Fatalf("a job of another run downloaded the artifact:\n%s", log) | |
| 345 | + | } | |
| 346 | + | ok, log := down(intruder, map[string]string{"path": "all"}) | |
| 347 | + | if !ok { | |
| 348 | + | t.Fatalf("download all:\n%s", log) | |
| 349 | + | } | |
| 350 | + | if entries, _ := os.ReadDir(filepath.Join(ws(intruder), "all")); len(entries) != 0 { | |
| 351 | + | t.Fatalf("a job of another run got %d artifacts", len(entries)) | |
| 352 | + | } | |
| 353 | + | }) | |
| 354 | + | } | |
A internal/resultssvc/twirp.go +317 -0
| @@ -0,0 +1,317 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package resultssvc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "net/url" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "connectrpc.com/connect" | |
| 19 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // maxTwirpBody bounds a Twirp request body: the largest the toolkit sends is a | |
| 23 | + | // few hundred bytes. | |
| 24 | + | const maxTwirpBody = 1 << 20 | |
| 25 | + | ||
| 26 | + | // msgExists is the message of an already_exists reply when ermite gives none: | |
| 27 | + | // the toolkit's own wording for it. | |
| 28 | + | const msgExists = "an artifact with this name already exists on the workflow run" | |
| 29 | + | ||
| 30 | + | // twirpError is an error reply: a status and the {"code","msg"} body of Twirp. | |
| 31 | + | // The toolkit retries 5xx and 429 (ArtifactHttpClient.isRetryableHttpStatusCode, | |
| 32 | + | // upload-artifact v4.6.2 dist/upload/index.js:2827-2837) and fails at once on | |
| 33 | + | // anything else, with msg in the job log (:2787-2793). The body must be JSON | |
| 34 | + | // whatever the status: a body that does not parse is retried as a network | |
| 35 | + | // error (:2795-2806). | |
| 36 | + | type twirpError struct { | |
| 37 | + | status int | |
| 38 | + | code string | |
| 39 | + | msg string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (e *twirpError) Error() string { return e.code + ": " + e.msg } | |
| 43 | + | ||
| 44 | + | // twirpRequest is the union of the request messages of the methods. The | |
| 45 | + | // toolkit's generated client sends proto field names (useProtoFieldName), | |
| 46 | + | // wrapper types (StringValue, Int64Value) as bare values and int64 as strings; | |
| 47 | + | // the camelCase names that protojson also accepts are read too. Unknown fields | |
| 48 | + | // are ignored. The run and job backend ids are not read: everything comes from | |
| 49 | + | // the Job of the channel. | |
| 50 | + | type twirpRequest struct { | |
| 51 | + | Name string `json:"name"` | |
| 52 | + | MimeType string `json:"mime_type"` | |
| 53 | + | MimeTypeCC string `json:"mimeType"` | |
| 54 | + | ExpiresAt string `json:"expires_at"` | |
| 55 | + | ExpiresAtCC string `json:"expiresAt"` | |
| 56 | + | Size json.RawMessage `json:"size"` | |
| 57 | + | Hash string `json:"hash"` | |
| 58 | + | NameFilter string `json:"name_filter"` | |
| 59 | + | NameFilterCC string `json:"nameFilter"` | |
| 60 | + | IDFilter json.RawMessage `json:"id_filter"` | |
| 61 | + | IDFilterCC json.RawMessage `json:"idFilter"` | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func firstOf[T string | json.RawMessage](a, b T) T { | |
| 65 | + | if len(a) > 0 { | |
| 66 | + | return a | |
| 67 | + | } | |
| 68 | + | return b | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // int64Value reads an int64 sent as a JSON string (protojson) or a number. An | |
| 72 | + | // absent or null value is 0. | |
| 73 | + | func int64Value(raw json.RawMessage, field string) (int64, *twirpError) { | |
| 74 | + | v := strings.TrimSpace(string(raw)) | |
| 75 | + | if v == "" || v == "null" { | |
| 76 | + | return 0, nil | |
| 77 | + | } | |
| 78 | + | if s, err := strconv.Unquote(v); err == nil { | |
| 79 | + | v = s | |
| 80 | + | } | |
| 81 | + | n, err := strconv.ParseInt(v, 10, 64) | |
| 82 | + | if err != nil { | |
| 83 | + | return 0, &twirpError{http.StatusBadRequest, "invalid_argument", field + " is not an int64"} | |
| 84 | + | } | |
| 85 | + | return n, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // artifactJSON is an item of ListArtifactsResponse, in the camelCase of the | |
| 89 | + | // toolkit's fromJson with int64 as strings. | |
| 90 | + | type artifactJSON struct { | |
| 91 | + | WorkflowRunBackendID string `json:"workflowRunBackendId"` | |
| 92 | + | WorkflowJobRunBackendID string `json:"workflowJobRunBackendId"` | |
| 93 | + | DatabaseID string `json:"databaseId"` | |
| 94 | + | Name string `json:"name"` | |
| 95 | + | Size string `json:"size"` | |
| 96 | + | CreatedAt string `json:"createdAt,omitempty"` | |
| 97 | + | Digest string `json:"digest,omitempty"` | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // twirp serves one Twirp method. The routes are those of the toolkit's | |
| 101 | + | // ArtifactServiceClientJSON: CreateArtifact, FinalizeArtifact, ListArtifacts, | |
| 102 | + | // GetSignedArtifactURL and DeleteArtifact, all POST with a JSON body. | |
| 103 | + | func (s *Service) twirp(job Job, w http.ResponseWriter, r *http.Request, method string) { | |
| 104 | + | if r.Method != http.MethodPost { | |
| 105 | + | writeTwirpError(w, &twirpError{http.StatusMethodNotAllowed, "bad_route", "unsupported method " + r.Method}) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | var call func(Job, *http.Request, *twirpRequest, time.Time) (any, error) | |
| 109 | + | switch method { | |
| 110 | + | case "CreateArtifact": | |
| 111 | + | call = s.createArtifact | |
| 112 | + | case "FinalizeArtifact": | |
| 113 | + | call = s.finalizeArtifact | |
| 114 | + | case "ListArtifacts": | |
| 115 | + | call = s.listArtifacts | |
| 116 | + | case "GetSignedArtifactURL": | |
| 117 | + | call = s.signedArtifactURL | |
| 118 | + | case "DeleteArtifact": | |
| 119 | + | call = s.deleteArtifact | |
| 120 | + | default: | |
| 121 | + | writeTwirpError(w, &twirpError{http.StatusNotFound, "bad_route", "no such method: " + method}) | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | expiry, ok := s.authorize(job, r) | |
| 125 | + | if !ok { | |
| 126 | + | writeTwirpError(w, &twirpError{http.StatusUnauthorized, "unauthenticated", "invalid runtime token"}) | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxTwirpBody)) | |
| 130 | + | if err != nil { | |
| 131 | + | writeTwirpError(w, &twirpError{http.StatusBadRequest, "malformed", "the request body could not be read"}) | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | var req twirpRequest | |
| 135 | + | if err := json.Unmarshal(body, &req); err != nil { | |
| 136 | + | writeTwirpError(w, &twirpError{http.StatusBadRequest, "malformed", "the request body is not a JSON " + method + " request"}) | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | s.log.Debug().Str("method", method).Str("job", job.ID).Str("name", req.Name).Msg("results twirp") | |
| 140 | + | out, err := call(job, r, &req, expiry) | |
| 141 | + | if err != nil { | |
| 142 | + | var te *twirpError | |
| 143 | + | if !errors.As(err, &te) { | |
| 144 | + | te = twirpFromConnect(err) | |
| 145 | + | s.log.Warn().Str("method", method).Str("job", job.ID).Str("code", connect.CodeOf(err).String()).Str("error", te.msg).Msg("results: ermite refused") | |
| 146 | + | } | |
| 147 | + | writeTwirpError(w, te) | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | w.Header().Set("Content-Type", "application/json") | |
| 151 | + | w.WriteHeader(http.StatusOK) | |
| 152 | + | _ = json.NewEncoder(w).Encode(out) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // authorize checks the bearer token: a runtime token of this channel's job | |
| 156 | + | // whose scopes hold the job's Actions.Results scope. It returns the token's | |
| 157 | + | // expiry, which is the expiry of the blob URLs. Every refusal looks the same. | |
| 158 | + | func (s *Service) authorize(job Job, r *http.Request) (time.Time, bool) { | |
| 159 | + | const p = "Bearer " | |
| 160 | + | a := r.Header.Get("Authorization") | |
| 161 | + | if len(a) <= len(p) || !strings.EqualFold(a[:len(p)], p) || s.auth == nil { | |
| 162 | + | return time.Time{}, false | |
| 163 | + | } | |
| 164 | + | jobID, scopes, expiry, err := s.auth.RuntimeToken(strings.TrimSpace(a[len(p):])) | |
| 165 | + | if err != nil || jobID != job.ID || !s.now().Before(expiry) { | |
| 166 | + | return time.Time{}, false | |
| 167 | + | } | |
| 168 | + | want := Scope(job.RunID, job.ID, job.Attempt) | |
| 169 | + | for _, sc := range strings.Fields(scopes) { | |
| 170 | + | if sc == want { | |
| 171 | + | return expiry, true | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return time.Time{}, false | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (s *Service) createArtifact(job Job, r *http.Request, req *twirpRequest, expiry time.Time) (any, error) { | |
| 178 | + | in := &ermite.CreateJobArtifactRequest{JobId: job.ID, Name: req.Name, MediaType: firstOf(req.MimeType, req.MimeTypeCC)} | |
| 179 | + | if v := firstOf(req.ExpiresAt, req.ExpiresAtCC); v != "" { | |
| 180 | + | t, err := time.Parse(time.RFC3339Nano, v) | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, &twirpError{http.StatusBadRequest, "invalid_argument", "expires_at is not an RFC 3339 time"} | |
| 183 | + | } | |
| 184 | + | in.ExpiresAt = timestamppb.New(t) | |
| 185 | + | } | |
| 186 | + | res, err := job.Client.CreateJobArtifact(r.Context(), connect.NewRequest(in)) | |
| 187 | + | if err != nil { | |
| 188 | + | return nil, err | |
| 189 | + | } | |
| 190 | + | id := res.Msg.GetArtifactId() | |
| 191 | + | return map[string]any{"ok": true, "signedUploadUrl": s.blobURL(job, r, opUpload, id, expiry)}, nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (s *Service) finalizeArtifact(job Job, r *http.Request, req *twirpRequest, _ time.Time) (any, error) { | |
| 195 | + | size, te := int64Value(req.Size, "size") | |
| 196 | + | if te != nil { | |
| 197 | + | return nil, te | |
| 198 | + | } | |
| 199 | + | res, err := job.Client.FinalizeJobArtifact(r.Context(), connect.NewRequest(&ermite.FinalizeJobArtifactRequest{ | |
| 200 | + | JobId: job.ID, Name: req.Name, Size: size, Digest: req.Hash, | |
| 201 | + | })) | |
| 202 | + | if err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 205 | + | return map[string]any{"ok": true, "artifactId": strconv.FormatInt(res.Msg.GetArtifact().GetId(), 10)}, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (s *Service) listArtifacts(job Job, r *http.Request, req *twirpRequest, _ time.Time) (any, error) { | |
| 209 | + | id, te := int64Value(firstOf(req.IDFilter, req.IDFilterCC), "id_filter") | |
| 210 | + | if te != nil { | |
| 211 | + | return nil, te | |
| 212 | + | } | |
| 213 | + | res, err := job.Client.ListJobArtifacts(r.Context(), connect.NewRequest(&ermite.ListJobArtifactsRequest{ | |
| 214 | + | JobId: job.ID, Name: firstOf(req.NameFilter, req.NameFilterCC), ArtifactId: id, | |
| 215 | + | })) | |
| 216 | + | if err != nil { | |
| 217 | + | return nil, err | |
| 218 | + | } | |
| 219 | + | items := make([]artifactJSON, 0, len(res.Msg.GetArtifacts())) | |
| 220 | + | for _, a := range res.Msg.GetArtifacts() { | |
| 221 | + | it := artifactJSON{ | |
| 222 | + | WorkflowRunBackendID: strconv.FormatInt(job.RunID, 10), | |
| 223 | + | // The job of the artifact, not the caller: download-artifact passes | |
| 224 | + | // it back in GetSignedArtifactURL, which ignores it. | |
| 225 | + | WorkflowJobRunBackendID: JobBackendID(a.GetJobId(), a.GetAttempt()), | |
| 226 | + | DatabaseID: strconv.FormatInt(a.GetId(), 10), | |
| 227 | + | Name: a.GetName(), | |
| 228 | + | Size: strconv.FormatInt(a.GetSize(), 10), | |
| 229 | + | Digest: a.GetDigest(), | |
| 230 | + | } | |
| 231 | + | if a.GetCreatedAt() != nil { | |
| 232 | + | it.CreatedAt = a.GetCreatedAt().AsTime().UTC().Format(time.RFC3339Nano) | |
| 233 | + | } | |
| 234 | + | items = append(items, it) | |
| 235 | + | } | |
| 236 | + | return map[string]any{"artifacts": items}, nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // signedArtifactURL answers GetSignedArtifactURL: the download URL of the | |
| 240 | + | // artifact of that name of the run. The body's job backend id is the | |
| 241 | + | // uploader's (download-artifact copies it from the list), so it is not read. | |
| 242 | + | func (s *Service) signedArtifactURL(job Job, r *http.Request, req *twirpRequest, expiry time.Time) (any, error) { | |
| 243 | + | if req.Name == "" { | |
| 244 | + | return nil, &twirpError{http.StatusBadRequest, "invalid_argument", "name is required"} | |
| 245 | + | } | |
| 246 | + | res, err := job.Client.ListJobArtifacts(r.Context(), connect.NewRequest(&ermite.ListJobArtifactsRequest{JobId: job.ID, Name: req.Name})) | |
| 247 | + | if err != nil { | |
| 248 | + | return nil, err | |
| 249 | + | } | |
| 250 | + | var id int64 | |
| 251 | + | for _, a := range res.Msg.GetArtifacts() { | |
| 252 | + | id = max(id, a.GetId()) | |
| 253 | + | } | |
| 254 | + | if id == 0 { | |
| 255 | + | return nil, &twirpError{http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", req.Name)} | |
| 256 | + | } | |
| 257 | + | return map[string]any{"signedUrl": s.blobURL(job, r, opDownload, id, expiry)}, nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (s *Service) deleteArtifact(job Job, r *http.Request, req *twirpRequest, _ time.Time) (any, error) { | |
| 261 | + | res, err := job.Client.DeleteJobArtifact(r.Context(), connect.NewRequest(&ermite.DeleteJobArtifactRequest{JobId: job.ID, Name: req.Name})) | |
| 262 | + | if err != nil { | |
| 263 | + | return nil, err | |
| 264 | + | } | |
| 265 | + | return map[string]any{"ok": true, "artifactId": strconv.FormatInt(res.Msg.GetArtifactId(), 10)}, nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // twirpFromConnect maps an error of ermite to a Twirp error. ermite's message | |
| 269 | + | // is kept: it reaches the job log, and it never holds the job token. | |
| 270 | + | // resource_exhausted is a 412 and not Twirp's 429: the toolkit would retry a | |
| 271 | + | // 429 five times, and a full quota is not transient. | |
| 272 | + | func twirpFromConnect(err error) *twirpError { | |
| 273 | + | msg := err.Error() | |
| 274 | + | var ce *connect.Error | |
| 275 | + | if errors.As(err, &ce) { | |
| 276 | + | msg = ce.Message() | |
| 277 | + | } | |
| 278 | + | te := &twirpError{msg: msg} | |
| 279 | + | switch connect.CodeOf(err) { | |
| 280 | + | case connect.CodeInvalidArgument: | |
| 281 | + | te.status, te.code = http.StatusBadRequest, "invalid_argument" | |
| 282 | + | case connect.CodeNotFound: | |
| 283 | + | te.status, te.code = http.StatusNotFound, "not_found" | |
| 284 | + | case connect.CodeAlreadyExists: | |
| 285 | + | te.status, te.code = http.StatusConflict, "already_exists" | |
| 286 | + | if te.msg == "" { | |
| 287 | + | te.msg = msgExists | |
| 288 | + | } | |
| 289 | + | case connect.CodeFailedPrecondition, connect.CodeResourceExhausted: | |
| 290 | + | te.status, te.code = http.StatusPreconditionFailed, "failed_precondition" | |
| 291 | + | case connect.CodeUnauthenticated, connect.CodePermissionDenied: | |
| 292 | + | te.status, te.code = http.StatusUnauthorized, "unauthenticated" | |
| 293 | + | default: | |
| 294 | + | te.status, te.code = http.StatusServiceUnavailable, "unavailable" | |
| 295 | + | } | |
| 296 | + | if te.msg == "" { | |
| 297 | + | te.msg = te.code | |
| 298 | + | } | |
| 299 | + | return te | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func writeTwirpError(w http.ResponseWriter, e *twirpError) { | |
| 303 | + | var b bytes.Buffer | |
| 304 | + | _ = json.NewEncoder(&b).Encode(map[string]string{"code": e.code, "msg": e.msg}) | |
| 305 | + | w.Header().Set("Content-Type", "application/json") | |
| 306 | + | w.Header().Set("Content-Length", strconv.Itoa(b.Len())) | |
| 307 | + | w.WriteHeader(e.status) | |
| 308 | + | _, _ = w.Write(b.Bytes()) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // blobURL is a signed URL of the blob of an artifact, on the host the sandbox | |
| 312 | + | // used to reach the service. | |
| 313 | + | func (s *Service) blobURL(job Job, r *http.Request, op string, id int64, expiry time.Time) string { | |
| 314 | + | se := expiry.Unix() | |
| 315 | + | q := url.Values{"se": {strconv.FormatInt(se, 10)}, "sig": {s.blobSig(job, op, id, se)}} | |
| 316 | + | return "http://" + r.Host + BlobPath + op + "/" + strconv.FormatInt(id, 10) + "?" + q.Encode() | |
| 317 | + | } | |
M internal/sandbox/cache.go +9 -4
| @@ -14,13 +14,14 @@ import ( | |||
| 14 | 14 | // loopback: through the sandbox's own bind mount (container) or through | |
| 15 | 15 | // Firecracker's vsock proxy (VM, which connects to <uds>_<port> when the guest | |
| 16 | 16 | // connects to the host on that port). Nothing but that sandbox can reach the | |
| 17 | - | // path, and the service accepts on it only tokens made for job (see | |
| 18 | - | // cachesvc.Service.Serve). own, when not nil, sets the owner and mode of the | |
| 17 | + | // path, and the service accepts on it only tokens made for spec.JobID (see | |
| 18 | + | // cachesvc.Service.Serve). spec.Results, when set, is served on the same | |
| 19 | + | // channel beside the cache (cachesvc.WithResults). own, when not nil, sets the owner and mode of the | |
| 19 | 20 | // socket before it is served. mbit caps the channel's traffic, all its | |
| 20 | 21 | // connections and both directions together (0: unlimited); a VM passes its | |
| 21 | 22 | // NetMbit, as the cache stream does not cross eth0, which carries that limit. | |
| 22 | 23 | // With no cache service this does nothing. | |
| 23 | - | func (c Config) ServeCache(id, path, job string, mbit int, own func(path string) error) error { | |
| 24 | + | func (c Config) ServeCache(id, path string, spec JobSpec, mbit int, own func(path string) error) error { | |
| 24 | 25 | if c.Cache == nil { | |
| 25 | 26 | return nil | |
| 26 | 27 | } | |
| @@ -34,7 +35,11 @@ func (c Config) ServeCache(id, path, job string, mbit int, own func(path string) | |||
| 34 | 35 | return err | |
| 35 | 36 | } | |
| 36 | 37 | } | |
| 37 | - | return c.Cache.Serve(id, ln, job, cachesvc.WithBandwidth(mbit)) | |
| 38 | + | opts := []cachesvc.ServeOption{cachesvc.WithBandwidth(mbit)} | |
| 39 | + | if spec.Results != nil { | |
| 40 | + | opts = append(opts, cachesvc.WithResults(spec.Results)) | |
| 41 | + | } | |
| 42 | + | return c.Cache.Serve(id, ln, spec.JobID, opts...) | |
| 38 | 43 | } | |
| 39 | 44 | ||
| 40 | 45 | // StopCache ends the cache channel of sandbox id: it closes the listener (which | |
M internal/sandbox/container/driver.go +1 -1
| @@ -427,7 +427,7 @@ func (s *containerSandbox) runCache(spec sandbox.JobSpec) error { | |||
| 427 | 427 | // connect, as for the executor socket. | |
| 428 | 428 | // No bandwidth limit (0): a container has no per-job network rate limit | |
| 429 | 429 | // (NetMbit is a micro-VM setting), and its cache traffic is a local socket. | |
| 430 | - | if err := d.cfg.ServeCache(s.id, sock, spec.JobID, 0, func(p string) error { return os.Chmod(p, 0o666) }); err != nil { | |
| 430 | + | if err := d.cfg.ServeCache(s.id, sock, spec, 0, func(p string) error { return os.Chmod(p, 0o666) }); err != nil { | |
| 431 | 431 | return fmt.Errorf("serve the cache socket: %w", err) | |
| 432 | 432 | } | |
| 433 | 433 | s.cacheOn = true | |
M internal/sandbox/sandbox.go +5 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "context" | |
| 12 | 12 | "errors" | |
| 13 | 13 | "io" | |
| 14 | + | "net/http" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // Mode is an isolation mode. | |
| @@ -59,6 +60,10 @@ type JobSpec struct { | |||
| 59 | 60 | CPUs int | |
| 60 | 61 | MemoryMiB int | |
| 61 | 62 | DiskMiB int | |
| 63 | + | // Results, when not nil, is the results service of the job (artifacts, | |
| 64 | + | // package resultssvc), served on the sandbox's cache channel beside the | |
| 65 | + | // cache (Config.ServeCache). | |
| 66 | + | Results http.Handler | |
| 62 | 67 | } | |
| 63 | 68 | ||
| 64 | 69 | // Conn is the executor stream: bytes in both directions, framed by execstream. | |
M internal/sandbox/vm/driver.go +1 -1
| @@ -457,7 +457,7 @@ func (s *vmSandbox) bootCache(spec sandbox.JobSpec) error { | |||
| 457 | 457 | if s.d.cfg.Cache == nil { | |
| 458 | 458 | return nil | |
| 459 | 459 | } | |
| 460 | - | err := s.d.cfg.ServeCache(s.id, s.cacheSocket(), spec.JobID, s.d.cfg.NetMbit, func(p string) error { | |
| 460 | + | err := s.d.cfg.ServeCache(s.id, s.cacheSocket(), spec, s.d.cfg.NetMbit, func(p string) error { | |
| 461 | 461 | if s.lay.jailed { | |
| 462 | 462 | return os.Lchown(p, s.lay.uid, s.lay.uid) | |
| 463 | 463 | } | |