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
7979
8080 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.
8181
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:
8383 - **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.
8484 - **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.
8585
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.
8787 - **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.
8888 - **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.
9090 - **Off.** `ERMITE_RUNNER_CACHE=0`.
9191 - **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.
9292
9393 **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.
9494
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.
96108
97109 ### Cancellation
98110
@@ -350,6 +362,7 @@ the moment it starts.
350362 | `ERMITE_RUNNER_CACHE` | `1` | `0` turns the cache service off (see "Cache") |
351363 | `ERMITE_RUNNER_CACHE_MAX` | `10G` | size cap of the cache, LRU eviction |
352364 | `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") |
353366 | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off (see "Externals") |
354367 | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
355368 | `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 (
2121 "bnema.dev/ermite-runner/internal/cachesvc"
2222 "bnema.dev/ermite-runner/internal/externals"
2323 "bnema.dev/ermite-runner/internal/host"
24+ "bnema.dev/ermite-runner/internal/resultssvc"
2425 "bnema.dev/ermite-runner/internal/sandbox"
2526 "bnema.dev/ermite-runner/internal/sandbox/drivers"
2627 )
@@ -91,6 +92,31 @@ func setupArchives(ctx context.Context, stateDir string) (*actionscache.Cache, e
9192 // envCache=0 turns the cache service off.
9293 const envCache = "ERMITE_RUNNER_CACHE"
9394
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+
94120 // externalsWait is how long the start of the runner waits for the Node.js
95121 // downloads. A runner with a working network is then ready for its first job; one
96122 // without goes on after the first failure (nothing waits for a dead network), and
@@ -210,6 +236,11 @@ func runCmd(ctx context.Context) int {
210236 if cache != nil {
211237 defer func() { _ = cache.CloseAll() }()
212238 }
239+ results, err := setupResults(ctx, cache)
240+ if err != nil {
241+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
242+ return 2
243+ }
213244 drv, err := buildDriver(ctx, scfg)
214245 if err != nil {
215246 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
@@ -228,6 +259,10 @@ func runCmd(ctx context.Context) int {
228259 Features: hcfg.ServerFeaturesFunc(url),
229260 Cache: cache,
230261 Archives: archives,
262+ Results: results,
263+ Artifacts: func(tok string) ermiteconnect.JobArtifactServiceClient {
264+ return hcfg.ArtifactClient(url, tok)
265+ },
231266 }
232267 r.Cfg.URL = url
233268 if err := r.Run(ctx); err != nil {
M internal/cachesvc/archives_test.go +13 -0
@@ -88,3 +88,16 @@ func TestActionArchiveURLNeedsACache(t *testing.T) {
8888 t.Errorf("url without a cache: %q", u)
8989 }
9090 }
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
44
55 import (
66 "net"
7+ "net/http"
78 "sync"
89 "time"
910 )
@@ -151,6 +152,7 @@ type ServeOption func(*serveConfig)
151152
152153 type serveConfig struct {
153154 bytesPerSec int64
155+ results http.Handler
154156 }
155157
156158 // WithBandwidth limits the channel to mbit Mbit/s, summed over all its
@@ -167,3 +169,10 @@ func WithBandwidth(mbit int) ServeOption {
167169 func withRate(bytesPerSec int64) ServeOption {
168170 return func(c *serveConfig) { c.bytesPerSec = bytesPerSec }
169171 }
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 {
7373 })
7474 }
7575
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+
7699 type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims)
77100
78101 // auth checks the bearer token (ACTIONS_RUNTIME_TOKEN), and when job is not
M internal/cachesvc/service.go +39 -12
@@ -9,9 +9,11 @@
99 // One Service per runner host. Entries live under <state>/cache/ (files plus a
1010 // JSON index, atomic finalisation, LRU eviction by total size) and are scoped by
1111 // (repository, ref, key, version). A job reaches the service with a bearer token
12-// (ACTIONS_RUNTIME_TOKEN) that is an HMAC, made with a key that exists only in
13-// the memory of the runner, over its job id, repository, write ref, read refs
14-// and expiry.
12+// (ACTIONS_RUNTIME_TOKEN): a JWT signed with HS256 under a key that exists only
13+// in the memory of the runner, over its job id, repository, write ref, read
14+// refs, expiry and scopes. The same token serves the results service of the
15+// job (package resultssvc), which the channel of a sandbox carries beside the
16+// cache (WithResults).
1517 //
1618 // The service never listens on a network. Each sandbox has a channel of its own
1719 // (Serve): a Unix socket, bind-mounted into a container or reached through
@@ -26,6 +28,7 @@ import (
2628 "net"
2729 "net/http"
2830 "strconv"
31+ "strings"
2932 "sync"
3033 "time"
3134
@@ -51,9 +54,10 @@ const (
5154 // progress before its connection is cut.
5255 DefaultProgressTimeout = 60 * time.Second
5356 // DefaultConnsPerListener and DefaultConns bound the open connections of one
54- // sandbox's listener and of the whole service.
55- DefaultConnsPerListener = 16
56- DefaultConns = 64
57+ // sandbox's listener and of the whole service. upload-artifact sends 5
58+ // blocks at once by default, besides downloads and the cache.
59+ DefaultConnsPerListener = 32
60+ DefaultConns = 128
5761 )
5862
5963 // Config configures a Service.
@@ -166,21 +170,44 @@ func (s *Service) housekeeping() {
166170 }
167171
168172 // Token returns the ACTIONS_RUNTIME_TOKEN of a job. It is valid for ttl, which
169-// should be the job timeout plus a margin.
170-func (s *Service) Token(jobID string, sc Scope, ttl time.Duration) string {
171- return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: s.now().Add(ttl).Unix()})
173+// should be the job timeout plus a margin. sc is the job's cache scope (the
174+// zero Scope for a job without a cache: the cache then refuses the token);
175+// scopes are added to its scp claim (resultssvc.Scope for a job with the
176+// results service).
177+func (s *Service) Token(jobID string, sc Scope, ttl time.Duration, scopes ...string) string {
178+ now := s.now()
179+ exp := now.Add(ttl).Unix()
180+ return s.sign.token(Claims{JobID: jobID, Scope: sc, Expiry: exp, ExpiresAt: exp, IssuedAt: now.Unix(),
181+ Scopes: strings.Join(append([]string{genericReadScope}, scopes...), " ")})
182+}
183+
184+// RuntimeToken checks a runtime token for the results service (resultssvc.Auth):
185+// made by this runner and not expired, whatever its cache scope. It returns
186+// the job, the scp claim and the expiry.
187+func (s *Service) RuntimeToken(tok string) (jobID, scopes string, expiry time.Time, err error) {
188+ c, err := s.sign.verifyAny(tok, s.now())
189+ if err != nil {
190+ return "", "", time.Time{}, err
191+ }
192+ return c.JobID, c.Scopes, time.Unix(c.Expiry, 0), nil
193+}
194+
195+// BlobMAC signs the blob URLs of the results service (resultssvc.Auth), under
196+// a key domain of their own.
197+func (s *Service) BlobMAC(parts ...[]byte) []byte {
198+ return s.sign.mac("blob", parts...)
172199 }
173200
174201 // start serves HTTP on ln, subject to the connection caps, until the listener
175202 // 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 {
177204 if bw != nil {
178205 ln = throttleListener{ln, bw}
179206 }
180207 ln = newLimitListener(ln, s.perListener, s.connsGlobal)
181208 l := &listener{
182209 srv: &http.Server{
183- Handler: s.handler(job),
210+ Handler: s.channelHandler(job, results),
184211 ReadHeaderTimeout: 30 * time.Second,
185212 // Bodies are chunks of up to tens of MiB and archives of GiB: only
186213 // idle connections time out.
@@ -227,7 +254,7 @@ func (s *Service) Serve(id string, ln net.Listener, job string, opts ...ServeOpt
227254 _ = ln.Close()
228255 return fmt.Errorf("cache: already serving %s", id)
229256 }
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)
231258 return nil
232259 }
233260
M internal/cachesvc/service_test.go +8 -4
@@ -370,15 +370,19 @@ func TestBadTokens(t *testing.T) {
370370 good := r.job("j1", repoA, main_, main_)
371371 other := newRig(t, 1<<20) // another runner: another key
372372 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, ".")
373374 tampered := func() string {
374- p, m, _ := strings.Cut(good.token, ".")
375- payload, _ := b64.DecodeString(p)
375+ payload, _ := b64.DecodeString(seg[1])
376376 payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1)
377- return b64.EncodeToString(payload) + "." + m
377+ return seg[0] + "." + b64.EncodeToString(payload) + "." + seg[2]
378378 }()
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")
379382 for name, tok := range map[string]string{
380383 "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,
382386 } {
383387 j := &job{r: r, token: tok}
384388 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 {
2828 ReadRefs []string `json:"rd,omitempty"`
2929 }
3030
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.
3234 type Claims struct {
3335 JobID string `json:"j"`
3436 Scope
3537 // Expiry is a Unix time in seconds.
3638 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"`
3747 }
3848
49+// genericReadScope is the first scope of GitHub's runtime tokens.
50+const genericReadScope = "Actions.GenericRead:00000000-0000-0000-0000-000000000000"
51+
3952 // errForbidden is every authentication failure: the caller learns nothing about
4053 // why a token was refused.
4154 var errForbidden = errors.New("cache: forbidden")
@@ -71,29 +84,58 @@ var b64 = base64.RawURLEncoding
7184 // maxTokenLen bounds what verify parses.
7285 const maxTokenLen = 16 << 10
7386
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.
7598 func (s *signer) token(c Claims) string {
7699 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))
78104 }
79105
80-// verify returns the claims of a token that this runner made and that has not
81-// expired. The comparison is constant time.
82-func (s *signer) verify(tok string, now time.Time) (Claims, error) {
106+// verifyAny returns the claims of a token that this runner made and that has
107+// not expired, whatever its scopes. The comparison is constant time.
108+func (s *signer) verifyAny(tok string, now time.Time) (Claims, error) {
83109 var c Claims
84110 if len(tok) > maxTokenLen {
85111 return c, errForbidden
86112 }
87- p, m, ok := strings.Cut(tok, ".")
88- if !ok {
113+ head, rest, ok1 := strings.Cut(tok, ".")
114+ p, m, ok2 := strings.Cut(rest, ".")
115+ if !ok1 || !ok2 || head != jwtHeader {
116+ return c, errForbidden
117+ }
118+ sum, err := b64.DecodeString(m)
119+ if err != nil {
89120 return c, errForbidden
90121 }
91- payload, err1 := b64.DecodeString(p)
92- sum, err2 := b64.DecodeString(m)
93- if err1 != nil || err2 != nil || !hmac.Equal(sum, s.mac("token", payload)) {
122+ h := hmac.New(sha256.New, s.jwtKey())
123+ h.Write([]byte(head + "." + p))
124+ if !hmac.Equal(sum, h.Sum(nil)) {
94125 return c, errForbidden
95126 }
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 == "" {
97139 return Claims{}, errForbidden
98140 }
99141 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 {
9595 // ACTIONS_CACHE_URL=http://127.0.0.1:<port>/ and the token as
9696 // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official
9797 // runner does, and masks the token. The token is not an ermite credential: it
98- // is an HMAC that only the runner's cache service accepts, scoped to this
99- // job's repository and refs.
98+ // is a JWT (HS256) that only the runner's runtime services accept, scoped to
99+ // this job's repository and refs (and its run, see results).
100100 uint32 cache_port = 15;
101101 string cache_token = 16;
102102 // Job.secrets and Job.vars are not here: they come in NamedValue frames
@@ -117,8 +117,17 @@ message JobSpec {
117117 TokenPermissions token_permissions = 20;
118118 // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"
119119 // 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.
121122 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;
122131 }
123132
124133 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
M internal/execstream/execpb/exec.pb.go +23 -6
@@ -749,8 +749,8 @@ type JobSpec struct {
749749 // ACTIONS_CACHE_URL=http://127.0.0.1:<port>/ and the token as
750750 // ACTIONS_RUNTIME_TOKEN to node and docker actions only, as the official
751751 // runner does, and masks the token. The token is not an ermite credential: it
752- // is an HMAC that only the runner's cache service accepts, scoped to this
753- // job's repository and refs.
752+ // is a JWT (HS256) that only the runner's runtime services accept, scoped to
753+ // this job's repository and refs (and its run, see results).
754754 CachePort uint32 `protobuf:"varint,15,opt,name=cache_port,json=cachePort,proto3" json:"cache_port,omitempty"`
755755 CacheToken string `protobuf:"bytes,16,opt,name=cache_token,json=cacheToken,proto3" json:"cache_token,omitempty"`
756756 // secrets_withheld is set by the host when the server sends Job.trusted and
@@ -766,8 +766,17 @@ type JobSpec struct {
766766 TokenPermissions *TokenPermissions `protobuf:"bytes,20,opt,name=token_permissions,json=tokenPermissions,proto3" json:"token_permissions,omitempty"`
767767 // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"
768768 // when the job's cache scope has a write ref, "read" when it has none. Unset
769- // without a cache service.
770- CacheMode string `protobuf:"bytes,21,opt,name=cache_mode,json=cacheMode,proto3" json:"cache_mode,omitempty"`
769+ // without a cache service, and unset when the channel serves the results
770+ // service only (results set, no cache scope): no ACTIONS_CACHE_URL then.
771+ CacheMode string `protobuf:"bytes,21,opt,name=cache_mode,json=cacheMode,proto3" json:"cache_mode,omitempty"`
772+ // results says that the service behind cache_port also serves artifacts
773+ // (Twirp github.actions.results.api.v1.ArtifactService and its blobs) and
774+ // that cache_token carries the job's Actions.Results scope. The executor
775+ // then gives node and docker actions ACTIONS_RESULTS_URL (the same origin as
776+ // ACTIONS_CACHE_URL) and sets GITHUB_SERVER_URL to a *.ghe.localhost host in
777+ // the process of the node steps of the artifact actions only (README,
778+ // "Artifacts"). Set only when the server has the feature ci.artifacts.
779+ Results bool `protobuf:"varint,22,opt,name=results,proto3" json:"results,omitempty"`
771780 unknownFields protoimpl.UnknownFields
772781 sizeCache protoimpl.SizeCache
773782 }
@@ -935,6 +944,13 @@ func (x *JobSpec) GetCacheMode() string {
935944 return ""
936945 }
937946
947+func (x *JobSpec) GetResults() bool {
948+ if x != nil {
949+ return x.Results
950+ }
951+ return false
952+}
953+
938954 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
939955 // "read", "write" or "none"; a key that is absent is none.
940956 type TokenPermissions struct {
@@ -1916,7 +1932,7 @@ const file_exec_proto_rawDesc = "" +
19161932 "\x03job\x18\x03 \x01(\v2\x1e.ermite.runner.exec.v1.JobSpecR\x03job\x1a6\n" +
19171933 "\bEnvEntry\x12\x10\n" +
19181934 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1919- "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd8\x05\n" +
1935+ "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x05\n" +
19201936 "\aJobSpec\x12#\n" +
19211937 "\rworkflow_path\x18\x01 \x01(\tR\fworkflowPath\x12#\n" +
19221938 "\rworkflow_yaml\x18\x02 \x01(\tR\fworkflowYaml\x12\x17\n" +
@@ -1942,7 +1958,8 @@ const file_exec_proto_rawDesc = "" +
19421958 "\x10secrets_withheld\x18\x13 \x01(\bR\x0fsecretsWithheld\x12T\n" +
19431959 "\x11token_permissions\x18\x14 \x01(\v2'.ermite.runner.exec.v1.TokenPermissionsR\x10tokenPermissions\x12\x1d\n" +
19441960 "\n" +
1945- "cache_mode\x18\x15 \x01(\tR\tcacheModeJ\x04\b\x11\x10\x12J\x04\b\x12\x10\x13R\asecretsR\x04vars\"\x9a\x01\n" +
1961+ "cache_mode\x18\x15 \x01(\tR\tcacheMode\x12\x18\n" +
1962+ "\aresults\x18\x16 \x01(\bR\aresultsJ\x04\b\x11\x10\x12J\x04\b\x12\x10\x13R\asecretsR\x04vars\"\x9a\x01\n" +
19461963 "\x10TokenPermissions\x12K\n" +
19471964 "\x06levels\x18\x01 \x03(\v23.ermite.runner.exec.v1.TokenPermissions.LevelsEntryR\x06levels\x1a9\n" +
19481965 "\vLevelsEntry\x12\x10\n" +
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 (
1212 cacheTestToken = "eyJqIjoiMSJ9.c2lnbmF0dXJl"
1313 )
1414
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+}
1622
1723 const envAction = `name: e
1824 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,
104104 v, _ := in.Get(k)
105105 env.Set(inputEnvName(k), v)
106106 }
107- j.addRuntimeEnv(env)
107+ // A docker action always runs in a container of its own.
108+ j.addRuntimeEnv(env, true)
108109 j.mu.Lock()
109110 workspace := j.state.GitHub("workspace")
110111 temp := j.state.Layout().Temp
M internal/executor/actions_docker_test.go +1 -0
@@ -29,6 +29,7 @@ pull)
2929 build) echo "built" ;;
3030 run)
3131 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"
3233 echo "::set-output name=x::y" ;;
3334 esac
3435 exit 0
M internal/executor/actions_node.go +37 -18
@@ -152,26 +152,44 @@ func (j *job) externalsDir() string {
152152 }
153153
154154 // addRuntimeEnv is the "Add Actions Runtime server info" part of the handlers
155-// (NodeScriptActionHandler.cs:55-72, ContainerActionHandler.cs:219-236): the
156-// variables @actions/cache reads. ermite has no Actions runtime service, only
157-// the cache of the runner, so only ACTIONS_CACHE_URL (the base of
158-// "_apis/artifactcache/…": the toolkit appends to it, cacheHttpClient.ts:47, so
159-// it ends with a slash), ACTIONS_RUNTIME_URL (same value, for clients that
160-// require the variable) and ACTIONS_RUNTIME_TOKEN are set. ACTIONS_CACHE_SERVICE_V2
161-// is never set: the toolkit would then ignore ACTIONS_CACHE_URL (config.ts:42-58)
162-// and, on a server URL that is not github.com, uses v1 anyway (config.ts:22-27).
163-// Without a cache service in the job nothing is set.
164-func (j *job) addRuntimeEnv(env *ordered) {
165- if j.cfg.CacheURL == "" || j.cfg.CacheToken == "" {
155+// (NodeScriptActionHandler.cs:55-84, ContainerActionHandler.cs:219-245): the
156+// variables @actions/cache and @actions/artifact read. ermite's runtime service
157+// is the channel of the runner behind one URL, which serves the cache, the
158+// results service (artifacts) or both: ACTIONS_RUNTIME_URL and
159+// ACTIONS_RUNTIME_TOKEN, then, when the channel serves the cache,
160+// ACTIONS_CACHE_URL (the base of "_apis/artifactcache/…": the toolkit appends
161+// to it, cacheHttpClient.ts:47, so it ends with a slash) and ACTIONS_CACHE_MODE,
162+// and, when it serves results, ACTIONS_RESULTS_URL (the handlers set it from
163+// ResultsServiceUrl, NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
164+// 237-245; the toolkit keeps only its origin, artifact shared/config.ts).
165+// ACTIONS_CACHE_SERVICE_V2 is never set: the toolkit would then ignore
166+// ACTIONS_CACHE_URL (config.ts:42-58) and, on a server URL that is not
167+// github.com, uses v1 anyway (config.ts:22-27).
168+//
169+// inContainer says the process runs in a container (a step in the job
170+// container, a docker action): it has its own network namespace and reaches
171+// the relay at ContainerCacheURL, not on the loopback. Without a URL for where
172+// the step runs, or without a token, nothing is set.
173+func (j *job) addRuntimeEnv(env *ordered, inContainer bool) {
174+ url := j.cfg.CacheURL
175+ if inContainer {
176+ url = j.cfg.ContainerCacheURL
177+ }
178+ if url == "" || j.cfg.CacheToken == "" {
166179 return
167180 }
168- env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)
181+ env.Set("ACTIONS_RUNTIME_URL", url)
169182 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
170- env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)
171- // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
172- // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
173- if j.cfg.CacheMode != "" {
174- env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)
183+ if !j.cfg.CacheOff {
184+ env.Set("ACTIONS_CACHE_URL", url)
185+ // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
186+ // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
187+ if j.cfg.CacheMode != "" {
188+ env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)
189+ }
190+ }
191+ if j.cfg.Results {
192+ env.Set("ACTIONS_RESULTS_URL", url)
175193 }
176194 }
177195
@@ -185,7 +203,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
185203 v, _ := in.Get(k)
186204 env.Set(inputEnvName(k), v)
187205 }
188- j.addRuntimeEnv(env)
206+ j.addRuntimeEnv(env, j.stepContainer() != nil)
189207 // The PATH of the previous steps is added by procEnv.
190208 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
191209 r := def.runs()
@@ -206,6 +224,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
206224 if st, err := os.Stat(script); err != nil || st.IsDir() {
207225 return fail(ghErrorf("File not found: '%s'", script))
208226 }
227+ j.setArtifactsServerURL(s, script)
209228 if c := j.stepContainer(); c != nil {
210229 return j.runNodeInContainer(s, c, script, env, cancel)
211230 }
M internal/executor/containers_fake_test.go +2 -0
@@ -17,6 +17,7 @@ import (
1717 // health words, one per inspect: starting, healthy, unhealthy
1818 // create_fail docker create exits 125
1919 // exec_out what docker exec prints
20+// exec_vars names of variables docker exec prints ("VAR NAME=value")
2021 // hang_exec docker exec waits until it gets a signal
2122 type fakeCtr struct {
2223 dir, bin, log string
@@ -61,6 +62,7 @@ rm) [ -f $D/rm_fail ] && { echo "Error response from daemon: cannot remove conta
6162 echo "$3" ;;
6263 exec) if [ -f $D/add_path ]; then f=$(echo "$GITHUB_PATH" | sed "s#^/__w#$(cat $D/add_path)#"); echo /added >> "$f"; fi
6364 [ -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
6466 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
6567 [ -f $D/exec_out ] && cat $D/exec_out
6668 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
8787 wd := j.state.GitHub("workspace")
8888 j.mu.Unlock()
8989 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+ }
9094 if err := checkEnvNames(henv.Keys()); err != nil {
9195 return fail(err)
9296 }
M internal/executor/job.go +17 -0
@@ -81,6 +81,20 @@ type Config struct {
8181 // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL;
8282 // empty sets nothing.
8383 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
8498 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in
8599 // the masker before the first step and form the secrets context; they are
86100 // never logged. Vars are the fallback of the vars context.
@@ -199,6 +213,9 @@ type job struct {
199213 summaries map[int]string
200214 logLines map[int]int // lines written to the log of each step, under mu
201215 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
202219 }
203220
204221 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 {
5050 // def is the action the step runs.
5151 def *actionDef
5252 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
5356 // scope is what the step shares with the step that contains it, see
5457 // stepScope. name is the display name of the step in the log; timedOut is set when the step
5558 // reached its timeout-minutes.
@@ -76,10 +79,29 @@ func (s *stepRun) procEnv() []string {
7679 if s.rawEnvList != nil {
7780 return s.rawEnvList
7881 }
82+ env := s.env
7983 if s.actEnv != nil {
80- return s.j.processEnv(s.actEnv)
84+ env = s.actEnv
8185 }
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)
83105 }
84106
85107 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
44
55 import (
66 "context"
7+ "errors"
78 "io"
89 "net"
910 "net/http"
1011 "path/filepath"
12+ "strconv"
1113 "testing"
1214 "time"
1315
@@ -30,6 +32,40 @@ func TestCacheURL(t *testing.T) {
3032 }
3133 }
3234
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+
3369 // freePort returns a loopback port nobody listens on.
3470 func freePort(t *testing.T) uint32 {
3571 t.Helper()
@@ -59,8 +95,9 @@ func TestStartCacheRelay(t *testing.T) {
5995
6096 port := freePort(t)
6197 spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"}
62- got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec)
63- if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) {
98+ got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec, noBridge)
99+ if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) ||
100+ got.bridge != nil || got.containerURL != "" || got.results || got.cacheOff {
64101 t.Fatalf("relay %+v", got)
65102 }
66103
@@ -89,7 +126,7 @@ func TestStartCacheRelay(t *testing.T) {
89126 func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) {
90127 target := Target{Network: "unix", Addr: filepath.Join(t.TempDir(), "exec.sock")}
91128 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{}) {
93130 t.Errorf("%+v: relay %+v", spec, got)
94131 }
95132 }
@@ -99,7 +136,82 @@ func TestStartCacheRelayWithoutServiceOrPort(t *testing.T) {
99136 }
100137 defer func() { _ = busy.Close() }()
101138 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{}) {
103140 t.Errorf("taken port: relay %+v", got)
104141 }
105142 }
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
214214 canceller := executor.NewCanceller()
215215 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
216216 res := newResolver(conn)
217- cacheEnv := startCacheRelay(ctx, cfg.Target, spec)
217+ cacheEnv := startCacheRelay(ctx, cfg.Target, spec, docker0IPv4)
218218 defer cacheEnv.close()
219219
220220 // 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
260260 RunnerVersion: spec.GetRunnerVersion(),
261261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
262262 CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode,
263+ ContainerCacheURL: cacheEnv.containerURL, Results: cacheEnv.results, CacheOff: cacheEnv.cacheOff,
263264 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
264265 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
265266 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
@@ -276,44 +277,111 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
276277 // the toolkit appends "_apis/…" to it (cacheHttpClient.ts:47). Empty when the
277278 // job has no cache service.
278279 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 {
279286 if port == 0 || port > 65535 {
280287 return ""
281288 }
282- return "http://" + net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(port), 10)) + "/"
289+ return "http://" + net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) + "/"
283290 }
284291
285-// cacheRelay is the cache of a job in the sandbox: the relay, the URL of the
286-// loopback it listens on and the token of the job. All are empty without a cache
287-// service.
292+// BridgeInterface is the default bridge of the sandbox's docker daemon. Its
293+// address is a local address of the sandbox that every container of that
294+// daemon reaches, whatever its bridge network: the traffic to it goes through
295+// INPUT, which Docker does not filter (what host-gateway relies on).
296+const BridgeInterface = "docker0"
297+
298+// bridgeLookup returns the IPv4 address the relay listens on for the steps
299+// that run in a container; an error means there is none.
300+type bridgeLookup func() (net.IP, error)
301+
302+// docker0IPv4 is the first IPv4 address of BridgeInterface, read when the job
303+// starts: the nested daemon picks it, and init starts the executor once the
304+// daemon answers (init.go), so the bridge exists by then when there is one.
305+func docker0IPv4() (net.IP, error) {
306+ ifi, err := net.InterfaceByName(BridgeInterface)
307+ if err != nil {
308+ return nil, err
309+ }
310+ addrs, err := ifi.Addrs()
311+ if err != nil {
312+ return nil, err
313+ }
314+ for _, a := range addrs {
315+ if n, ok := a.(*net.IPNet); ok {
316+ if ip4 := n.IP.To4(); ip4 != nil {
317+ return ip4, nil
318+ }
319+ }
320+ }
321+ return nil, fmt.Errorf("%s has no IPv4 address", BridgeInterface)
322+}
323+
324+// cacheRelay is the runtime service of a job in the sandbox (the cache, the
325+// results service or both, on one channel): the relays, the URLs they listen
326+// on and the token of the job. All are empty without a service; containerURL
327+// and bridge are empty when the steps that run in a container cannot reach it.
288328 type cacheRelay struct {
289- relay *Relay
290- url, token, mode string
329+ relay, bridge *Relay
330+ url, containerURL string
331+ token, mode string
332+ results, cacheOff bool
291333 }
292334
293335 func (c cacheRelay) close() {
294336 if c.relay != nil {
295337 _ = c.relay.Close()
296338 }
339+ if c.bridge != nil {
340+ _ = c.bridge.Close()
341+ }
297342 }
298343
299-// startCacheRelay starts the relay of the cache for a job that has a cache
300-// service: it listens on the loopback port the host says and pipes every
344+// startCacheRelay starts the relay of the runtime service for a job that has
345+// one: it listens on the loopback port the host says and pipes every
301346 // connection to a new stream on the cache channel of the host (a sibling of
302-// the executor's target). A relay that cannot start leaves the job without a
303-// cache, like a scope the host refuses: the job runs.
304-func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) cacheRelay {
305- url := cacheURL(spec.GetCachePort())
347+// the executor's target). A relay that cannot start leaves the job without the
348+// service, like a scope the host refuses: the job runs. A second relay listens
349+// on the same port at the address bridge returns, for the job container and
350+// the docker actions, whose loopback is their own; without it those steps get
351+// no runtime variables.
352+func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec, bridge bridgeLookup) cacheRelay {
353+ port := spec.GetCachePort()
354+ url := cacheURL(port)
306355 if url == "" || spec.GetCacheToken() == "" {
307356 return cacheRelay{}
308357 }
309- addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(spec.GetCachePort()), 10))
310- r, err := StartRelay(ctx, addr, target.CacheTarget().DialStream, DefaultRelayMax)
358+ log := zerowrap.FromCtx(ctx)
359+ dial := target.CacheTarget().DialStream
360+ addr := net.JoinHostPort(CacheListenHost, strconv.FormatUint(uint64(port), 10))
361+ r, err := StartRelay(ctx, addr, dial, DefaultRelayMax)
311362 if err != nil {
312- log := zerowrap.FromCtx(ctx)
313363 log.Warn().Err(err).Msg("job runs without the cache")
314364 return cacheRelay{}
315365 }
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
317385 }
318386
319387 // 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 (
1414
1515 // The cache service of the runner never listens on a network. In the sandbox it
1616 // is reached on the guest's own loopback (CacheListenHost, on the port the
17-// host gives in JobSpec.cache_port), where a Relay accepts the TCP connections
18-// of the actions and pipes each of them to a new stream on the host↔guest
19-// channel (a vsock port for a VM, a Unix socket for a container); the host
20-// serves HTTP on the other end of that stream.
17+// host gives in JobSpec.cache_port), and on the same port at the address of
18+// the docker bridge for the steps that run in a container (BridgeInterface),
19+// where a Relay accepts the TCP connections of the actions and pipes each of
20+// them to a new stream on the host↔guest channel (a vsock port for a VM, a
21+// Unix socket for a container); the host serves HTTP on the other end of that
22+// stream.
2123 const (
2224 // CacheListenHost is the loopback address of the relay.
2325 CacheListenHost = "127.0.0.1"
@@ -40,7 +42,7 @@ const (
4042 // DialFunc opens one new stream to the host.
4143 type DialFunc func(ctx context.Context) (io.ReadWriteCloser, error)
4244
43-// Relay accepts TCP connections on a loopback address and pipes each one to a
45+// Relay accepts TCP connections on a local address and pipes each one to a
4446 // new stream from its DialFunc, in both directions, until Close.
4547 type Relay struct {
4648 ln net.Listener
M internal/host/cache.go +42 -10
@@ -5,12 +5,14 @@ package host
55 import (
66 "encoding/json"
77 "fmt"
8+ "net/http"
89 "strings"
910 "time"
1011
1112 ermite "bnema.dev/ermite-api/gen/ermite"
1213
1314 "bnema.dev/ermite-runner/internal/cachesvc"
15+ "bnema.dev/ermite-runner/internal/resultssvc"
1416 )
1517
1618 // cacheTokenMargin is added to the job timeout for the life of the cache token:
@@ -106,26 +108,56 @@ func githubRepository(contexts string) string {
106108 return c.Github.Repository
107109 }
108110
109-// cacheFor returns the loopback port the cache has in the guest and the token
110-// of a job, or 0 and "" when the runner serves no cache or the job has no scope.
111-func (j *jobRun) cacheFor(rt *Runner) (port uint32, token, mode string) {
111+// runtimeFor returns what the executor needs of the job's runtime channel: the
112+// port it has in the guest, the job's ACTIONS_RUNTIME_TOKEN, the cache mode and
113+// whether the channel serves the results service. The channel exists when the
114+// runner serves a cache and the job has a cache scope or the results service;
115+// without it, 0, "", "" and false. A job with the results service and no cache
116+// scope gets an empty mode (the executor then sets no ACTIONS_CACHE_URL) and a
117+// token that the cache refuses.
118+func (j *jobRun) runtimeFor(rt *Runner) (port uint32, token, mode string, results bool) {
112119 if rt.Cache == nil {
113- return 0, "", ""
120+ return 0, "", "", false
114121 }
115122 sc, ok, err := cacheScope(j.job, j.contexts)
116123 if err != nil {
117124 // A bad scope from the server is not the job's failure: it runs without a
118125 // cache.
119126 j.log.Warn().Err(err).Msg("cache disabled for the job")
120- return 0, "", ""
127+ ok = false
121128 }
122129 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))
124143 }
125144 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126- mode = "read"
127- if sc.WriteRef != "" {
128- mode = "write"
145+ return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl, scopes...), mode, j.results
146+}
147+
148+// resultsOn says whether the job gets the results service: the runner has one
149+// (ERMITE_RUNNER_ARTIFACTS is not 0) with a cache channel to serve it on, and
150+// the server has the feature ci.artifacts. Without it artifact actions fail as
151+// on a GitHub Enterprise Server without artifacts v4.
152+func (j *jobRun) resultsOn(rt *Runner) bool {
153+ return rt.Results != nil && rt.Cache != nil && rt.Artifacts != nil && j.features.artifacts
154+}
155+
156+// resultsHandler is the results service of the job's channel, nil without one.
157+// It calls ermite with the job token.
158+func (j *jobRun) resultsHandler(rt *Runner) http.Handler {
159+ if !j.results {
160+ return nil
129161 }
130- return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl), mode
162+ return rt.Results.Handler(resultssvc.Job{ID: j.job.JobId, RunID: j.job.RunId, Attempt: j.job.Attempt, Client: rt.Artifacts(j.job.JobToken)})
131163 }
M internal/host/cache_test.go +86 -10
@@ -13,8 +13,10 @@ import (
1313 "github.com/bnema/zerowrap"
1414
1515 ermite "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1617
1718 "bnema.dev/ermite-runner/internal/cachesvc"
19+ "bnema.dev/ermite-runner/internal/resultssvc"
1820 )
1921
2022 const scopeCtx = `{"github":{"repository":"o/r"}}`
@@ -123,7 +125,7 @@ func TestCacheScopeFallback(t *testing.T) {
123125
124126 // A mismatched scope disables the cache of the job, with a log line; the job
125127 // goes on.
126-func TestCacheForMismatchDisablesCache(t *testing.T) {
128+func TestRuntimeForMismatchDisablesCache(t *testing.T) {
127129 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
128130 if err != nil {
129131 t.Fatal(err)
@@ -134,7 +136,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
134136 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
135137 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),
136138 }
137- if port, tok, _ := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" {
139+ if port, tok, _, _ := j.runtimeFor(&Runner{Cache: svc}); port != 0 || tok != "" {
138140 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
139141 }
140142 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) {
142144 }
143145 }
144146
145-func TestCacheForNeedsServiceAndScope(t *testing.T) {
147+func TestRuntimeForNeedsServiceAndScope(t *testing.T) {
146148 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 != "" {
148150 t.Fatalf("no service: %d %q", port, tok)
149151 }
150152 }
151153
152154 // The token of a job is the one the service accepts, for the job's repository
153155 // and ref only.
154-func TestCacheForToken(t *testing.T) {
156+func TestRuntimeForToken(t *testing.T) {
155157 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
156158 if err != nil {
157159 t.Fatal(err)
@@ -161,10 +163,10 @@ func TestCacheForToken(t *testing.T) {
161163 defer srv.Close()
162164 rt := &Runner{Cache: svc}
163165 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)
165167 // The port is the cache's loopback port in the guest, not a host port.
166- if port != 9089 || tok == "" {
167- t.Fatalf("%d %q", port, tok)
168+ if port != 9089 || tok == "" || results {
169+ t.Fatalf("%d %q %v", port, tok, results)
168170 }
169171 // No Job.cache: the narrowest scope writes the job's own ref.
170172 if mode != "write" {
@@ -193,7 +195,7 @@ func TestCacheForToken(t *testing.T) {
193195
194196 // ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job
195197 // read-only.
196-func TestCacheForMode(t *testing.T) {
198+func TestRuntimeForMode(t *testing.T) {
197199 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
198200 if err != nil {
199201 t.Fatal(err)
@@ -204,8 +206,82 @@ func TestCacheForMode(t *testing.T) {
204206 job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},
205207 contexts: `{"github":{"repository":"o/r"}}`,
206208 }
207- if _, _, mode := j.cacheFor(&Runner{Cache: svc}); mode != tc.want {
209+ if _, _, mode, _ := j.runtimeFor(&Runner{Cache: svc}); mode != tc.want {
208210 t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want)
209211 }
210212 }
211213 }
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 {
7777 return NewClient(url, token, http.DefaultTransport)
7878 }
7979
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+
8085 // environment is Environment, the default when a Config was built without
8186 // LoadConfig.
8287 func (c Config) environment() string {
M internal/host/job.go +5 -2
@@ -79,6 +79,8 @@ type jobRun struct {
7979 // features is what the server accepts; annLists are the annotations the
8080 // executor sent for steps that have not completed yet (see results.go).
8181 features jobFeatures
82+ // results says the job gets the results service (see resultsOn).
83+ results bool
8284 annLists map[int32][]*ermite.Annotation
8385 // annSeen and sumSeen are the steps that sent their annotations and their
8486 // summary: each is sent once.
@@ -623,6 +625,7 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct
623625 // Stop runs to completion even when the job context is done.
624626 defer j.stopSandbox(ctx, rt, sb)
625627 j.features = j.loadFeatures(ctx, rt)
628+ j.results = j.resultsOn(rt)
626629
627630 conn, err := j.startSandbox(ctx, rt, sb, stopHard)
628631 if err != nil {
@@ -679,7 +682,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo
679682 case <-stopWatch:
680683 }
681684 }()
682- return sb.Start(startCtx, rt.spec(j.job))
685+ return sb.Start(startCtx, rt.spec(j.job, j.resultsHandler(rt)))
683686 }
684687
685688 // 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) {
700703 if p := j.job.Permissions; p != nil && j.features.secrets {
701704 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
702705 }
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)
704707 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
705708 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
706709 Log: j.logLine,
M internal/host/results.go +7 -2
@@ -23,6 +23,9 @@ const (
2323 // FeatureSecrets: the server sends Job.secrets, vars, environment,
2424 // permissions and trusted. Without it Job.trusted is absent, not false.
2525 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"
2629 )
2730
2831 // Limits of Annotation and UploadSummaryRequest (runner.proto). The executor
@@ -42,7 +45,7 @@ const (
4245 type ServerFeatures func(ctx context.Context) ([]string, error)
4346
4447 // 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 }
4649
4750 func featuresOf(list []string) jobFeatures {
4851 var f jobFeatures
@@ -54,6 +57,8 @@ func featuresOf(list []string) jobFeatures {
5457 f.summaries = true
5558 case FeatureSecrets:
5659 f.secrets = true
60+ case FeatureArtifacts:
61+ f.artifacts = true
5762 }
5863 }
5964 return f
@@ -68,7 +73,7 @@ func (j *jobRun) loadFeatures(ctx context.Context, rt *Runner) jobFeatures {
6873 defer cancel()
6974 list, err := rt.Features(cctx)
7075 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")
7277 return jobFeatures{}
7378 }
7479 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
106106 return ermiteconnect.NewRunnerServiceClient(hc, strings.TrimRight(baseURL, "/")+APIPath)
107107 }
108108
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+
109120 // ServerFeaturesFunc returns the function that reads the features of the
110121 // server with GetVersion, which needs no authentication. It is called once
111122 // per job.
M internal/host/runner.go +13 -2
@@ -23,6 +23,7 @@ import (
2323 "bnema.dev/ermite-runner/internal/actionscache"
2424 "bnema.dev/ermite-runner/internal/cachesvc"
2525 "bnema.dev/ermite-runner/internal/execstream/execpb"
26+ "bnema.dev/ermite-runner/internal/resultssvc"
2627 "bnema.dev/ermite-runner/internal/sandbox"
2728 )
2829
@@ -50,6 +51,14 @@ type Runner struct {
5051 // means the default transport.
5152 Archives *actionscache.Cache
5253 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
5362
5463 // now returns the current time; tests replace it.
5564 now func() time.Time
@@ -70,8 +79,10 @@ func (r *Runner) clock() time.Time {
7079 // (sandbox.Config.Resolve in Start), that is from ERMITE_RUNNER_JOB_CPUS,
7180 // _JOB_MEMORY and _JOB_DISK, so that the limits are one setting of the runner
7281 // and not something a job or this package can change.
73-func (r *Runner) spec(j *ermite.Job) sandbox.JobSpec {
74- return sandbox.JobSpec{JobID: j.JobId}
82+//
83+// results is the job's results service, nil without one.
84+func (r *Runner) spec(j *ermite.Job, results http.Handler) sandbox.JobSpec {
85+ return sandbox.JobSpec{JobID: j.JobId, Results: results}
7586 }
7687
7788 // 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 (
1414 // loopback: through the sandbox's own bind mount (container) or through
1515 // Firecracker's vsock proxy (VM, which connects to <uds>_<port> when the guest
1616 // connects to the host on that port). Nothing but that sandbox can reach the
17-// path, and the service accepts on it only tokens made for job (see
18-// cachesvc.Service.Serve). own, when not nil, sets the owner and mode of the
17+// path, and the service accepts on it only tokens made for spec.JobID (see
18+// cachesvc.Service.Serve). spec.Results, when set, is served on the same
19+// channel beside the cache (cachesvc.WithResults). own, when not nil, sets the owner and mode of the
1920 // socket before it is served. mbit caps the channel's traffic, all its
2021 // connections and both directions together (0: unlimited); a VM passes its
2122 // NetMbit, as the cache stream does not cross eth0, which carries that limit.
2223 // 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 {
2425 if c.Cache == nil {
2526 return nil
2627 }
@@ -34,7 +35,11 @@ func (c Config) ServeCache(id, path, job string, mbit int, own func(path string)
3435 return err
3536 }
3637 }
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...)
3843 }
3944
4045 // 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 {
427427 // connect, as for the executor socket.
428428 // No bandwidth limit (0): a container has no per-job network rate limit
429429 // (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 {
431431 return fmt.Errorf("serve the cache socket: %w", err)
432432 }
433433 s.cacheOn = true
M internal/sandbox/sandbox.go +5 -0
@@ -11,6 +11,7 @@ import (
1111 "context"
1212 "errors"
1313 "io"
14+ "net/http"
1415 )
1516
1617 // Mode is an isolation mode.
@@ -59,6 +60,10 @@ type JobSpec struct {
5960 CPUs int
6061 MemoryMiB int
6162 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
6267 }
6368
6469 // 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 {
457457 if s.d.cfg.Cache == nil {
458458 return nil
459459 }
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 {
461461 if s.lay.jailed {
462462 return os.Lchown(p, s.lay.uid, s.lay.uid)
463463 }

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