v0.3.0 8f98fd3

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
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}

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