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