1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "strings"
10 "time"
11
12 ermite "bnema.dev/ermite-api/gen/ermite"
13
14 "bnema.dev/ermite-runner/internal/cachesvc"
15 "bnema.dev/ermite-runner/internal/resultssvc"
16)
17
18// cacheTokenMargin is added to the job timeout for the life of the cache token:
19// the archive URLs of a lookup expire with it.
20const cacheTokenMargin = 15 * time.Minute
21
22// maxCacheReadRefs bounds Job.cache.read_refs (own ref, base branch, default
23// branch, with room to spare).
24const maxCacheReadRefs = 8
25
26// cacheScope is what the job may do in the cache of the runner.
27//
28// The server says (Job.cache, feature "ci.cache"): the repository, the one ref
29// the job may write and the refs it may read, most specific first. It is used
30// as given once checked: the repository must be the job's own (the
31// "owner/name" of the github context, or its name alone, which is how
32// Job.cache documents it) and the scope always carries the canonical
33// "owner/name", a ref must be a full "refs/..." name, empty refs
34// are dropped, and there are at most maxCacheReadRefs read refs. An empty
35// write_ref makes the job read-only. A scope that fails the checks is an error:
36// the job runs without a cache.
37//
38// A server that sends no Job.cache (an old server) gets the narrowest scope the
39// job's own fields give: the repository of the github context and the ref that
40// triggered the run, for reading and writing, with no fallback to the base or
41// default branch. A job without a repository or a ref has no cache (ok false,
42// no error).
43func cacheScope(job *ermite.Job, contexts string) (sc cachesvc.Scope, ok bool, err error) {
44 repo := githubRepository(contexts)
45 jc := job.GetCache()
46 if jc == nil {
47 if repo == "" || job.Ref == "" {
48 return cachesvc.Scope{}, false, nil
49 }
50 return cachesvc.Scope{Repository: repo, WriteRef: job.Ref, ReadRefs: []string{job.Ref}}, true, nil
51 }
52 name := repo[strings.LastIndex(repo, "/")+1:]
53 if jc.Repository == "" || repo == "" || (jc.Repository != repo && jc.Repository != name) {
54 return cachesvc.Scope{}, false, fmt.Errorf("the cache repository %q is not the job's repository %q", jc.Repository, repo)
55 }
56 // Always the job's canonical "owner/name", whichever way the server spelled
57 // it: entries of two repositories with the same name under different owners
58 // must never share a scope.
59 sc.Repository = repo
60 if w := jc.WriteRef; w != "" {
61 if !fullRef(w) {
62 return cachesvc.Scope{}, false, fmt.Errorf("the cache write ref %q is not a full ref name", w)
63 }
64 sc.WriteRef = w
65 }
66 for _, r := range jc.ReadRefs {
67 if r == "" {
68 continue
69 }
70 if !fullRef(r) {
71 return cachesvc.Scope{}, false, fmt.Errorf("the cache read ref %q is not a full ref name", r)
72 }
73 sc.ReadRefs = append(sc.ReadRefs, r)
74 }
75 if len(sc.ReadRefs) > maxCacheReadRefs {
76 return cachesvc.Scope{}, false, fmt.Errorf("%d cache read refs, at most %d", len(sc.ReadRefs), maxCacheReadRefs)
77 }
78 if sc.WriteRef == "" && len(sc.ReadRefs) == 0 {
79 return cachesvc.Scope{}, false, nil // allowed to do nothing
80 }
81 return sc, true, nil
82}
83
84// fullRef reports whether s is a full ref name: "refs/" and something after it,
85// without control characters or spaces.
86func fullRef(s string) bool {
87 if !strings.HasPrefix(s, "refs/") || len(s) == len("refs/") || len(s) > 1024 {
88 return false
89 }
90 for _, r := range s {
91 if r <= ' ' || r == 0x7f {
92 return false
93 }
94 }
95 return true
96}
97
98// githubRepository is github.repository ("owner/name") of the contexts JSON.
99func githubRepository(contexts string) string {
100 var c struct {
101 Github struct {
102 Repository string `json:"repository"`
103 } `json:"github"`
104 }
105 if json.Unmarshal([]byte(contexts), &c) != nil {
106 return ""
107 }
108 return c.Github.Repository
109}
110
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, the results service,
115// or the cache serves a registry proxy. Without it, 0, "", "" and false.
116// A job with the results service and no cache scope gets an empty mode and a
117// token that the cache refuses. A registry-only job (neither) gets the port
118// alone: no token, and so no runtime environment variables for its steps.
119func (j *jobRun) runtimeFor(rt *Runner) (port uint32, token, mode string, results bool) {
120 if rt.Cache == nil {
121 return 0, "", "", false
122 }
123 sc, ok, err := cacheScope(j.job, j.contexts)
124 if err != nil {
125 // A bad scope from the server is not the job's failure: it runs without a
126 // cache.
127 j.log.Warn().Err(err).Msg("cache disabled for the job")
128 ok = false
129 }
130 if !ok {
131 sc = cachesvc.Scope{}
132 } else {
133 mode = "read"
134 if sc.WriteRef != "" {
135 mode = "write"
136 }
137 }
138 if !ok && !j.results {
139 if rt.Cache.RegistryEnabled() {
140 return cachesvc.DefaultPort, "", "", false
141 }
142 return 0, "", "", false
143 }
144 var scopes []string
145 if j.results {
146 scopes = append(scopes, resultssvc.Scope(j.job.RunId, j.job.JobId, j.job.Attempt))
147 }
148 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
149 return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl, scopes...), mode, j.results
150}
151
152// resultsOn says whether the job gets the results service: the runner has one
153// (ERMITE_RUNNER_ARTIFACTS is not 0) with a cache channel to serve it on, and
154// the server has the feature ci.artifacts. Without it artifact actions fail as
155// on a GitHub Enterprise Server without artifacts v4.
156func (j *jobRun) resultsOn(rt *Runner) bool {
157 return rt.Results != nil && rt.Cache != nil && rt.Artifacts != nil && j.features.artifacts
158}
159
160// resultsHandler is the results service of the job's channel, nil without one.
161// It calls ermite with the job token.
162func (j *jobRun) resultsHandler(rt *Runner) http.Handler {
163 if !j.results {
164 return nil
165 }
166 return rt.Results.Handler(resultssvc.Job{ID: j.job.JobId, RunID: j.job.RunId, Attempt: j.job.Attempt, Client: rt.Artifacts(j.job.JobToken)})
167}