v0.5.1 25d29f5

toolkit_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package resultssvc
4
5import (
6	"bufio"
7	"bytes"
8	"context"
9	"crypto/rand"
10	"net"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"regexp"
15	"strconv"
16	"strings"
17	"testing"
18	"time"
19
20	"bnema.dev/ermite-runner/internal/cachesvc"
21)
22
23// The checkouts of actions/upload-artifact and actions/download-artifact
24// (v4 or later: action.yml and the built dist/) that TestArtifactActions runs
25// against the service: the protocol as @actions/artifact speaks it, not as
26// the other tests of this package spell it. The test needs node 20 or later
27// (ERMITE_TEST_NODE, else node from PATH) and no network; without the
28// checkouts it is skipped.
29const (
30	envUploadDist   = "ERMITE_TEST_UPLOAD_ARTIFACT_DIST"
31	envDownloadDist = "ERMITE_TEST_DOWNLOAD_ARTIFACT_DIST"
32	envNode         = "ERMITE_TEST_NODE"
33)
34
35// chJob is a job of TestArtifactActions: its Job, the URL of its channel and
36// its runtime token.
37type chJob struct {
38	Job
39	url   string
40	token string
41}
42
43// manifest is what the test needs of an action.yml: the script and the input
44// defaults.
45type manifest struct {
46	main     string
47	defaults map[string]string
48}
49
50// readManifest reads action.yml with a few lines of parsing: the inputs'
51// "default:" keys and runs.main, which is all these manifests hold that the
52// test uses.
53func readManifest(t *testing.T, dir string) manifest {
54	t.Helper()
55	f, err := os.Open(filepath.Join(dir, "action.yml"))
56	if err != nil {
57		t.Fatal(err)
58	}
59	defer func() { _ = f.Close() }()
60	m := manifest{defaults: map[string]string{}}
61	unquote := func(v string) string {
62		v = strings.TrimSpace(v)
63		if len(v) >= 2 && (v[0] == '\'' || v[0] == '"') && v[len(v)-1] == v[0] {
64			v = v[1 : len(v)-1]
65		}
66		return v
67	}
68	section, input := "", ""
69	sc := bufio.NewScanner(f)
70	for sc.Scan() {
71		line := sc.Text()
72		trimmed := strings.TrimSpace(line)
73		indent := len(line) - len(strings.TrimLeft(line, " "))
74		switch {
75		case trimmed == "" || strings.HasPrefix(trimmed, "#"):
76		case indent == 0:
77			section, _, _ = strings.Cut(trimmed, ":")
78		case section == "inputs" && indent == 2 && strings.HasSuffix(trimmed, ":"):
79			input = strings.TrimSuffix(trimmed, ":")
80			m.defaults[input] = ""
81		case section == "inputs" && indent == 4 && strings.HasPrefix(trimmed, "default:"):
82			m.defaults[input] = unquote(strings.TrimPrefix(trimmed, "default:"))
83		case section == "runs" && indent == 2 && strings.HasPrefix(trimmed, "main:"):
84			m.main = unquote(strings.TrimPrefix(trimmed, "main:"))
85		}
86	}
87	if m.main == "" {
88		t.Fatalf("%s/action.yml: no runs.main", dir)
89	}
90	return m
91}
92
93// TestArtifactActions runs the real upload-artifact and download-artifact
94// against the service in front of the fake ermite: round trips of one file,
95// of several blocks and of an empty file, merge-multiple, overwrite, a
96// download by another job of the run, a token refused on another job's
97// channel, and a job of another run that sees nothing. Tokens and channels are
98// cachesvc's, as in production.
99func TestArtifactActions(t *testing.T) {
100	upDir, downDir := os.Getenv(envUploadDist), os.Getenv(envDownloadDist)
101	if upDir == "" || downDir == "" {
102		t.Skip(envUploadDist + " and " + envDownloadDist + " are not set (checkouts of actions/upload-artifact and actions/download-artifact)")
103	}
104	node := os.Getenv(envNode)
105	if node == "" {
106		var err error
107		if node, err = exec.LookPath("node"); err != nil {
108			t.Skip("node not found")
109		}
110	}
111	out, err := exec.Command(node, "--version").Output()
112	major, _ := strconv.Atoi(strings.TrimPrefix(strings.SplitN(strings.TrimSpace(string(out)), ".", 2)[0], "v"))
113	if err != nil || major < 20 {
114		t.Skipf("node %q is not 20 or later", strings.TrimSpace(string(out)))
115	}
116	upload, download := readManifest(t, upDir), readManifest(t, downDir)
117
118	// The production path: cachesvc mints and checks the runtime tokens and
119	// serves each job's channel, with the results service of that job beside
120	// the cache.
121	cache, err := cachesvc.New(cachesvc.Config{Dir: t.TempDir()})
122	if err != nil {
123		t.Fatal(err)
124	}
125	t.Cleanup(func() { _ = cache.CloseAll() })
126	f := newFakeErmite()
127	ermiteURL := f.serve(t)
128	results := New(Config{Auth: cache})
129	root := t.TempDir()
130	const run = 1001
131	nJobs := 0
132	// newJob registers a job with ermite and serves its channel on a TCP
133	// listener of its own.
134	newJob := func(run int64, id string) *chJob {
135		t.Helper()
136		nJobs++
137		et := "ermitejob_" + strconv.Itoa(nJobs)
138		f.addJob(et, run, id, 1)
139		j := Job{ID: id, RunID: run, Attempt: 1, Client: client(ermiteURL, et)}
140		ln, err := net.Listen("tcp", "127.0.0.1:0")
141		if err != nil {
142			t.Fatal(err)
143		}
144		if err := cache.Serve("ch-"+id, ln, id, cachesvc.WithResults(results.Handler(j))); err != nil {
145			t.Fatal(err)
146		}
147		t.Cleanup(func() { cache.Stop("ch-" + id) })
148		return &chJob{Job: j, url: "http://" + ln.Addr().String(), token: cache.Token(id, cachesvc.Scope{}, time.Hour, Scope(run, id, 1))}
149	}
150	build, test := newJob(run, "build"), newJob(run, "test")
151	intruder := newJob(run+1, "intruder")
152
153	ws := func(j *chJob) string { return filepath.Join(root, "ws-"+j.ID) }
154	write := func(j *chJob, rel string, data []byte) {
155		t.Helper()
156		p := filepath.Join(ws(j), rel)
157		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
158			t.Fatal(err)
159		}
160		if err := os.WriteFile(p, data, 0o644); err != nil {
161			t.Fatal(err)
162		}
163	}
164	read := func(j *chJob, rel string) []byte {
165		t.Helper()
166		b, err := os.ReadFile(filepath.Join(ws(j), rel))
167		if err != nil {
168			t.Fatal(err)
169		}
170		return b
171	}
172	outputRE := regexp.MustCompile(`(?ms)^(\S+)<<(\S+)\n(.*?)\n\S+$`)
173
174	// step runs the main script of an action as the runner would run it for
175	// job j, and returns whether it succeeded, its outputs and its log.
176	step := func(dir string, m manifest, j *chJob, inputs map[string]string) (bool, map[string]string, string) {
177		t.Helper()
178		tmp := filepath.Join(root, "tmp-"+j.ID)
179		for _, d := range []string{ws(j), tmp} {
180			if err := os.MkdirAll(d, 0o755); err != nil {
181				t.Fatal(err)
182			}
183		}
184		files := map[string]string{"output": "", "env": "", "summary": "", "event.json": "{}"}
185		for name, content := range files {
186			if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0o644); err != nil {
187				t.Fatal(err)
188			}
189		}
190		runID := strconv.FormatInt(j.RunID, 10)
191		env := []string{
192			"PATH=" + os.Getenv("PATH"), "HOME=" + tmp,
193			"GITHUB_SERVER_URL=https://ermite.ghe.localhost", "GITHUB_REPOSITORY=bnema/app",
194			"GITHUB_RUN_ID=" + runID, "GITHUB_RUN_ATTEMPT=" + strconv.Itoa(int(j.Attempt)), "GITHUB_JOB=" + j.ID,
195			"GITHUB_WORKSPACE=" + ws(j), "RUNNER_TEMP=" + tmp, "RUNNER_OS=Linux",
196			"GITHUB_OUTPUT=" + filepath.Join(tmp, "output"), "GITHUB_ENV=" + filepath.Join(tmp, "env"),
197			"GITHUB_STEP_SUMMARY=" + filepath.Join(tmp, "summary"), "GITHUB_EVENT_PATH=" + filepath.Join(tmp, "event.json"),
198			"ACTIONS_RESULTS_URL=" + j.url + "/", "ACTIONS_RUNTIME_TOKEN=" + j.token,
199		}
200		for name, def := range m.defaults {
201			v, ok := inputs[name]
202			if !ok {
203				v = strings.NewReplacer("${{ github.repository }}", "bnema/app", "${{ github.run_id }}", runID, "${{ github.token }}", "").Replace(def)
204			}
205			env = append(env, "INPUT_"+strings.ToUpper(strings.ReplaceAll(name, " ", "_"))+"="+v)
206		}
207		ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
208		defer cancel()
209		// node itself, not a shell: a shell would drop the INPUT_ variables
210		// whose names have a '-'.
211		cmd := exec.CommandContext(ctx, node, filepath.Join(dir, m.main))
212		cmd.Dir, cmd.Env = ws(j), env
213		var buf bytes.Buffer
214		cmd.Stdout, cmd.Stderr = &buf, &buf
215		cmd.WaitDelay = 5 * time.Second
216		err := cmd.Run()
217		raw, _ := os.ReadFile(filepath.Join(tmp, "output"))
218		outs := map[string]string{}
219		for _, m := range outputRE.FindAllStringSubmatch(string(raw), -1) {
220			outs[m[1]] = m[3]
221		}
222		return err == nil, outs, buf.String()
223	}
224	up := func(j *chJob, inputs map[string]string) (bool, map[string]string, string) {
225		t.Helper()
226		return step(upDir, upload, j, inputs)
227	}
228	down := func(j *chJob, inputs map[string]string) (bool, string) {
229		t.Helper()
230		ok, _, log := step(downDir, download, j, inputs)
231		return ok, log
232	}
233	random := func(n int) []byte {
234		b := make([]byte, n)
235		_, _ = rand.Read(b)
236		return b
237	}
238
239	t.Run("single file", func(t *testing.T) {
240		write(build, "one.txt", []byte("hello artifacts\n"))
241		ok, outs, log := up(build, map[string]string{"name": "single", "path": "one.txt"})
242		if !ok || outs["artifact-id"] == "" {
243			t.Fatalf("upload (%v):\n%s", outs, log)
244		}
245		// Another job of the run downloads it.
246		if ok, log := down(test, map[string]string{"name": "single", "path": "got-single"}); !ok {
247			t.Fatalf("download:\n%s", log)
248		}
249		if got := read(test, "got-single/one.txt"); string(got) != "hello artifacts\n" {
250			t.Fatalf("downloaded %q", got)
251		}
252	})
253
254	t.Run("several blocks", func(t *testing.T) {
255		big := random(20<<20 + 123)
256		write(build, "big/rand.bin", big)
257		if ok, _, log := up(build, map[string]string{"name": "big", "path": "big/rand.bin", "compression-level": "0"}); !ok {
258			t.Fatalf("upload:\n%s", log)
259		}
260		if ok, log := down(test, map[string]string{"name": "big", "path": "got-big"}); !ok {
261			t.Fatalf("download:\n%s", log)
262		}
263		if !bytes.Equal(read(test, "got-big/rand.bin"), big) {
264			t.Fatal("the downloaded file differs")
265		}
266	})
267
268	t.Run("empty file", func(t *testing.T) {
269		write(build, "empty/zero.txt", nil)
270		if ok, _, log := up(build, map[string]string{"name": "empty", "path": "empty/zero.txt"}); !ok {
271			t.Fatalf("upload:\n%s", log)
272		}
273		if ok, log := down(test, map[string]string{"name": "empty", "path": "got-empty"}); !ok {
274			t.Fatalf("download:\n%s", log)
275		}
276		if got := read(test, "got-empty/zero.txt"); len(got) != 0 {
277			t.Fatalf("downloaded %q", got)
278		}
279	})
280
281	t.Run("merge multiple", func(t *testing.T) {
282		for i := range 3 {
283			n := strconv.Itoa(i)
284			write(build, "part"+n+"/p"+n+".txt", []byte("part "+n+"\n"))
285			if ok, _, log := up(build, map[string]string{"name": "part-" + n, "path": "part" + n + "/"}); !ok {
286				t.Fatalf("upload %d:\n%s", i, log)
287			}
288		}
289		if ok, log := down(test, map[string]string{"pattern": "part-*", "merge-multiple": "true", "path": "merged"}); !ok {
290			t.Fatalf("download:\n%s", log)
291		}
292		for i := range 3 {
293			n := strconv.Itoa(i)
294			if got := read(test, "merged/p"+n+".txt"); string(got) != "part "+n+"\n" {
295				t.Fatalf("p%d.txt: %q", i, got)
296			}
297		}
298	})
299
300	t.Run("overwrite", func(t *testing.T) {
301		write(build, "one.txt", []byte("hello artifacts v2\n"))
302		ok, _, log := up(build, map[string]string{"name": "single", "path": "one.txt"})
303		if ok || !strings.Contains(log, "409") {
304			t.Fatalf("a second upload of the name without overwrite must fail with a 409:\n%s", log)
305		}
306		if ok, _, log := up(build, map[string]string{"name": "single", "path": "one.txt", "overwrite": "true"}); !ok {
307			t.Fatalf("overwrite:\n%s", log)
308		}
309		if ok, log := down(test, map[string]string{"name": "single", "path": "got-single-2"}); !ok {
310			t.Fatalf("download:\n%s", log)
311		}
312		if got := read(test, "got-single-2/one.txt"); string(got) != "hello artifacts v2\n" {
313			t.Fatalf("downloaded %q", got)
314		}
315	})
316
317	t.Run("archive false", func(t *testing.T) {
318		if _, ok := upload.defaults["archive"]; !ok {
319			t.Skip("upload-artifact before v7 has no archive input")
320		}
321		write(build, "raw/report.html", []byte("<h1>raw</h1>\n"))
322		if ok, _, log := up(build, map[string]string{"path": "raw/report.html", "archive": "false"}); !ok {
323			t.Fatalf("upload:\n%s", log)
324		}
325		if ok, log := down(test, map[string]string{"name": "report.html", "path": "got-raw"}); !ok {
326			t.Fatalf("download:\n%s", log)
327		}
328		if got := read(test, "got-raw/report.html"); string(got) != "<h1>raw</h1>\n" {
329			t.Fatalf("downloaded %q", got)
330		}
331	})
332
333	t.Run("token of another channel", func(t *testing.T) {
334		// test's token on build's channel: the channel accepts only build's.
335		stray := &chJob{Job: test.Job, url: build.url, token: test.token}
336		ok, log := down(stray, map[string]string{"name": "single", "path": "stray"})
337		if ok || !strings.Contains(log, "401") {
338			t.Fatalf("a token used on another job's channel must be refused with a 401:\n%s", log)
339		}
340	})
341
342	t.Run("another run", func(t *testing.T) {
343		if ok, log := down(intruder, map[string]string{"name": "single", "path": "x"}); ok {
344			t.Fatalf("a job of another run downloaded the artifact:\n%s", log)
345		}
346		ok, log := down(intruder, map[string]string{"path": "all"})
347		if !ok {
348			t.Fatalf("download all:\n%s", log)
349		}
350		if entries, _ := os.ReadDir(filepath.Join(ws(intruder), "all")); len(entries) != 0 {
351			t.Fatalf("a job of another run got %d artifacts", len(entries))
352		}
353	})
354}

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