1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "bytes"
7 "net/http"
8 "net/http/httptest"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/bnema/zerowrap"
14
15 ermite "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17
18 "bnema.dev/ermite-runner/internal/cachesvc"
19 "bnema.dev/ermite-runner/internal/resultssvc"
20)
21
22const scopeCtx = `{"github":{"repository":"o/r"}}`
23
24func TestCacheScopeFromJobCache(t *testing.T) {
25 main, pr := "refs/heads/main", "refs/pull/7/merge"
26 for name, tc := range map[string]struct {
27 jc *ermite.JobCache
28 write string
29 reads []string
30 }{
31 "pull request": {&ermite.JobCache{Repository: "o/r", WriteRef: pr, ReadRefs: []string{pr, "refs/heads/dev", main}}, pr, []string{pr, "refs/heads/dev", main}},
32 // Job.cache documents the repository as its name.
33 "name only": {&ermite.JobCache{Repository: "r", WriteRef: main, ReadRefs: []string{main}}, main, []string{main}},
34 "read-only": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{main}}, "", []string{main}},
35 "empties dropped": {&ermite.JobCache{Repository: "o/r", WriteRef: main, ReadRefs: []string{"", main, ""}}, main, []string{main}},
36 "write-only": {&ermite.JobCache{Repository: "o/r", WriteRef: main}, main, nil},
37 } {
38 // The server's scope wins over Job.Ref.
39 sc, ok, err := cacheScope(&ermite.Job{Ref: "refs/heads/other", Cache: tc.jc}, scopeCtx)
40 if err != nil || !ok || sc.Repository != "o/r" || sc.WriteRef != tc.write || strings.Join(sc.ReadRefs, ",") != strings.Join(tc.reads, ",") {
41 t.Errorf("%s: %v %v %+v", name, ok, err, sc)
42 }
43 }
44 // Nothing allowed: no cache, no error.
45 if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "o/r", ReadRefs: []string{""}}}, scopeCtx); ok || err != nil {
46 t.Errorf("empty scope: %v %v", ok, err)
47 }
48}
49
50// A name alone and owner/name are the same repository, and the scope is always
51// owner/name: two repositories with the same name under different owners never
52// share entries.
53func TestCacheScopeCanonicalRepository(t *testing.T) {
54 ref := "refs/heads/main"
55 ctxA, ctxB := `{"github":{"repository":"a/r"}}`, `{"github":{"repository":"b/r"}}`
56 var got []string
57 for _, tc := range []struct{ repo, ctx string }{{"r", ctxA}, {"a/r", ctxA}, {"r", ctxB}, {"b/r", ctxB}} {
58 sc, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: tc.repo, WriteRef: ref, ReadRefs: []string{ref}}}, tc.ctx)
59 if err != nil || !ok {
60 t.Fatalf("%+v: %v %v", tc, ok, err)
61 }
62 got = append(got, sc.Repository)
63 }
64 if strings.Join(got, ",") != "a/r,a/r,b/r,b/r" {
65 t.Fatalf("scopes %v", got)
66 }
67 // A spelling that names the other owner is refused.
68 if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "b/r", WriteRef: ref}}, ctxA); ok || err == nil {
69 t.Fatal("b/r accepted for a/r")
70 }
71}
72
73func TestCacheScopeInvalid(t *testing.T) {
74 main := "refs/heads/main"
75 nine := make([]string, 9)
76 for i := range nine {
77 nine[i] = "refs/heads/b" + string(rune('0'+i))
78 }
79 for name, tc := range map[string]struct {
80 jc *ermite.JobCache
81 ctx string
82 }{
83 "other repository": {&ermite.JobCache{Repository: "o/other", WriteRef: main}, scopeCtx},
84 "other owner": {&ermite.JobCache{Repository: "x/r", WriteRef: main}, scopeCtx},
85 "empty repository": {&ermite.JobCache{WriteRef: main}, scopeCtx},
86 "job without repo": {&ermite.JobCache{Repository: "o/r", WriteRef: main}, `{}`},
87 "short write ref": {&ermite.JobCache{Repository: "o/r", WriteRef: "main"}, scopeCtx},
88 "short read ref": {&ermite.JobCache{Repository: "o/r", WriteRef: main, ReadRefs: []string{main, "dev"}}, scopeCtx},
89 "bare refs/": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{"refs/"}}, scopeCtx},
90 "space in ref": {&ermite.JobCache{Repository: "o/r", ReadRefs: []string{"refs/heads/a b"}}, scopeCtx},
91 "nine read refs": {&ermite.JobCache{Repository: "o/r", ReadRefs: nine}, scopeCtx},
92 } {
93 if sc, ok, err := cacheScope(&ermite.Job{Ref: main, Cache: tc.jc}, tc.ctx); ok || err == nil {
94 t.Errorf("%s: accepted %+v", name, sc)
95 }
96 }
97 // Eight are fine.
98 if _, ok, err := cacheScope(&ermite.Job{Cache: &ermite.JobCache{Repository: "o/r", ReadRefs: nine[:8]}}, scopeCtx); !ok || err != nil {
99 t.Errorf("eight read refs: %v %v", ok, err)
100 }
101}
102
103// An old server sends no Job.cache: the repository and Job.ref, both ways.
104func TestCacheScopeFallback(t *testing.T) {
105 sc, ok, err := cacheScope(&ermite.Job{Ref: "refs/heads/main"}, scopeCtx)
106 if err != nil || !ok || sc.Repository != "o/r" || sc.WriteRef != "refs/heads/main" || len(sc.ReadRefs) != 1 || sc.ReadRefs[0] != "refs/heads/main" {
107 t.Fatalf("%v %v %+v", ok, err, sc)
108 }
109 // No repository or no ref: no cache, rather than a scope that could be
110 // shared by unrelated jobs.
111 for name, tc := range map[string]struct {
112 job *ermite.Job
113 ctx string
114 }{
115 "no ref": {&ermite.Job{}, scopeCtx},
116 "no repository": {&ermite.Job{Ref: "refs/heads/main"}, `{"github":{}}`},
117 "bad contexts": {&ermite.Job{Ref: "refs/heads/main"}, `nope`},
118 "no github": {&ermite.Job{Ref: "refs/heads/main"}, `{}`},
119 } {
120 if _, ok, err := cacheScope(tc.job, tc.ctx); ok || err != nil {
121 t.Errorf("%s: %v %v", name, ok, err)
122 }
123 }
124}
125
126// A mismatched scope disables the cache of the job, with a log line; the job
127// goes on.
128func TestRuntimeForMismatchDisablesCache(t *testing.T) {
129 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
130 if err != nil {
131 t.Fatal(err)
132 }
133 t.Cleanup(func() { _ = svc.CloseAll() })
134 var logs bytes.Buffer
135 j := &jobRun{
136 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
137 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),
138 }
139 if port, tok, _, _ := j.runtimeFor(&Runner{Cache: svc}); port != 0 || tok != "" {
140 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
141 }
142 if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {
143 t.Fatalf("log: %q", logs.String())
144 }
145}
146
147func TestRuntimeForNeedsServiceAndScope(t *testing.T) {
148 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
149 if port, tok, _, _ := j.runtimeFor(&Runner{}); port != 0 || tok != "" {
150 t.Fatalf("no service: %d %q", port, tok)
151 }
152}
153
154// The token of a job is the one the service accepts, for the job's repository
155// and ref only.
156func TestRuntimeForToken(t *testing.T) {
157 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
158 if err != nil {
159 t.Fatal(err)
160 }
161 t.Cleanup(func() { _ = svc.CloseAll() })
162 srv := httptest.NewServer(svc.Handler())
163 defer srv.Close()
164 rt := &Runner{Cache: svc}
165 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
166 port, tok, mode, results := j.runtimeFor(rt)
167 // The port is the cache's loopback port in the guest, not a host port.
168 if port != 9089 || tok == "" || results {
169 t.Fatalf("%d %q %v", port, tok, results)
170 }
171 // No Job.cache: the narrowest scope writes the job's own ref.
172 if mode != "write" {
173 t.Fatalf("mode %q", mode)
174 }
175 do := func(token string) int {
176 req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`))
177 if token != "" {
178 req.Header.Set("Authorization", "Bearer "+token)
179 }
180 resp, err := http.DefaultClient.Do(req)
181 if err != nil {
182 t.Fatal(err)
183 }
184 _ = resp.Body.Close()
185 return resp.StatusCode
186 }
187 if c := do(tok); c != http.StatusCreated {
188 t.Fatalf("reserve with the job's token: %d", c)
189 }
190 // The job token of ermite is not a cache token, nor the other way round.
191 if c := do("ermitejob_0123456789"); c != http.StatusForbidden {
192 t.Fatalf("foreign token: %d", c)
193 }
194}
195
196// ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job
197// read-only.
198func TestRuntimeForMode(t *testing.T) {
199 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
200 if err != nil {
201 t.Fatal(err)
202 }
203 t.Cleanup(func() { _ = svc.CloseAll() })
204 for _, tc := range []struct{ write, want string }{{"refs/heads/main", "write"}, {"", "read"}} {
205 j := &jobRun{
206 job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},
207 contexts: `{"github":{"repository":"o/r"}}`,
208 }
209 if _, _, mode, _ := j.runtimeFor(&Runner{Cache: svc}); mode != tc.want {
210 t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want)
211 }
212 }
213}
214
215// A job with the results service gets the channel even without a cache scope:
216// its token carries the job's Actions.Results scope, the results service takes
217// it and the cache refuses it. With a cache scope the token serves both.
218func 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.
251func 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}