v0.4.0 76a5504

cache_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close