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.
21package resultssvc
22
23import (
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.
40const (
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).
48type 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.
59type 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.
68type Service struct {
69 auth Auth
70 now func() time.Time
71 log zerowrap.Logger
72}
73
74// New returns a Service.
75func 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.
83type 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.
96func (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).
113func 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.
120func Scope(runID int64, jobID string, attempt int32) string {
121 return "Actions.Results:" + strconv.FormatInt(runID, 10) + ":" + JobBackendID(jobID, attempt)
122}