detached 33a243c

externals_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package externals
4
5import (
6	"archive/tar"
7	"bytes"
8	"compress/gzip"
9	"context"
10	"crypto/sha256"
11	"encoding/hex"
12	"fmt"
13	"net/http"
14	"net/http/httptest"
15	"net/url"
16	"os"
17	"os/exec"
18	"path/filepath"
19	"regexp"
20	"strings"
21	"sync"
22	"sync/atomic"
23	"syscall"
24	"testing"
25	"time"
26
27	"github.com/bnema/zerowrap"
28
29	"bnema.dev/ermite-runner/internal/sandbox"
30)
31
32// ---- the table
33
34func TestPinnedTable(t *testing.T) {
35	hex64 := regexp.MustCompile(`^[0-9a-f]{64}$`)
36	seen := map[string]bool{}
37	for _, a := range Pinned {
38		key := a.Name + "/" + a.Arch
39		if seen[key] {
40			t.Errorf("%s listed twice", key)
41		}
42		seen[key] = true
43		if !strings.HasPrefix(a.URL, "https://") || !strings.HasSuffix(a.URL, ".tar.gz") || !strings.Contains(a.URL, "v"+a.Version+"/") {
44			t.Errorf("%s: URL %q", key, a.URL)
45		}
46		if !hex64.MatchString(a.SHA256) {
47			t.Errorf("%s: SHA-256 %q", key, a.SHA256)
48		}
49		if a.StripTop != strings.HasPrefix(a.URL, nodeDist) {
50			t.Errorf("%s: StripTop %v for %s", key, a.StripTop, a.URL)
51		}
52	}
53	// actions/runner's externals.sh: node20 and node24 for both architectures,
54	// the alpine builds for x64.
55	for _, k := range []string{"node20/x64", "node20/arm64", "node24/x64", "node24/arm64", "node20_alpine/x64", "node24_alpine/x64"} {
56		if !seen[k] {
57			t.Errorf("%s missing", k)
58		}
59	}
60	for _, a := range Pinned {
61		if strings.HasPrefix(a.Name, "node20") && a.Version != "20.20.2" || strings.HasPrefix(a.Name, "node24") && a.Version != "24.21.0" {
62			t.Errorf("%s pins %s", a.Name, a.Version)
63		}
64	}
65	for _, a := range Pinned {
66		if strings.HasSuffix(a.Name, "_alpine") && (a.Arch != "x64" || !strings.HasPrefix(a.URL, alpineDist+"v"+a.Version+"/node-v"+a.Version+"-alpine-x64.tar.gz")) {
67			t.Errorf("%s: %+v", a.Name, a)
68		}
69	}
70	if got := len(ForArch(Pinned, "arm64")); got != 2 {
71		t.Errorf("arm64 has %d artifacts", got)
72	}
73}
74
75func TestVerifySum(t *testing.T) {
76	if err := VerifySum("AB12", "ab12"); err != nil {
77		t.Error(err)
78	}
79	if err := VerifySum("ab12", "ab13"); err == nil || !strings.Contains(err.Error(), "mismatch") {
80		t.Errorf("got %v", err)
81	}
82}
83
84// ---- archives
85
86type ent struct {
87	name, link string
88	typ        byte
89	mode       int64
90	body       string
91}
92
93func targz(t *testing.T, ents ...ent) []byte {
94	t.Helper()
95	var buf bytes.Buffer
96	gz := gzip.NewWriter(&buf)
97	tw := tar.NewWriter(gz)
98	for _, e := range ents {
99		typ := e.typ
100		if typ == 0 {
101			typ = tar.TypeReg
102		}
103		h := &tar.Header{Name: e.name, Linkname: e.link, Typeflag: typ, Mode: e.mode, Size: int64(len(e.body))}
104		if typ != tar.TypeReg {
105			h.Size = 0
106		}
107		if err := tw.WriteHeader(h); err != nil {
108			t.Fatal(err)
109		}
110		if typ == tar.TypeReg {
111			if _, err := tw.Write([]byte(e.body)); err != nil {
112				t.Fatal(err)
113			}
114		}
115	}
116	if err := tw.Close(); err != nil {
117		t.Fatal(err)
118	}
119	if err := gz.Close(); err != nil {
120		t.Fatal(err)
121	}
122	return buf.Bytes()
123}
124
125func nodeTree() []ent {
126	return []ent{
127		{name: "node-v1-linux-x64/", typ: tar.TypeDir, mode: 0o755},
128		{name: "node-v1-linux-x64/bin/", typ: tar.TypeDir, mode: 0o755},
129		{name: "node-v1-linux-x64/bin/node", mode: 0o755, body: "#!/bin/sh\necho v1\n"},
130		{name: "node-v1-linux-x64/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, body: "x"},
131		{name: "node-v1-linux-x64/bin/npm", typ: tar.TypeSymlink, link: "../lib/node_modules/npm/bin/npm-cli.js", mode: 0o777},
132		{name: "node-v1-linux-x64/bin/nodejs", typ: tar.TypeLink, link: "node-v1-linux-x64/bin/node", mode: 0o755},
133	}
134}
135
136func extractBytes(t *testing.T, data []byte) (string, error) {
137	t.Helper()
138	dir := t.TempDir()
139	arch := filepath.Join(dir, "a.tar.gz")
140	if err := os.WriteFile(arch, data, 0o600); err != nil {
141		t.Fatal(err)
142	}
143	dst := filepath.Join(dir, "out")
144	if err := os.Mkdir(dst, 0o755); err != nil {
145		t.Fatal(err)
146	}
147	return dst, extractTarGz(arch, dst, true)
148}
149
150func TestExtractLayoutAndModes(t *testing.T) {
151	dst, err := extractBytes(t, targz(t, nodeTree()...))
152	if err != nil {
153		t.Fatal(err)
154	}
155	st, err := os.Stat(filepath.Join(dst, "bin", "node"))
156	if err != nil || st.Mode().Perm() != 0o755 {
157		t.Fatalf("bin/node: %v %v", st, err)
158	}
159	if st, _ := os.Stat(filepath.Join(dst, "lib/node_modules/npm/bin/npm-cli.js")); st == nil || st.Mode().Perm() != 0o644 {
160		t.Fatalf("npm-cli.js: %v", st)
161	}
162	if l, err := os.Readlink(filepath.Join(dst, "bin", "npm")); err != nil || l != "../lib/node_modules/npm/bin/npm-cli.js" {
163		t.Fatalf("npm link: %q %v", l, err)
164	}
165	a, _ := os.Stat(filepath.Join(dst, "bin", "node"))
166	b, _ := os.Stat(filepath.Join(dst, "bin", "nodejs"))
167	if !os.SameFile(a, b) {
168		t.Fatal("hard link not recreated")
169	}
170	if !isReady(dst) {
171		t.Fatal("not ready")
172	}
173}
174
175func TestModesDoNotDependOnTheUmask(t *testing.T) {
176	old := syscall.Umask(0o077)
177	defer syscall.Umask(old)
178	dst, err := extractBytes(t, targz(t, nodeTree()...))
179	if err != nil {
180		t.Fatal(err)
181	}
182	for p, want := range map[string]os.FileMode{
183		dst: 0o755, filepath.Join(dst, "bin"): 0o755, filepath.Join(dst, "lib/node_modules"): 0o755,
184		filepath.Join(dst, "bin/node"): 0o755, filepath.Join(dst, "lib/node_modules/npm/bin/npm-cli.js"): 0o644,
185	} {
186		if st, err := os.Stat(p); err != nil || st.Mode().Perm() != want {
187			t.Errorf("%s: %v %v, want %v", p, st, err, want)
188		}
189	}
190	m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64"}
191	e := m.unavailable("node24", "why", false)
192	for p, want := range map[string]os.FileMode{
193		filepath.Dir(e.ext.Dir): 0o755, e.ext.Dir: 0o755, filepath.Join(e.ext.Dir, "bin"): 0o755,
194		filepath.Join(e.ext.Dir, "bin/node"): 0o755, filepath.Join(e.ext.Dir, "UNAVAILABLE"): 0o644,
195	} {
196		if st, err := os.Stat(p); err != nil || st.Mode().Perm() != want {
197			t.Errorf("%s: %v %v, want %v", p, st, err, want)
198		}
199	}
200}
201
202func TestExtractRefusesUnsafeArchives(t *testing.T) {
203	top := "node-v1-linux-x64/"
204	for name, ents := range map[string][]ent{
205		"absolute path":      {{name: "/etc/passwd", body: "x"}},
206		"dot dot":            {{name: top + "../../evil", body: "x"}},
207		"dot dot in middle":  {{name: top + "a/../../evil", body: "x"}},
208		"backslash":          {{name: top + `a\b`, body: "x"}},
209		"absolute symlink":   {{name: top + "l", typ: tar.TypeSymlink, link: "/etc"}},
210		"escaping symlink":   {{name: top + "l", typ: tar.TypeSymlink, link: "../../.."}},
211		"escaping from deep": {{name: top + "a/b/l", typ: tar.TypeSymlink, link: "../../../x"}},
212		"climb after a name": {{name: top + "a/l", typ: tar.TypeSymlink, link: "b/../../../x"}},
213		"empty link target":  {{name: top + "l", typ: tar.TypeSymlink, link: ""}},
214		"write through link": {
215			{name: top + "d", typ: tar.TypeSymlink, link: "."},
216			{name: top + "d/file", body: "x"},
217		},
218		"write through dir link": {
219			{name: top + "sub/", typ: tar.TypeDir},
220			{name: top + "sub/up", typ: tar.TypeSymlink, link: ".."},
221			{name: top + "sub/up/file", body: "x"},
222		},
223		"hard link outside": {{name: top + "h", typ: tar.TypeLink, link: "/etc/passwd"}},
224		"hard link dot dot": {{name: top + "h", typ: tar.TypeLink, link: top + "../x"}},
225		"hard link to top":  {{name: top + "h", typ: tar.TypeLink, link: top}},
226		"hard link to a link": {
227			{name: top + "l", typ: tar.TypeSymlink, link: "x"},
228			{name: top + "h", typ: tar.TypeLink, link: top + "l"},
229		},
230		"device":    {{name: top + "dev", typ: tar.TypeChar}},
231		"fifo":      {{name: top + "fifo", typ: tar.TypeFifo}},
232		"duplicate": {{name: top + "f", body: "1"}, {name: top + "f", body: "2"}},
233	} {
234		t.Run(name, func(t *testing.T) {
235			dst, err := extractBytes(t, targz(t, ents...))
236			if err == nil {
237				t.Fatal("accepted")
238			}
239			// Nothing was written outside the destination, whatever the order.
240			parent := filepath.Dir(dst)
241			if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil {
242				t.Fatal("wrote outside")
243			}
244			if _, err := os.Stat(filepath.Join(dst, "..", "file")); err == nil {
245				t.Fatal("wrote outside through a link")
246			}
247		})
248	}
249}
250
251func TestExtractRefusesGarbageAndTooMany(t *testing.T) {
252	if _, err := extractBytes(t, []byte("not gzip")); err == nil {
253		t.Error("garbage accepted")
254	}
255	var ents []ent
256	for i := 0; i < maxEntries+1; i++ {
257		ents = append(ents, ent{name: "t/d/", typ: tar.TypeDir})
258	}
259	if _, err := extractBytes(t, targz(t, ents...)); err == nil || !strings.Contains(err.Error(), "entries") {
260		t.Errorf("entry limit: %v", err)
261	}
262}
263
264func TestLinkTargetRule(t *testing.T) {
265	for _, c := range []struct {
266		name, target string
267		ok           bool
268	}{
269		{"bin/npm", "../lib/x", true},
270		{"bin/npm", "../../x", false}, // one above the tree
271		{"lib/a/b/l", "../../../x", true},
272		{"lib/a/b/l", "../../../../x", false},
273		{"l", "bin/node", true},
274		{"l", "../x", false},
275		{"l", "/abs", false},
276		{"a/l", "b/../c", false},
277	} {
278		if err := checkLinkTarget(c.name, c.target); (err == nil) != c.ok {
279			t.Errorf("%s -> %s: %v", c.name, c.target, err)
280		}
281	}
282}
283
284// ---- the manager
285
286type fixture struct {
287	srv           *httptest.Server
288	hits          atomic.Int64
289	archive       []byte
290	sum           string
291	archiveAlpine []byte // no top directory, like actions/alpine_nodejs
292	sumAlpine     string
293	table         []Artifact
294	dir           string
295	m             *Manager
296}
297
298func newFixture(t *testing.T) *fixture {
299	t.Helper()
300	f := &fixture{}
301	f.archive = targz(t, nodeTree()...)
302	s := sha256.Sum256(f.archive)
303	f.sum = hex.EncodeToString(s[:])
304	f.archiveAlpine = targz(t,
305		ent{name: "./bin/", typ: tar.TypeDir, mode: 0o755},
306		ent{name: "./bin/node", mode: 0o755, body: "#!/bin/sh\necho alpine\n"},
307		ent{name: "./LICENSE", mode: 0o644, body: "x"})
308	sa := sha256.Sum256(f.archiveAlpine)
309	f.sumAlpine = hex.EncodeToString(sa[:])
310	f.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
311		f.hits.Add(1)
312		switch {
313		case strings.HasSuffix(r.URL.Path, "/alpine.tar.gz"):
314			_, _ = w.Write(f.archiveAlpine)
315		case strings.HasSuffix(r.URL.Path, "-linux-x64.tar.gz"):
316			_, _ = w.Write(f.archive)
317		case strings.HasSuffix(r.URL.Path, "tampered.tar.gz"):
318			_, _ = w.Write(append([]byte(nil), f.archive[:len(f.archive)-1]...))
319		default:
320			http.NotFound(w, r)
321		}
322	}))
323	t.Cleanup(f.srv.Close)
324	f.dir = filepath.Join(t.TempDir(), "externals")
325	f.table = []Artifact{
326		{Name: "node20", Version: "1.0.0", Arch: "x64", URL: f.srv.URL + "/v1.0.0/node-v1.0.0-linux-x64.tar.gz", SHA256: f.sum, StripTop: true},
327		{Name: "node24", Version: "2.0.0", Arch: "x64", URL: f.srv.URL + "/v2.0.0/node-v2.0.0-linux-x64.tar.gz", SHA256: f.sum, StripTop: true},
328		{Name: "node20_alpine", Version: "1.0.0", Arch: "x64", URL: f.srv.URL + "/v1.0.0/alpine.tar.gz", SHA256: f.sumAlpine},
329	}
330	f.m = &Manager{Dir: f.dir, Arch: "x64", Table: f.table, Client: f.srv.Client()}
331	return f
332}
333
334func byName(set []sandbox.External) map[string]sandbox.External {
335	m := map[string]sandbox.External{}
336	for _, e := range set {
337		m[e.Name] = e
338	}
339	return m
340}
341
342func TestSyncDownloadsVerifiesAndReuses(t *testing.T) {
343	f := newFixture(t)
344	if err := f.m.Sync(context.Background()); err != nil {
345		t.Fatal(err)
346	}
347	set := byName(f.m.Externals())
348	n20, n24, al := set["node20"], set["node24"], set["node20_alpine"]
349	if n20.Unavailable || n24.Unavailable || n20.Dir != filepath.Join(f.dir, "node20-1.0.0-x64") {
350		t.Fatalf("set: %+v", set)
351	}
352	if n20.ID == n24.ID {
353		t.Error("two tools share an ID")
354	}
355	if b, err := os.ReadFile(filepath.Join(n24.Dir, "bin", "node")); err != nil || !strings.Contains(string(b), "echo v1") {
356		t.Fatalf("node24: %q %v", b, err)
357	}
358	// The alpine archive has no top directory: bin/node is at its root.
359	if al.Unavailable {
360		t.Fatalf("alpine: %+v", al)
361	}
362	if b, err := os.ReadFile(filepath.Join(al.Dir, "bin", "node")); err != nil || !strings.Contains(string(b), "echo alpine") {
363		t.Fatalf("alpine node: %q %v", b, err)
364	}
365	hits := f.hits.Load()
366	if hits != 3 {
367		t.Fatalf("downloads: %d", hits)
368	}
369
370	// A second start finds everything in place and downloads nothing.
371	m2 := &Manager{Dir: f.dir, Arch: "x64", Table: f.table, Client: f.srv.Client()}
372	if err := m2.Sync(context.Background()); err != nil {
373		t.Fatal(err)
374	}
375	if f.hits.Load() != hits {
376		t.Fatal("downloaded again")
377	}
378	if got := byName(m2.Externals())["node24"]; got.ID != n24.ID {
379		t.Fatalf("ID changed: %q -> %q", n24.ID, got.ID)
380	}
381}
382
383// runStub runs the bin/node of an unavailable tool: it must fail with the reason
384// on stderr, from wherever the tool is mounted.
385func runStub(t *testing.T, e sandbox.External, want string) {
386	t.Helper()
387	if _, err := exec.LookPath("sh"); err != nil {
388		t.Skip("sh needed")
389	}
390	// Run it through a symlinked path too, as a guest sees it at another place.
391	cmd := exec.Command(filepath.Join(e.Dir, "bin", "node"), "-e", "x")
392	var stderr, stdout bytes.Buffer
393	cmd.Stderr, cmd.Stdout = &stderr, &stdout
394	err := cmd.Run()
395	if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 1 {
396		t.Fatalf("the stub must exit 1, got %v", err)
397	}
398	if !strings.Contains(stderr.String(), want) || stdout.Len() != 0 {
399		t.Fatalf("stub stderr %q stdout %q, want %q", stderr.String(), stdout.String(), want)
400	}
401	if b, _ := os.ReadFile(filepath.Join(e.Dir, "bin", "node")); strings.Contains(string(b), want) {
402		t.Fatalf("the stub holds the reason: %q", b)
403	}
404}
405
406func TestSyncRefusesAChecksumMismatchBeforeExtracting(t *testing.T) {
407	f := newFixture(t)
408	f.table[0].SHA256 = strings.Repeat("0", 64)
409	f.m.Table = f.table
410	err := f.m.Sync(context.Background())
411	if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
412		t.Fatalf("got %v", err)
413	}
414	n20 := byName(f.m.Externals())["node20"]
415	if !n20.Unavailable {
416		t.Fatal("a tool with a wrong checksum is available")
417	}
418	if _, err := os.Stat(filepath.Join(f.dir, "node20-1.0.0-x64")); err == nil {
419		t.Fatal("extracted although the checksum differs")
420	}
421	reason, _ := os.ReadFile(filepath.Join(n20.Dir, sandbox.ExternalUnavailableFile))
422	if !strings.Contains(string(reason), "SHA-256 mismatch") {
423		t.Fatalf("reason: %q", reason)
424	}
425	// The other tool is unaffected.
426	if byName(f.m.Externals())["node24"].Unavailable {
427		t.Fatal("node24 lost to node20's failure")
428	}
429	// A checksum failure is a verdict: no retry is scheduled for it.
430	if f.m.state["node20"].retry {
431		t.Fatal("retry scheduled for a mismatch")
432	}
433	runStub(t, n20, "SHA-256 mismatch")
434	ents, _ := os.ReadDir(f.dir)
435	for _, e := range ents {
436		if strings.HasPrefix(e.Name(), ".tmp-") {
437			t.Fatalf("leftover %s", e.Name())
438		}
439	}
440}
441
442func TestSyncRefusesAnUnsafeArchiveWithTheRightChecksum(t *testing.T) {
443	f := newFixture(t)
444	bad := targz(t, ent{name: "node-v1-linux-x64/bin/node", mode: 0o755, body: "x"}, ent{name: "node-v1-linux-x64/l", typ: tar.TypeSymlink, link: "/etc"})
445	f.archive = bad
446	s := sha256.Sum256(bad)
447	f.table[0].SHA256, f.table[1].SHA256 = hex.EncodeToString(s[:]), hex.EncodeToString(s[:])
448	f.m.Table = f.table
449	err := f.m.Sync(context.Background())
450	if err == nil || !strings.Contains(err.Error(), "absolute") {
451		t.Fatalf("got %v", err)
452	}
453	if _, err := os.Stat(filepath.Join(f.dir, "node20-1.0.0-x64")); err == nil {
454		t.Fatal("unsafe archive left in place")
455	}
456}
457
458func TestSyncOfflineKeepsTheRunnerGoing(t *testing.T) {
459	f := newFixture(t)
460	f.srv.Close() // offline
461	err := f.m.Sync(context.Background())
462	if err == nil {
463		t.Fatal("no error offline")
464	}
465	for _, n := range []string{"node20", "node24"} {
466		e := byName(f.m.Externals())[n]
467		if !e.Unavailable {
468			t.Fatalf("%s available offline", n)
469		}
470		runStub(t, e, "is not available on this runner")
471	}
472	if !f.m.wantsRetry() {
473		t.Fatal("a network failure must be retried")
474	}
475}
476
477func TestBadStatusAndRedirectToHTTP(t *testing.T) {
478	f := newFixture(t)
479	f.table[0].URL = f.srv.URL + "/missing.tar.gz"
480	f.m.Table = f.table
481	if err := f.m.Sync(context.Background()); err == nil || !strings.Contains(err.Error(), "404") {
482		t.Fatalf("got %v", err)
483	}
484	if !byName(f.m.Externals())["node20"].Unavailable {
485		t.Fatal("404 gives a tool")
486	}
487
488	// The default client refuses a redirect to plain http, and too many.
489	check := newClient().CheckRedirect
490	if err := check(&http.Request{URL: mustURL(t, "http://example.org/x")}, nil); err == nil {
491		t.Error("redirect to http followed")
492	}
493	if err := check(&http.Request{URL: mustURL(t, "https://example.org/x")}, make([]*http.Request, 5)); err == nil {
494		t.Error("redirect loop followed")
495	}
496	if err := check(&http.Request{URL: mustURL(t, "https://example.org/x")}, make([]*http.Request, 1)); err != nil {
497		t.Error(err)
498	}
499	// A plain http URL in the table is refused outright.
500	m2 := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64", Client: f.srv.Client()}
501	m2.Table = []Artifact{{Name: "node20", Version: "1", Arch: "x64", URL: "http://example.invalid/a.tar.gz", SHA256: f.sum}}
502	if err := m2.Sync(context.Background()); err == nil || !strings.Contains(err.Error(), "not an https URL") {
503		t.Fatalf("got %v", err)
504	}
505}
506
507func mustURL(t *testing.T, s string) *url.URL {
508	t.Helper()
509	u, err := url.Parse(s)
510	if err != nil {
511		t.Fatal(err)
512	}
513	return u
514}
515
516func TestSyncKeepsOnlyPinnedVersions(t *testing.T) {
517	f := newFixture(t)
518	if err := f.m.Sync(context.Background()); err != nil {
519		t.Fatal(err)
520	}
521	// Leftovers: an old version, an interrupted download, a stray file, an
522	// incomplete directory under a pinned name.
523	for _, d := range []string{"node20-0.9.0-x64", ".tmp-123", "node24-2.0.0-arm64", "node-old"} {
524		if err := os.MkdirAll(filepath.Join(f.dir, d, "bin"), 0o755); err != nil {
525			t.Fatal(err)
526		}
527	}
528	if err := os.WriteFile(filepath.Join(f.dir, "stray"), []byte("x"), 0o644); err != nil {
529		t.Fatal(err)
530	}
531	if err := f.m.Sync(context.Background()); err != nil {
532		t.Fatal(err)
533	}
534	var got []string
535	ents, _ := os.ReadDir(f.dir)
536	for _, e := range ents {
537		got = append(got, e.Name())
538	}
539	want := "node20-1.0.0-x64 node20_alpine-1.0.0-x64 node24-2.0.0-x64"
540	if strings.Join(got, " ") != want {
541		t.Fatalf("kept %v, want %s", got, want)
542	}
543}
544
545func TestSyncRebuildsAnIncompleteDirectory(t *testing.T) {
546	f := newFixture(t)
547	final := filepath.Join(f.dir, "node20-1.0.0-x64")
548	if err := os.MkdirAll(filepath.Join(final, "bin"), 0o755); err != nil { // no bin/node
549		t.Fatal(err)
550	}
551	if err := f.m.Sync(context.Background()); err != nil {
552		t.Fatal(err)
553	}
554	if !isReady(final) {
555		t.Fatal("incomplete directory kept")
556	}
557}
558
559func TestANewVersionChangesTheIDAndDropsTheOldDirectory(t *testing.T) {
560	f := newFixture(t)
561	if err := f.m.Sync(context.Background()); err != nil {
562		t.Fatal(err)
563	}
564	old := byName(f.m.Externals())["node24"]
565	f.table[1].Version = "2.1.0"
566	f.table[1].URL = f.srv.URL + "/v2.1.0/node-v2.1.0-linux-x64.tar.gz"
567	if err := f.m.Sync(context.Background()); err != nil {
568		t.Fatal(err)
569	}
570	n := byName(f.m.Externals())["node24"]
571	if n.ID == old.ID || n.Dir == old.Dir {
572		t.Fatalf("same tool after an upgrade: %+v %+v", old, n)
573	}
574	if _, err := os.Stat(old.Dir); err == nil {
575		t.Fatal("old version kept")
576	}
577}
578
579func TestNoBuildForTheArchitecture(t *testing.T) {
580	f := newFixture(t)
581	f.m.Arch = "arm64" // the fixture table is x64 only
582	if err := f.m.Sync(context.Background()); err != nil {
583		t.Fatal(err)
584	}
585	for _, e := range f.m.Externals() {
586		reason, _ := os.ReadFile(filepath.Join(e.Dir, sandbox.ExternalUnavailableFile))
587		if !e.Unavailable || !strings.Contains(string(reason), "no "+e.Name+" build for linux-arm64") {
588			t.Errorf("%s: %+v %q", e.Name, e, reason)
589		}
590	}
591	if f.hits.Load() != 0 {
592		t.Error("downloaded for the wrong architecture")
593	}
594}
595
596func TestBeforeTheFirstSyncTheToolsAreBeingDownloaded(t *testing.T) {
597	f := newFixture(t)
598	set := f.m.Externals()
599	if len(set) != 3 {
600		t.Fatalf("set: %+v", set)
601	}
602	for _, e := range set {
603		reason, _ := os.ReadFile(filepath.Join(e.Dir, sandbox.ExternalUnavailableFile))
604		if !e.Unavailable || !strings.Contains(string(reason), "still being downloaded") {
605			t.Errorf("%s: %q", e.Name, reason)
606		}
607	}
608}
609
610func TestFailedDownloadIsRetriedInTheBackground(t *testing.T) {
611	f := newFixture(t)
612	var up atomic.Bool
613	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
614		if !up.Load() {
615			http.Error(w, "down", http.StatusServiceUnavailable)
616			return
617		}
618		_, _ = w.Write(f.archive)
619	}))
620	defer srv.Close()
621	m := &Manager{Dir: f.dir, Arch: "x64", Client: srv.Client(), RetryEvery: time.Millisecond}
622	m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a-linux-x64.tar.gz", SHA256: f.sum, StripTop: true}}
623	ctx, cancel := context.WithCancel(context.Background())
624	defer cancel()
625	m.Start(ctx, time.Minute)
626	if !m.Externals()[0].Unavailable {
627		t.Fatal("available while the server is down")
628	}
629	up.Store(true)
630	deadline := time.Now().Add(10 * time.Second)
631	for time.Now().Before(deadline) {
632		time.Sleep(20 * time.Millisecond)
633		if !m.Externals()[0].Unavailable {
634			return
635		}
636	}
637	t.Fatal("never retried")
638}
639
640func TestStartDoesNotWaitLongerThanAsked(t *testing.T) {
641	block := make(chan struct{})
642	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-block }))
643	defer srv.Close()
644	m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64", Client: srv.Client()}
645	m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a.tar.gz", SHA256: strings.Repeat("a", 64)}}
646	ctx, cancel := context.WithCancel(context.Background())
647	start := time.Now()
648	m.Start(ctx, 100*time.Millisecond)
649	if d := time.Since(start); d > 5*time.Second {
650		t.Fatalf("waited %v", d)
651	}
652	// The download goes on in the background; stop it before the directory goes.
653	cancel()
654	close(block)
655	for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
656		m.mu.Lock()
657		busy := m.syncing
658		m.mu.Unlock()
659		if !busy {
660			return
661		}
662	}
663	t.Fatal("the background sync did not stop")
664}
665
666// ---- the directories of unavailable tools
667
668func TestStubIsBuiltOnceAndNeverRewrittenInPlace(t *testing.T) {
669	m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64"}
670	e := m.unavailable("node24", "why", false)
671	node := filepath.Join(e.ext.Dir, "bin", "node")
672	file := filepath.Join(e.ext.Dir, sandbox.ExternalUnavailableFile)
673	ino := func(p string) uint64 {
674		st, err := os.Stat(p)
675		if err != nil {
676			t.Fatal(err)
677		}
678		return st.Sys().(*syscall.Stat_t).Ino
679	}
680	n0, f0 := ino(node), ino(file)
681
682	// Again, from this process and from a fresh one (a restart): same files.
683	m.unavailable("node24", "why", false)
684	(&Manager{Dir: m.Dir, Arch: "x64"}).unavailable("node24", "why", false)
685	if ino(node) != n0 || ino(file) != f0 {
686		t.Fatal("an existing, correct stub was rewritten")
687	}
688
689	// A damaged or missing file is replaced by a rename (a new inode, so a reader
690	// of the old one never sees it change), with the right mode.
691	if err := os.WriteFile(node, nil, 0o755); err != nil {
692		t.Fatal(err)
693	}
694	if err := os.Remove(file); err != nil {
695		t.Fatal(err)
696	}
697	(&Manager{Dir: m.Dir, Arch: "x64"}).unavailable("node24", "why", false)
698	if b, _ := os.ReadFile(node); string(b) != unavailableStub {
699		t.Fatalf("stub not repaired: %q", b)
700	}
701	if b, _ := os.ReadFile(file); string(b) != "why\n" {
702		t.Fatalf("reason not repaired: %q", b)
703	}
704	if st, _ := os.Stat(node); st.Mode().Perm() != 0o755 || ino(node) == n0 {
705		t.Fatalf("repair in place: %v", st.Mode())
706	}
707	runStub(t, e.ext, "why")
708	left, _ := filepath.Glob(filepath.Join(e.ext.Dir, "*", ".tmp-*"))
709	left2, _ := filepath.Glob(filepath.Join(e.ext.Dir, ".tmp-*"))
710	if len(left)+len(left2) != 0 {
711		t.Fatalf("temporary files left: %v %v", left, left2)
712	}
713}
714
715// While Externals() and Sync() run in several goroutines, a reader that walks
716// the directories of the unavailable tools (as the build of a VM's externals
717// disk does) never sees a missing, empty or partial file.
718func TestConcurrentExternalsAndSyncNeverShowAHalfWrittenStub(t *testing.T) {
719	f := newFixture(t)
720	f.srv.Close() // offline: every tool is unavailable
721	ctx := context.Background()
722	stop := make(chan struct{})
723	var wg sync.WaitGroup
724	var bad atomic.Value
725	check := func(dir string) {
726		for _, c := range []struct{ name, want string }{{"bin/node", unavailableStub}} {
727			b, err := os.ReadFile(filepath.Join(dir, c.name))
728			if err != nil {
729				bad.Store(fmt.Sprintf("%s/%s: %v", dir, c.name, err))
730			} else if string(b) != c.want {
731				bad.Store(fmt.Sprintf("%s/%s: %q", dir, c.name, b))
732			}
733		}
734		b, err := os.ReadFile(filepath.Join(dir, sandbox.ExternalUnavailableFile))
735		if err != nil || len(b) == 0 || !strings.HasSuffix(string(b), "\n") {
736			bad.Store(fmt.Sprintf("%s/UNAVAILABLE: %q %v", dir, b, err))
737		}
738	}
739	for i := 0; i < 4; i++ {
740		wg.Add(1)
741		go func() {
742			defer wg.Done()
743			for {
744				select {
745				case <-stop:
746					return
747				default:
748				}
749				for _, e := range f.m.Externals() {
750					check(e.Dir) // what a driver would hand to the disk build
751				}
752			}
753		}()
754	}
755	wg.Add(1)
756	go func() {
757		defer wg.Done()
758		for {
759			select {
760			case <-stop:
761				return
762			default:
763			}
764			dirs, _ := filepath.Glob(filepath.Join(f.dir, unavailableDir, "*"))
765			for _, d := range dirs {
766				if !strings.Contains(filepath.Base(d), ".tmp-") {
767					check(d)
768				}
769			}
770		}
771	}()
772	for i := 0; i < 5; i++ {
773		_ = f.m.Sync(ctx)
774	}
775	close(stop)
776	wg.Wait()
777	if v := bad.Load(); v != nil {
778		t.Fatalf("a reader saw an incomplete stub: %v", v)
779	}
780}
781
782func TestPruneKeepsStubDirectoriesHandedOutInTheLastHour(t *testing.T) {
783	f := newFixture(t)
784	f.srv.Close()
785	_ = f.m.Sync(context.Background()) // offline: stubs for the three tools
786	root := filepath.Join(f.dir, unavailableDir)
787	mk := func(name string, age time.Duration) string {
788		p := filepath.Join(root, name)
789		if err := os.MkdirAll(filepath.Join(p, "bin"), 0o755); err != nil {
790			t.Fatal(err)
791		}
792		old := time.Now().Add(-age)
793		if err := os.Chtimes(p, old, old); err != nil {
794			t.Fatal(err)
795		}
796		return p
797	}
798	fresh := mk("node24-unavailable-aaaaaaaaaaaaaaaa", 5*time.Minute) // handed to a running job
799	stale := mk("node24-unavailable-bbbbbbbbbbbbbbbb", 2*time.Hour)
800
801	// A reason that is not current any more but was handed out again just now
802	// (Externals touches what it returns) survives; one that was not, does not.
803	cur := f.m.Externals()[0]
804	old := time.Now().Add(-3 * time.Hour)
805	if err := os.Chtimes(cur.Dir, old, old); err != nil {
806		t.Fatal(err)
807	}
808	f.m.Externals() // handed out: touched
809	if st, _ := os.Stat(cur.Dir); time.Since(st.ModTime()) > time.Minute {
810		t.Fatalf("Externals did not touch %s", cur.Dir)
811	}
812	// The set changes (the reasons are not the same any more), the touched
813	// directory is no longer current.
814	f.m.mu.Lock()
815	f.m.state = map[string]entry{}
816	f.m.mu.Unlock()
817	f.m.prune(zerowrap.Logger{}, map[string]bool{})
818	for _, p := range []string{fresh, cur.Dir} {
819		if _, err := os.Stat(p); err != nil {
820			t.Errorf("%s was pruned although handed out in the last hour", p)
821		}
822	}
823	if _, err := os.Stat(stale); err == nil {
824		t.Error("a stale stub directory was kept")
825	}
826}
827
828// ---- retries
829
830func TestRetriesRunOnATickerWithoutAnyJob(t *testing.T) {
831	f := newFixture(t)
832	var up atomic.Bool
833	var calls atomic.Int64
834	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
835		calls.Add(1)
836		if !up.Load() {
837			http.Error(w, "down", http.StatusServiceUnavailable)
838			return
839		}
840		_, _ = w.Write(f.archive)
841	}))
842	defer srv.Close()
843	m := &Manager{Dir: f.dir, Arch: "x64", Client: srv.Client(), RetryEvery: 20 * time.Millisecond}
844	m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a-linux-x64.tar.gz", SHA256: f.sum, StripTop: true}}
845	ctx, cancel := context.WithCancel(context.Background())
846	m.Start(ctx, time.Minute)
847
848	// Nobody calls Externals(): the ticker alone retries.
849	deadline := time.Now().Add(10 * time.Second)
850	for calls.Load() < 3 && time.Now().Before(deadline) {
851		time.Sleep(10 * time.Millisecond)
852	}
853	if calls.Load() < 3 {
854		t.Fatalf("%d attempts without any job", calls.Load())
855	}
856	up.Store(true)
857	for time.Now().Before(deadline) {
858		m.mu.Lock()
859		ok := !m.state["node24"].ext.Unavailable
860		m.mu.Unlock()
861		if ok {
862			break
863		}
864		time.Sleep(10 * time.Millisecond)
865	}
866	if m.Externals()[0].Unavailable {
867		t.Fatal("never recovered")
868	}
869
870	// Once everything is there the ticker stops downloading, and with the context
871	// it stops altogether.
872	before := calls.Load()
873	time.Sleep(150 * time.Millisecond)
874	if calls.Load() != before {
875		t.Fatal("the ticker keeps requesting what it has")
876	}
877	cancel()
878	m.mu.Lock()
879	m.state["node24"] = entry{ext: sandbox.External{Name: "node24", Unavailable: true}, retry: true}
880	m.mu.Unlock()
881	time.Sleep(150 * time.Millisecond)
882	if calls.Load() != before {
883		t.Fatal("a retry ran after the context ended")
884	}
885}
886
887// A verdict (wrong checksum) is not retried by the ticker.
888func TestNoRetryForAVerdict(t *testing.T) {
889	f := newFixture(t)
890	f.table[0].SHA256 = strings.Repeat("0", 64)
891	m := &Manager{Dir: f.dir, Arch: "x64", Table: f.table[:1], Client: f.srv.Client(), RetryEvery: 10 * time.Millisecond}
892	ctx, cancel := context.WithCancel(context.Background())
893	defer cancel()
894	m.Start(ctx, time.Minute)
895	hits := f.hits.Load()
896	time.Sleep(150 * time.Millisecond)
897	if f.hits.Load() != hits {
898		t.Fatal("a checksum mismatch was retried")
899	}
900}
901
902// ---- the real archives, when they are at hand
903
904// The actions/alpine_nodejs archives have no top directory (./bin/node). Set
905// ERMITE_RUNNER_TEST_ALPINE_DIR, or leave the files in /tmp/alpine-node.
906func TestRealAlpineArchives(t *testing.T) {
907	dir := os.Getenv("ERMITE_RUNNER_TEST_ALPINE_DIR")
908	if dir == "" {
909		dir = "/tmp/alpine-node"
910	}
911	ran := false
912	for _, a := range Pinned {
913		if !strings.HasSuffix(a.Name, "_alpine") {
914			continue
915		}
916		archive := filepath.Join(dir, filepath.Base(a.URL))
917		b, err := os.ReadFile(archive)
918		if err != nil {
919			t.Logf("%s: %v", a.Name, err)
920			continue
921		}
922		ran = true
923		t.Run(a.Name, func(t *testing.T) {
924			sum := sha256.Sum256(b)
925			if err := VerifySum(hex.EncodeToString(sum[:]), a.SHA256); err != nil {
926				t.Fatalf("the pinned checksum is not the file's: %v", err)
927			}
928			dst := t.TempDir()
929			if err := extractTarGz(archive, dst, a.StripTop); err != nil {
930				t.Fatal(err)
931			}
932			if !isReady(dst) {
933				t.Fatal("no executable bin/node")
934			}
935			if st, err := os.Stat(filepath.Join(dst, "LICENSE")); err != nil || st.Mode().Perm() != 0o644 {
936				t.Fatalf("LICENSE: %v %v", st, err)
937			}
938			// Taking the first component off would lose bin/node.
939			wrong := t.TempDir()
940			_ = extractTarGz(archive, wrong, true)
941			if isReady(wrong) {
942				t.Fatal("the alpine archive must not be unpacked with the top directory stripped")
943			}
944		})
945	}
946	if !ran {
947		t.Skipf("no alpine archive in %s", dir)
948	}
949}

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