detached 0e950d6

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 UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The 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 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.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 - **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.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 - **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.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 - **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.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 - **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`.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 - **Off.** `ERMITE_RUNNER_CACHE=0`.90 - **Off.** `ERMITE_RUNNER_CACHE=0`.
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.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 **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.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 ### Cancellation109 ### Cancellation
98 110
@@ -350,6 +362,7 @@ the moment it starts.
350 | `ERMITE_RUNNER_CACHE` | `1` | `0` turns the cache service off (see "Cache") |362 | `ERMITE_RUNNER_CACHE` | `1` | `0` turns the cache service off (see "Cache") |
351 | `ERMITE_RUNNER_CACHE_MAX` | `10G` | size cap of the cache, LRU eviction |363 | `ERMITE_RUNNER_CACHE_MAX` | `10G` | size cap of the cache, LRU eviction |
352 | `ERMITE_RUNNER_ACTIONS_CACHE_MAX` | `2G` | size cap of the action archive cache; `0` turns it off |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 | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") |366 | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") |
354 | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |367 | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
355 | `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 |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 "bnema.dev/ermite-runner/internal/cachesvc"21 "bnema.dev/ermite-runner/internal/cachesvc"
22 "bnema.dev/ermite-runner/internal/externals"22 "bnema.dev/ermite-runner/internal/externals"
23 "bnema.dev/ermite-runner/internal/host"23 "bnema.dev/ermite-runner/internal/host"
24+ "bnema.dev/ermite-runner/internal/resultssvc"
24 "bnema.dev/ermite-runner/internal/sandbox"25 "bnema.dev/ermite-runner/internal/sandbox"
25 "bnema.dev/ermite-runner/internal/sandbox/drivers"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 // envCache=0 turns the cache service off.92 // envCache=0 turns the cache service off.
92 const envCache = "ERMITE_RUNNER_CACHE"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 // externalsWait is how long the start of the runner waits for the Node.js120 // externalsWait is how long the start of the runner waits for the Node.js
95 // downloads. A runner with a working network is then ready for its first job; one121 // downloads. A runner with a working network is then ready for its first job; one
96 // without goes on after the first failure (nothing waits for a dead network), and122 // 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 if cache != nil {236 if cache != nil {
211 defer func() { _ = cache.CloseAll() }()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 drv, err := buildDriver(ctx, scfg)244 drv, err := buildDriver(ctx, scfg)
214 if err != nil {245 if err != nil {
215 fmt.Fprintln(os.Stderr, "ermite-runner:", err)246 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
@@ -228,6 +259,10 @@ func runCmd(ctx context.Context) int {
228 Features: hcfg.ServerFeaturesFunc(url),259 Features: hcfg.ServerFeaturesFunc(url),
229 Cache: cache,260 Cache: cache,
230 Archives: archives,261 Archives: archives,
262+ Results: results,
263+ Artifacts: func(tok string) ermiteconnect.JobArtifactServiceClient {
264+ return hcfg.ArtifactClient(url, tok)
265+ },
231 }266 }
232 r.Cfg.URL = url267 r.Cfg.URL = url
233 if err := r.Run(ctx); err != nil {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 t.Errorf("url without a cache: %q", u)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 import (5 import (
6 "net"6 "net"
7+ "net/http"
7 "sync"8 "sync"
8 "time"9 "time"
9 )10 )
@@ -151,6 +152,7 @@ type ServeOption func(*serveConfig)
151 152
152 type serveConfig struct {153 type serveConfig struct {
153 bytesPerSec int64154 bytesPerSec int64
155+ results http.Handler
154 }156 }
155 157
156 // WithBandwidth limits the channel to mbit Mbit/s, summed over all its158 // WithBandwidth limits the channel to mbit Mbit/s, summed over all its
@@ -167,3 +169,10 @@ func WithBandwidth(mbit int) ServeOption {
167 func withRate(bytesPerSec int64) ServeOption {169 func withRate(bytesPerSec int64) ServeOption {
168 return func(c *serveConfig) { c.bytesPerSec = bytesPerSec }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 type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims)99 type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims)
77 100
78 // auth checks the bearer token (ACTIONS_RUNTIME_TOKEN), and when job is not101 // 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 // One Service per runner host. Entries live under <state>/cache/ (files plus a9 // One Service per runner host. Entries live under <state>/cache/ (files plus a
10 // JSON index, atomic finalisation, LRU eviction by total size) and are scoped by10 // JSON index, atomic finalisation, LRU eviction by total size) and are scoped by
11 // (repository, ref, key, version). A job reaches the service with a bearer token11 // (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 in12+// (ACTIONS_RUNTIME_TOKEN): a JWT signed with HS256 under a key that exists only
13-// the memory of the runner, over its job id, repository, write ref, read refs13+// in the memory of the runner, over its job id, repository, write ref, read
14-// and expiry.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 // The service never listens on a network. Each sandbox has a channel of its own18 // The service never listens on a network. Each sandbox has a channel of its own
17 // (Serve): a Unix socket, bind-mounted into a container or reached through19 // (Serve): a Unix socket, bind-mounted into a container or reached through
@@ -26,6 +28,7 @@ import (
26 "net"28 "net"
27 "net/http"29 "net/http"
28 "strconv"30 "strconv"
31+ "strings"
29 "sync"32 "sync"
30 "time"33 "time"
31 34
@@ -51,9 +54,10 @@ const (
51 // progress before its connection is cut.54 // progress before its connection is cut.
52 DefaultProgressTimeout = 60 * time.Second55 DefaultProgressTimeout = 60 * time.Second
53 // DefaultConnsPerListener and DefaultConns bound the open connections of one56 // DefaultConnsPerListener and DefaultConns bound the open connections of one
54- // sandbox's listener and of the whole service.57+ // sandbox's listener and of the whole service. upload-artifact sends 5
55- DefaultConnsPerListener = 1658+ // blocks at once by default, besides downloads and the cache.
56- DefaultConns = 6459+ DefaultConnsPerListener = 32
60+ DefaultConns = 128
57 )61 )
58 62
59 // Config configures a Service.63 // Config configures a Service.
@@ -166,21 +170,44 @@ func (s *Service) housekeeping() {
166 }170 }
167 171
168 // Token returns the ACTIONS_RUNTIME_TOKEN of a job. It is valid for ttl, which172 // Token returns the ACTIONS_RUNTIME_TOKEN of a job. It is valid for ttl, which
169-// should be the job timeout plus a margin.173+// should be the job timeout plus a margin. sc is the job's cache scope (the
170-func (s *Service) Token(jobID string, sc Scope, ttl time.Duration) string {174+// zero Scope for a job without a cache: the cache then refuses the token);
171- return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: s.now().Add(ttl).Unix()})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 // start serves HTTP on ln, subject to the connection caps, until the listener201 // start serves HTTP on ln, subject to the connection caps, until the listener
175 // is closed. job, when not empty, restricts the tokens accepted to that job's.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 if bw != nil {204 if bw != nil {
178 ln = throttleListener{ln, bw}205 ln = throttleListener{ln, bw}
179 }206 }
180 ln = newLimitListener(ln, s.perListener, s.connsGlobal)207 ln = newLimitListener(ln, s.perListener, s.connsGlobal)
181 l := &listener{208 l := &listener{
182 srv: &http.Server{209 srv: &http.Server{
183- Handler: s.handler(job),210+ Handler: s.channelHandler(job, results),
184 ReadHeaderTimeout: 30 * time.Second,211 ReadHeaderTimeout: 30 * time.Second,
185 // Bodies are chunks of up to tens of MiB and archives of GiB: only212 // Bodies are chunks of up to tens of MiB and archives of GiB: only
186 // idle connections time out.213 // idle connections time out.
@@ -227,7 +254,7 @@ func (s *Service) Serve(id string, ln net.Listener, job string, opts ...ServeOpt
227 _ = ln.Close()254 _ = ln.Close()
228 return fmt.Errorf("cache: already serving %s", id)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 return nil258 return nil
232 }259 }
233 260
M internal/cachesvc/service_test.go +8 -4
@@ -370,15 +370,19 @@ func TestBadTokens(t *testing.T) {
370 good := r.job("j1", repoA, main_, main_)370 good := r.job("j1", repoA, main_, main_)
371 other := newRig(t, 1<<20) // another runner: another key371 other := newRig(t, 1<<20) // another runner: another key
372 expired := r.svc.sign.token(Claims{JobID: "j", Scope: Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, Expiry: r.clk.now().Add(-time.Second).Unix()})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 tampered := func() string {374 tampered := func() string {
374- p, m, _ := strings.Cut(good.token, ".")375+ payload, _ := b64.DecodeString(seg[1])
375- payload, _ := b64.DecodeString(p)
376 payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1)376 payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1)
377- return b64.EncodeToString(payload) + "." + m377+ 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 for name, tok := range map[string]string{382 for name, tok := range map[string]string{
380 "none": "", "garbage": "abc", "dots": "a.b", "other key": other.job("j1", repoA, main_, main_).token,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 j := &job{r: r, token: tok}387 j := &job{r: r, token: tok}
384 for _, req := range []struct{ m, p string }{{"GET", "cache?keys=k&version=v"}, {"POST", "caches"}, {"PATCH", "caches/1"}, {"POST", "caches/1"}, {"GET", "caches?key=k"}} {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 ReadRefs []string `json:"rd,omitempty"`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 type Claims struct {34 type Claims struct {
33 JobID string `json:"j"`35 JobID string `json:"j"`
34 Scope36 Scope
35 // Expiry is a Unix time in seconds.37 // Expiry is a Unix time in seconds.
36 Expiry int64 `json:"x"`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 // errForbidden is every authentication failure: the caller learns nothing about52 // errForbidden is every authentication failure: the caller learns nothing about
40 // why a token was refused.53 // why a token was refused.
41 var errForbidden = errors.New("cache: forbidden")54 var errForbidden = errors.New("cache: forbidden")
@@ -71,29 +84,58 @@ var b64 = base64.RawURLEncoding
71 // maxTokenLen bounds what verify parses.84 // maxTokenLen bounds what verify parses.
72 const maxTokenLen = 16 << 1085 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 func (s *signer) token(c Claims) string {98 func (s *signer) token(c Claims) string {
76 payload, _ := json.Marshal(c) // plain strings and ints: cannot fail99 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 not106+// verifyAny returns the claims of a token that this runner made and that has
81-// expired. The comparison is constant time.107+// not expired, whatever its scopes. The comparison is constant time.
82-func (s *signer) verify(tok string, now time.Time) (Claims, error) {108+func (s *signer) verifyAny(tok string, now time.Time) (Claims, error) {
83 var c Claims109 var c Claims
84 if len(tok) > maxTokenLen {110 if len(tok) > maxTokenLen {
85 return c, errForbidden111 return c, errForbidden
86 }112 }
87- p, m, ok := strings.Cut(tok, ".")113+ head, rest, ok1 := strings.Cut(tok, ".")
88- if !ok {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 return c, errForbidden120 return c, errForbidden
90 }121 }
91- payload, err1 := b64.DecodeString(p)122+ h := hmac.New(sha256.New, s.jwtKey())
92- sum, err2 := b64.DecodeString(m)123+ h.Write([]byte(head + "." + p))
93- if err1 != nil || err2 != nil || !hmac.Equal(sum, s.mac("token", payload)) {124+ if !hmac.Equal(sum, h.Sum(nil)) {
94 return c, errForbidden125 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 return Claims{}, errForbidden139 return Claims{}, errForbidden
98 }140 }
99 return c, nil141 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 // ACTIONS_CACHE_URL=http://127.0.0.1:<port>/ and the token as95 // ACTIONS_CACHE_URL=http://127.0.0.1:<port>/ and the token as
96 // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official96 // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official
97 // runner does, and masks the token. The token is not an ermite credential: it97 // 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 this98+ // is a JWT (HS256) that only the runner's runtime services accept, scoped to
99- // job's repository and refs.99+ // this job's repository and refs (and its run, see results).
100 uint32 cache_port = 15;100 uint32 cache_port = 15;
101 string cache_token = 16;101 string cache_token = 16;
102 // Job.secrets and Job.vars are not here: they come in NamedValue frames102 // Job.secrets and Job.vars are not here: they come in NamedValue frames
@@ -117,8 +117,17 @@ message JobSpec {
117 TokenPermissions token_permissions = 20;117 TokenPermissions token_permissions = 20;
118 // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"118 // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"
119 // when the job's cache scope has a write ref, "read" when it has none. Unset119 // 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 string cache_mode = 21;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 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to133 // 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 cacheTestToken = "eyJqIjoiMSJ9.c2lnbmF0dXJl"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 const envAction = `name: e23 const envAction = `name: e
18 description: d24 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 v, _ := in.Get(k)104 v, _ := in.Get(k)
105 env.Set(inputEnvName(k), v)105 env.Set(inputEnvName(k), v)
106 }106 }
107- j.addRuntimeEnv(env)107+ // A docker action always runs in a container of its own.
108+ j.addRuntimeEnv(env, true)
108 j.mu.Lock()109 j.mu.Lock()
109 workspace := j.state.GitHub("workspace")110 workspace := j.state.GitHub("workspace")
110 temp := j.state.Layout().Temp111 temp := j.state.Layout().Temp
M internal/executor/actions_docker_test.go +1 -0
@@ -29,6 +29,7 @@ pull)
29 build) echo "built" ;;29 build) echo "built" ;;
30 run)30 run)
31 echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO"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 echo "::set-output name=x::y" ;;33 echo "::set-output name=x::y" ;;
33 esac34 esac
34 exit 035 exit 0
M internal/executor/actions_node.go +37 -18
@@ -152,26 +152,44 @@ func (j *job) externalsDir() string {
152 }152 }
153 153
154 // addRuntimeEnv is the "Add Actions Runtime server info" part of the handlers154 // addRuntimeEnv is the "Add Actions Runtime server info" part of the handlers
155-// (NodeScriptActionHandler.cs:55-72, ContainerActionHandler.cs:219-236): the155+// (NodeScriptActionHandler.cs:55-84, ContainerActionHandler.cs:219-245): the
156-// variables @actions/cache reads. ermite has no Actions runtime service, only156+// variables @actions/cache and @actions/artifact read. ermite's runtime service
157-// the cache of the runner, so only ACTIONS_CACHE_URL (the base of157+// is the channel of the runner behind one URL, which serves the cache, the
158-// "_apis/artifactcache/…": the toolkit appends to it, cacheHttpClient.ts:47, so158+// results service (artifacts) or both: ACTIONS_RUNTIME_URL and
159-// it ends with a slash), ACTIONS_RUNTIME_URL (same value, for clients that159+// ACTIONS_RUNTIME_TOKEN, then, when the channel serves the cache,
160-// require the variable) and ACTIONS_RUNTIME_TOKEN are set. ACTIONS_CACHE_SERVICE_V2160+// ACTIONS_CACHE_URL (the base of "_apis/artifactcache/…": the toolkit appends
161-// is never set: the toolkit would then ignore ACTIONS_CACHE_URL (config.ts:42-58)161+// to it, cacheHttpClient.ts:47, so it ends with a slash) and ACTIONS_CACHE_MODE,
162-// and, on a server URL that is not github.com, uses v1 anyway (config.ts:22-27).162+// and, when it serves results, ACTIONS_RESULTS_URL (the handlers set it from
163-// Without a cache service in the job nothing is set.163+// ResultsServiceUrl, NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
164-func (j *job) addRuntimeEnv(env *ordered) {164+// 237-245; the toolkit keeps only its origin, artifact shared/config.ts).
165- if j.cfg.CacheURL == "" || j.cfg.CacheToken == "" {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 return179 return
167 }180 }
168- env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)181+ env.Set("ACTIONS_RUNTIME_URL", url)
169 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)182 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
170- env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)183+ if !j.cfg.CacheOff {
171- // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:184+ env.Set("ACTIONS_CACHE_URL", url)
172- // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).185+ // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
173- if j.cfg.CacheMode != "" {186+ // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
174- env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)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 v, _ := in.Get(k)203 v, _ := in.Get(k)
186 env.Set(inputEnvName(k), v)204 env.Set(inputEnvName(k), v)
187 }205 }
188- j.addRuntimeEnv(env)206+ j.addRuntimeEnv(env, j.stepContainer() != nil)
189 // The PATH of the previous steps is added by procEnv.207 // The PATH of the previous steps is added by procEnv.
190 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795208 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
191 r := def.runs()209 r := def.runs()
@@ -206,6 +224,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
206 if st, err := os.Stat(script); err != nil || st.IsDir() {224 if st, err := os.Stat(script); err != nil || st.IsDir() {
207 return fail(ghErrorf("File not found: '%s'", script))225 return fail(ghErrorf("File not found: '%s'", script))
208 }226 }
227+ j.setArtifactsServerURL(s, script)
209 if c := j.stepContainer(); c != nil {228 if c := j.stepContainer(); c != nil {
210 return j.runNodeInContainer(s, c, script, env, cancel)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 // health words, one per inspect: starting, healthy, unhealthy17 // health words, one per inspect: starting, healthy, unhealthy
18 // create_fail docker create exits 12518 // create_fail docker create exits 125
19 // exec_out what docker exec prints19 // exec_out what docker exec prints
20+// exec_vars names of variables docker exec prints ("VAR NAME=value")
20 // hang_exec docker exec waits until it gets a signal21 // hang_exec docker exec waits until it gets a signal
21 type fakeCtr struct {22 type fakeCtr struct {
22 dir, bin, log string23 dir, bin, log string
@@ -61,6 +62,7 @@ rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove conta
61 echo "$3" ;;62 echo "$3" ;;
62 exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi63 exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi
63 [ -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"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 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }66 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
65 [ -f $D/exec_out ] && cat $D/exec_out67 [ -f $D/exec_out ] && cat $D/exec_out
66 if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;;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 wd := j.state.GitHub("workspace")87 wd := j.state.GitHub("workspace")
88 j.mu.Unlock()88 j.mu.Unlock()
89 henv := j.handlerEnv(env)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 if err := checkEnvNames(henv.Keys()); err != nil {94 if err := checkEnvNames(henv.Keys()); err != nil {
91 return fail(err)95 return fail(err)
92 }96 }
M internal/executor/job.go +17 -0
@@ -81,6 +81,20 @@ type Config struct {
81 // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL;81 // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL;
82 // empty sets nothing.82 // empty sets nothing.
83 CacheMode string83 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 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in98 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in
85 // the masker before the first step and form the secrets context; they are99 // the masker before the first step and form the secrets context; they are
86 // never logged. Vars are the fallback of the vars context.100 // never logged. Vars are the fallback of the vars context.
@@ -199,6 +213,9 @@ type job struct {
199 summaries map[int]string213 summaries map[int]string
200 logLines map[int]int // lines written to the log of each step, under mu214 logLines map[int]int // lines written to the log of each step, under mu
201 states map[string]*intraState // intra-action state, see saveState215 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 func (j *job) mask(s string) string { return j.masker.Load().mask(s) }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 // def is the action the step runs.50 // def is the action the step runs.
51 def *actionDef51 def *actionDef
52 nodeVersion string52 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 // scope is what the step shares with the step that contains it, see56 // scope is what the step shares with the step that contains it, see
54 // stepScope. name is the display name of the step in the log; timedOut is set when the step57 // stepScope. name is the display name of the step in the log; timedOut is set when the step
55 // reached its timeout-minutes.58 // reached its timeout-minutes.
@@ -76,10 +79,29 @@ func (s *stepRun) procEnv() []string {
76 if s.rawEnvList != nil {79 if s.rawEnvList != nil {
77 return s.rawEnvList80 return s.rawEnvList
78 }81 }
82+ env := s.env
79 if s.actEnv != nil {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 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }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 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "io"8 "io"
8 "net"9 "net"
9 "net/http"10 "net/http"
10 "path/filepath"11 "path/filepath"
12+ "strconv"
11 "testing"13 "testing"
12 "time"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 // freePort returns a loopback port nobody listens on.69 // freePort returns a loopback port nobody listens on.
34 func freePort(t *testing.T) uint32 {70 func freePort(t *testing.T) uint32 {
35 t.Helper()71 t.Helper()
@@ -59,8 +95,9 @@ func TestStartCacheRelay(t *testing.T) {
59 95
60 port := freePort(t)96 port := freePort(t)
61 spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"}97 spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"}
62- got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec)98+ got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec, noBridge)
63- if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) {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 t.Fatalf("relay %+v", got)101 t.Fatalf("relay %+v", got)
65 }102 }
66 103
@@ -89,7 +126,7 @@ func TestStartCacheRelay(t *testing.T) {
89 func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) {126 func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) {
90 target := Target{Network: "unix", Addr: filepath.Join(t.TempDir(), "exec.sock")}127 target := Target{Network: "unix", Addr: filepath.Join(t.TempDir(), "exec.sock")}
91 for _, spec := range []*execpb.JobSpec{{}, {CachePort: 9089}, {CacheToken: "tok"}} {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 t.Errorf("%+v: relay %+v", spec, got)130 t.Errorf("%+v: relay %+v", spec, got)
94 }131 }
95 }132 }
@@ -99,7 +136,82 @@ func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) {
99 }136 }
100 defer func() { _ = busy.Close() }()137 defer func() { _ = busy.Close() }()
101 spec := &execpb.JobSpec{CachePort: uint32(busy.Addr().(*net.TCPAddr).Port), CacheToken: "tok"}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 t.Errorf("taken port: relay %+v", got)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 canceller := executor.NewCanceller()214 canceller := executor.NewCanceller()
215 rep := &frameReporter{conn: conn, failed: make(chan struct{})}215 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
216 res := newResolver(conn)216 res := newResolver(conn)
217- cacheEnv := startCacheRelay(ctx, cfg.Target, spec)217+ cacheEnv := startCacheRelay(ctx, cfg.Target, spec, docker0IPv4)
218 defer cacheEnv.close()218 defer cacheEnv.close()
219 219
220 // Frames from the host while the job runs: a Cancel stops the job; a lost220 // 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 RunnerVersion: spec.GetRunnerVersion(),260 RunnerVersion: spec.GetRunnerVersion(),
261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
262 CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode,262 CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode,
263+ ContainerCacheURL: cacheEnv.containerURL, Results: cacheEnv.results, CacheOff: cacheEnv.cacheOff,
263 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),264 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
264 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),265 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
265 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),266 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
@@ -276,44 +277,111 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
276 // the toolkit appends "_apis/…" to it (cacheHttpClient.ts:47). Empty when the277 // the toolkit appends "_apis/…" to it (cacheHttpClient.ts:47). Empty when the
277 // job has no cache service.278 // job has no cache service.
278 func cacheURL(port uint32) string {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 if port == 0 || port > 65535 {286 if port == 0 || port > 65535 {
280 return ""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 the292+// BridgeInterface is the default bridge of the sandbox's docker daemon. Its
286-// loopback it listens on and the token of the job. All are empty without a cache293+// address is a local address of the sandbox that every container of that
287-// service.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 type cacheRelay struct {328 type cacheRelay struct {
289- relay *Relay329+ relay, bridge *Relay
290- url, token, mode string330+ url, containerURL string
331+ token, mode string
332+ results, cacheOff bool
291 }333 }
292 334
293 func (c cacheRelay) close() {335 func (c cacheRelay) close() {
294 if c.relay != nil {336 if c.relay != nil {
295 _ = c.relay.Close()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 cache344+// startCacheRelay starts the relay of the runtime service for a job that has
300-// service: it listens on the loopback port the host says and pipes every345+// one: it listens on the loopback port the host says and pipes every
301 // connection to a new stream on the cache channel of the host (a sibling of346 // 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 a347+// the executor's target). A relay that cannot start leaves the job without the
303-// cache, like a scope the host refuses: the job runs.348+// service, like a scope the host refuses: the job runs. A second relay listens
304-func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) cacheRelay {349+// on the same port at the address bridge returns, for the job container and
305- url := cacheURL(spec.GetCachePort())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 if url == "" || spec.GetCacheToken() == "" {355 if url == "" || spec.GetCacheToken() == "" {
307 return cacheRelay{}356 return cacheRelay{}
308 }357 }
309- addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(spec.GetCachePort()), 10))358+ log := zerowrap.FromCtx(ctx)
310- r, err := StartRelay(ctx, addr, target.CacheTarget().DialStream, DefaultRelayMax)359+ dial := target.CacheTarget().DialStream
360+ addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(port), 10))
361+ r, err := StartRelay(ctx, addr, dial, DefaultRelayMax)
311 if err != nil {362 if err != nil {
312- log := zerowrap.FromCtx(ctx)
313 log.Warn().Err(err).Msg("job runs without the cache")363 log.Warn().Err(err).Msg("job runs without the cache")
314 return cacheRelay{}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 // failSetup ends a job that cannot start, like a failed "Set up job": step 1387 // 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 // The cache service of the runner never listens on a network. In the sandbox it15 // The cache service of the runner never listens on a network. In the sandbox it
16 // is reached on the guest's own loopback (CacheListenHost, on the port the16 // 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 connections17+// host gives in JobSpec.cache_port), and on the same port at the address of
18-// of the actions and pipes each of them to a new stream on the host↔guest18+// the docker bridge for the steps that run in a container (BridgeInterface),
19-// channel (a vsock port for a VM, a Unix socket for a container); the host19+// where a Relay accepts the TCP connections of the actions and pipes each of
20-// serves HTTP on the other end of that stream.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 const (23 const (
22 // CacheListenHost is the loopback address of the relay.24 // CacheListenHost is the loopback address of the relay.
23 CacheListenHost = "127.0.0.1"25 CacheListenHost = "127.0.0.1"
@@ -40,7 +42,7 @@ const (
40 // DialFunc opens one new stream to the host.42 // DialFunc opens one new stream to the host.
41 type DialFunc func(ctx context.Context) (io.ReadWriteCloser, error)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 a45+// Relay accepts TCP connections on a local address and pipes each one to a
44 // new stream from its DialFunc, in both directions, until Close.46 // new stream from its DialFunc, in both directions, until Close.
45 type Relay struct {47 type Relay struct {
46 ln net.Listener48 ln net.Listener
M internal/host/cache.go +42 -10
@@ -5,12 +5,14 @@ package host
5 import (5 import (
6 "encoding/json"6 "encoding/json"
7 "fmt"7 "fmt"
8+ "net/http"
8 "strings"9 "strings"
9 "time"10 "time"
10 11
11 ermite "bnema.dev/ermite-api/gen/ermite"12 ermite "bnema.dev/ermite-api/gen/ermite"
12 13
13 "bnema.dev/ermite-runner/internal/cachesvc"14 "bnema.dev/ermite-runner/internal/cachesvc"
15+ "bnema.dev/ermite-runner/internal/resultssvc"
14 )16 )
15 17
16 // cacheTokenMargin is added to the job timeout for the life of the cache token: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 return c.Github.Repository108 return c.Github.Repository
107 }109 }
108 110
109-// cacheFor returns the loopback port the cache has in the guest and the token111+// runtimeFor returns what the executor needs of the job's runtime channel: the
110-// of a job, or 0 and "" when the runner serves no cache or the job has no scope.112+// port it has in the guest, the job's ACTIONS_RUNTIME_TOKEN, the cache mode and
111-func (j *jobRun) cacheFor(rt *Runner) (port uint32, token, mode string) {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 if rt.Cache == nil {119 if rt.Cache == nil {
113- return 0, "", ""120+ return 0, "", "", false
114 }121 }
115 sc, ok, err := cacheScope(j.job, j.contexts)122 sc, ok, err := cacheScope(j.job, j.contexts)
116 if err != nil {123 if err != nil {
117 // A bad scope from the server is not the job's failure: it runs without a124 // A bad scope from the server is not the job's failure: it runs without a
118 // cache.125 // cache.
119 j.log.Warn().Err(err).Msg("cache disabled for the job")126 j.log.Warn().Err(err).Msg("cache disabled for the job")
120- return 0, "", ""127+ ok = false
121 }128 }
122 if !ok {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 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin144 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126- mode = "read"145+ return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl, scopes...), mode, j.results
127- if sc.WriteRef != "" {146+}
128- mode = "write"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), mode162+ 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 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
14 14
15 ermite "bnema.dev/ermite-api/gen/ermite"15 ermite "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
16 17
17 "bnema.dev/ermite-runner/internal/cachesvc"18 "bnema.dev/ermite-runner/internal/cachesvc"
19+ "bnema.dev/ermite-runner/internal/resultssvc"
18 )20 )
19 21
20 const scopeCtx = `{"github":{"repository":"o/r"}}`22 const scopeCtx = `{"github":{"repository":"o/r"}}`
@@ -123,7 +125,7 @@ func TestCacheScopeFallback(t *testing.T) {
123 125
124 // A mismatched scope disables the cache of the job, with a log line; the job126 // A mismatched scope disables the cache of the job, with a log line; the job
125 // goes on.127 // goes on.
126-func TestCacheForMismatchDisablesCache(t *testing.T) {128+func TestRuntimeForMismatchDisablesCache(t *testing.T) {
127 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})129 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
128 if err != nil {130 if err != nil {
129 t.Fatal(err)131 t.Fatal(err)
@@ -134,7 +136,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
134 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},136 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
135 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),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 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)140 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
139 }141 }
140 if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {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 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}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 t.Fatalf("no service: %d %q", port, tok)150 t.Fatalf("no service: %d %q", port, tok)
149 }151 }
150 }152 }
151 153
152 // The token of a job is the one the service accepts, for the job's repository154 // The token of a job is the one the service accepts, for the job's repository
153 // and ref only.155 // and ref only.
154-func TestCacheForToken(t *testing.T) {156+func TestRuntimeForToken(t *testing.T) {
155 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})157 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
156 if err != nil {158 if err != nil {
157 t.Fatal(err)159 t.Fatal(err)
@@ -161,10 +163,10 @@ func TestCacheForToken(t *testing.T) {
161 defer srv.Close()163 defer srv.Close()
162 rt := &Runner{Cache: svc}164 rt := &Runner{Cache: svc}
163 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}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 // The port is the cache's loopback port in the guest, not a host port.167 // The port is the cache's loopback port in the guest, not a host port.
166- if port != 9089 || tok == "" {168+ if port != 9089 || tok == "" || results {
167- t.Fatalf("%d %q", port, tok)169+ t.Fatalf("%d %q %v", port, tok, results)
168 }170 }
169 // No Job.cache: the narrowest scope writes the job's own ref.171 // No Job.cache: the narrowest scope writes the job's own ref.
170 if mode != "write" {172 if mode != "write" {
@@ -193,7 +195,7 @@ func TestCacheForToken(t *testing.T) {
193 195
194 // ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job196 // ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job
195 // read-only.197 // read-only.
196-func TestCacheForMode(t *testing.T) {198+func TestRuntimeForMode(t *testing.T) {
197 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})199 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
198 if err != nil {200 if err != nil {
199 t.Fatal(err)201 t.Fatal(err)
@@ -204,8 +206,82 @@ func TestCacheForMode(t *testing.T) {
204 job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},206 job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},
205 contexts: `{"github":{"repository":"o/r"}}`,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 t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want)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 return NewClient(url, token, http.DefaultTransport)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 // environment is Environment, the default when a Config was built without85 // environment is Environment, the default when a Config was built without
81 // LoadConfig.86 // LoadConfig.
82 func (c Config) environment() string {87 func (c Config) environment() string {
M internal/host/job.go +5 -2
@@ -79,6 +79,8 @@ type jobRun struct {
79 // features is what the server accepts; annLists are the annotations the79 // features is what the server accepts; annLists are the annotations the
80 // executor sent for steps that have not completed yet (see results.go).80 // executor sent for steps that have not completed yet (see results.go).
81 features jobFeatures81 features jobFeatures
82+ // results says the job gets the results service (see resultsOn).
83+ results bool
82 annLists map[int32][]*ermite.Annotation84 annLists map[int32][]*ermite.Annotation
83 // annSeen and sumSeen are the steps that sent their annotations and their85 // annSeen and sumSeen are the steps that sent their annotations and their
84 // summary: each is sent once.86 // summary: each is sent once.
@@ -623,6 +625,7 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
623 // Stop runs to completion even when the job context is done.625 // Stop runs to completion even when the job context is done.
624 defer j.stopSandbox(ctx, rt, sb)626 defer j.stopSandbox(ctx, rt, sb)
625 j.features = j.loadFeatures(ctx, rt)627 j.features = j.loadFeatures(ctx, rt)
628+ j.results = j.resultsOn(rt)
626 629
627 conn, err := j.startSandbox(ctx, rt, sb, stopHard)630 conn, err := j.startSandbox(ctx, rt, sb, stopHard)
628 if err != nil {631 if err != nil {
@@ -679,7 +682,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo
679 case <-stopWatch: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 // drive sends the job to the executor and handles its frames until it exits.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 if p := j.job.Permissions; p != nil && j.features.secrets {703 if p := j.job.Permissions; p != nil && j.features.secrets {
701 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}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 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{707 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,708 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
706 Log: j.logLine,709 Log: j.logLine,
M internal/host/results.go +7 -2
@@ -23,6 +23,9 @@ const (
23 // FeatureSecrets: the server sends Job.secrets, vars, environment,23 // FeatureSecrets: the server sends Job.secrets, vars, environment,
24 // permissions and trusted. Without it Job.trusted is absent, not false.24 // permissions and trusted. Without it Job.trusted is absent, not false.
25 FeatureSecrets = "ci.secrets"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 // Limits of Annotation and UploadSummaryRequest (runner.proto). The executor31 // Limits of Annotation and UploadSummaryRequest (runner.proto). The executor
@@ -42,7 +45,7 @@ const (
42 type ServerFeatures func(ctx context.Context) ([]string, error)45 type ServerFeatures func(ctx context.Context) ([]string, error)
43 46
44 // jobFeatures is what the server accepts for this job.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 func featuresOf(list []string) jobFeatures {50 func featuresOf(list []string) jobFeatures {
48 var f jobFeatures51 var f jobFeatures
@@ -54,6 +57,8 @@ func featuresOf(list []string) jobFeatures {
54 f.summaries = true57 f.summaries = true
55 case FeatureSecrets:58 case FeatureSecrets:
56 f.secrets = true59 f.secrets = true
60+ case FeatureArtifacts:
61+ f.artifacts = true
57 }62 }
58 }63 }
59 return f64 return f
@@ -68,7 +73,7 @@ func (j *jobRun) loadFeatures(ctx context.Context, rt *Runner) jobFeatures {
68 defer cancel()73 defer cancel()
69 list, err := rt.Features(cctx)74 list, err := rt.Features(cctx)
70 if err != nil {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 return jobFeatures{}77 return jobFeatures{}
73 }78 }
74 return featuresOf(list)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 return ermiteconnect.NewRunnerServiceClient(hc, strings.TrimRight(baseURL, "/")+APIPath)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 // ServerFeaturesFunc returns the function that reads the features of the120 // ServerFeaturesFunc returns the function that reads the features of the
110 // server with GetVersion, which needs no authentication. It is called once121 // server with GetVersion, which needs no authentication. It is called once
111 // per job.122 // per job.
M internal/host/runner.go +13 -2
@@ -23,6 +23,7 @@ import (
23 "bnema.dev/ermite-runner/internal/actionscache"23 "bnema.dev/ermite-runner/internal/actionscache"
24 "bnema.dev/ermite-runner/internal/cachesvc"24 "bnema.dev/ermite-runner/internal/cachesvc"
25 "bnema.dev/ermite-runner/internal/execstream/execpb"25 "bnema.dev/ermite-runner/internal/execstream/execpb"
26+ "bnema.dev/ermite-runner/internal/resultssvc"
26 "bnema.dev/ermite-runner/internal/sandbox"27 "bnema.dev/ermite-runner/internal/sandbox"
27 )28 )
28 29
@@ -50,6 +51,14 @@ type Runner struct {
50 // means the default transport.51 // means the default transport.
51 Archives *actionscache.Cache52 Archives *actionscache.Cache
52 ArchiveHTTP *http.Client53 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 // now returns the current time; tests replace it.63 // now returns the current time; tests replace it.
55 now func() time.Time64 now func() time.Time
@@ -70,8 +79,10 @@ func (r *Runner) clock() time.Time {
70 // (sandbox.Config.Resolve in Start), that is from ERMITE_RUNNER_JOB_CPUS,79 // (sandbox.Config.Resolve in Start), that is from ERMITE_RUNNER_JOB_CPUS,
71 // _JOB_MEMORY and _JOB_DISK, so that the limits are one setting of the runner80 // _JOB_MEMORY and _JOB_DISK, so that the limits are one setting of the runner
72 // and not something a job or this package can change.81 // and not something a job or this package can change.
73-func (r *Runner) spec(j *ermite.Job) sandbox.JobSpec {82+//
74- return sandbox.JobSpec{JobID: j.JobId}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 // markLeaked records that a sandbox could not be cleaned up: no new job is88 // 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 // loopback: through the sandbox's own bind mount (container) or through14 // loopback: through the sandbox's own bind mount (container) or through
15 // Firecracker's vsock proxy (VM, which connects to <uds>_<port> when the guest15 // Firecracker's vsock proxy (VM, which connects to <uds>_<port> when the guest
16 // connects to the host on that port). Nothing but that sandbox can reach the16 // 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 (see17+// path, and the service accepts on it only tokens made for spec.JobID (see
18-// cachesvc.Service.Serve). own, when not nil, sets the owner and mode of the18+// 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 // socket before it is served. mbit caps the channel's traffic, all its20 // socket before it is served. mbit caps the channel's traffic, all its
20 // connections and both directions together (0: unlimited); a VM passes its21 // connections and both directions together (0: unlimited); a VM passes its
21 // NetMbit, as the cache stream does not cross eth0, which carries that limit.22 // NetMbit, as the cache stream does not cross eth0, which carries that limit.
22 // With no cache service this does nothing.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 if c.Cache == nil {25 if c.Cache == nil {
25 return nil26 return nil
26 }27 }
@@ -34,7 +35,11 @@ func (c Config) ServeCache(id, path, job string, mbit int, own func(path string)
34 return err35 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 // StopCache ends the cache channel of sandbox id: it closes the listener (which45 // 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 // connect, as for the executor socket.427 // connect, as for the executor socket.
428 // No bandwidth limit (0): a container has no per-job network rate limit428 // No bandwidth limit (0): a container has no per-job network rate limit
429 // (NetMbit is a micro-VM setting), and its cache traffic is a local socket.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 return fmt.Errorf("serve the cache socket: %w", err)431 return fmt.Errorf("serve the cache socket: %w", err)
432 }432 }
433 s.cacheOn = true433 s.cacheOn = true
M internal/sandbox/sandbox.go +5 -0
@@ -11,6 +11,7 @@ import (
11 "context"11 "context"
12 "errors"12 "errors"
13 "io"13 "io"
14+ "net/http"
14 )15 )
15 16
16 // Mode is an isolation mode.17 // Mode is an isolation mode.
@@ -59,6 +60,10 @@ type JobSpec struct {
59 CPUs int60 CPUs int
60 MemoryMiB int61 MemoryMiB int
61 DiskMiB int62 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 // Conn is the executor stream: bytes in both directions, framed by execstream.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 if s.d.cfg.Cache == nil {457 if s.d.cfg.Cache == nil {
458 return nil458 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 if s.lay.jailed {461 if s.lay.jailed {
462 return os.Lchown(p, s.lay.uid, s.lay.uid)462 return os.Lchown(p, s.lay.uid, s.lay.uid)
463 }463 }

Keyboard shortcuts

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

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

Branches 4

Tags 11

↑↓ moveenter openesc close