1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "encoding/json"
7 "fmt"
8 "strings"
9 "time"
10
11 ermite "bnema.dev/ermite-api/gen/ermite"
12
13 "bnema.dev/ermite-runner/internal/cachesvc"
14)
15
16// cacheTokenMargin is added to the job timeout for the life of the cache token:
17// the archive URLs of a lookup expire with it.
18const cacheTokenMargin = 15 * time.Minute
19
20// maxCacheReadRefs bounds Job.cache.read_refs (own ref, base branch, default
21// branch, with room to spare).
22const maxCacheReadRefs = 8
23
24// cacheScope is what the job may do in the cache of the runner.
25//
26// The server says (Job.cache, feature "ci.cache"): the repository, the one ref
27// the job may write and the refs it may read, most specific first. It is used
28// as given once checked: the repository must be the job's own (the
29// "owner/name" of the github context, or its name alone, which is how
30// Job.cache documents it) and the scope always carries the canonical
31// "owner/name", a ref must be a full "refs/..." name, empty refs
32// are dropped, and there are at most maxCacheReadRefs read refs. An empty
33// write_ref makes the job read-only. A scope that fails the checks is an error:
34// the job runs without a cache.
35//
36// A server that sends no Job.cache (an old server) gets the narrowest scope the
37// job's own fields give: the repository of the github context and the ref that
38// triggered the run, for reading and writing, with no fallback to the base or
39// default branch. A job without a repository or a ref has no cache (ok false,
40// no error).
41func cacheScope(job *ermite.Job, contexts string) (sc cachesvc.Scope, ok bool, err error) {
42 repo := githubRepository(contexts)
43 jc := job.GetCache()
44 if jc == nil {
45 if repo == "" || job.Ref == "" {
46 return cachesvc.Scope{}, false, nil
47 }
48 return cachesvc.Scope{Repository: repo, WriteRef: job.Ref, ReadRefs: []string{job.Ref}}, true, nil
49 }
50 name := repo[strings.LastIndex(repo, "/")+1:]
51 if jc.Repository == "" || repo == "" || (jc.Repository != repo && jc.Repository != name) {
52 return cachesvc.Scope{}, false, fmt.Errorf("the cache repository %q is not the job's repository %q", jc.Repository, repo)
53 }
54 // Always the job's canonical "owner/name", whichever way the server spelled
55 // it: entries of two repositories with the same name under different owners
56 // must never share a scope.
57 sc.Repository = repo
58 if w := jc.WriteRef; w != "" {
59 if !fullRef(w) {
60 return cachesvc.Scope{}, false, fmt.Errorf("the cache write ref %q is not a full ref name", w)
61 }
62 sc.WriteRef = w
63 }
64 for _, r := range jc.ReadRefs {
65 if r == "" {
66 continue
67 }
68 if !fullRef(r) {
69 return cachesvc.Scope{}, false, fmt.Errorf("the cache read ref %q is not a full ref name", r)
70 }
71 sc.ReadRefs = append(sc.ReadRefs, r)
72 }
73 if len(sc.ReadRefs) > maxCacheReadRefs {
74 return cachesvc.Scope{}, false, fmt.Errorf("%d cache read refs, at most %d", len(sc.ReadRefs), maxCacheReadRefs)
75 }
76 if sc.WriteRef == "" && len(sc.ReadRefs) == 0 {
77 return cachesvc.Scope{}, false, nil // allowed to do nothing
78 }
79 return sc, true, nil
80}
81
82// fullRef reports whether s is a full ref name: "refs/" and something after it,
83// without control characters or spaces.
84func fullRef(s string) bool {
85 if !strings.HasPrefix(s, "refs/") || len(s) == len("refs/") || len(s) > 1024 {
86 return false
87 }
88 for _, r := range s {
89 if r <= ' ' || r == 0x7f {
90 return false
91 }
92 }
93 return true
94}
95
96// githubRepository is github.repository ("owner/name") of the contexts JSON.
97func githubRepository(contexts string) string {
98 var c struct {
99 Github struct {
100 Repository string `json:"repository"`
101 } `json:"github"`
102 }
103 if json.Unmarshal([]byte(contexts), &c) != nil {
104 return ""
105 }
106 return c.Github.Repository
107}
108
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.
111func (j *jobRun) cacheFor(rt *Runner) (port uint32, token string) {
112 if rt.Cache == nil {
113 return 0, ""
114 }
115 sc, ok, err := cacheScope(j.job, j.contexts)
116 if err != nil {
117 // A bad scope from the server is not the job's failure: it runs without a
118 // cache.
119 j.log.Warn().Err(err).Msg("cache disabled for the job")
120 return 0, ""
121 }
122 if !ok {
123 return 0, ""
124 }
125 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126 return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl)
127}