1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6 "bytes"
7 "context"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14)
15
16// envSetupGoDist is the dist directory of a checkout of actions/setup-go
17// (dist/setup/index.js and dist/cache-save/index.js bundle the real
18// @actions/cache, 6.3.0 at the time of writing). TestSetupGoCacheRoundTrip runs
19// them against the service: the protocol as the toolkit speaks it, not as the
20// other tests of this package spell it. It needs node, tar, zstd and the
21// checkout, and no network; without them the test is skipped.
22const envSetupGoDist = "ERMITE_TEST_SETUP_GO_DIST"
23
24// TestSetupGoCacheRoundTrip is AC7's "second run of a setup-go cache: true
25// workflow logs a cache hit and restores", with the real action code: run 1
26// misses, the post step saves, run 2 hits and restores the files; jobs of
27// another repository or of another ref miss.
28func TestSetupGoCacheRoundTrip(t *testing.T) {
29 dist := os.Getenv(envSetupGoDist)
30 if dist == "" {
31 t.Skip(envSetupGoDist + " is not set (a built checkout of actions/setup-go)")
32 }
33 for _, bin := range []string{"node", "tar", "zstd", "go", "sed"} {
34 if _, err := exec.LookPath(bin); err != nil {
35 t.Skip(bin + " not found")
36 }
37 }
38 r := newRig(t, 1<<30)
39 root := t.TempDir()
40 work := filepath.Join(root, "ws")
41 modcache, gocache := filepath.Join(root, "gomod"), filepath.Join(root, "gocache")
42 for _, d := range []string{work, filepath.Join(modcache, "pkg"), gocache, filepath.Join(root, "tmp"), filepath.Join(root, "tc"), filepath.Join(root, "bin")} {
43 if err := os.MkdirAll(d, 0o755); err != nil {
44 t.Fatal(err)
45 }
46 }
47 cached := map[string]string{
48 filepath.Join(modcache, "pkg", "a.txt"): "module cache file\n",
49 filepath.Join(gocache, "blob"): strings.Repeat("0123456789abcdef", 1<<16),
50 }
51 write := func() {
52 t.Helper()
53 for p, c := range cached {
54 if err := os.WriteFile(p, []byte(c), 0o644); err != nil {
55 t.Fatal(err)
56 }
57 }
58 }
59 remove := func() {
60 for p := range cached {
61 _ = os.Remove(p)
62 }
63 }
64 if err := os.WriteFile(filepath.Join(work, "go.sum"), []byte("sum\n"), 0o644); err != nil {
65 t.Fatal(err)
66 }
67 write()
68
69 // A "go" without the "-X:…" suffix that some distributions add to the
70 // version: the setup-go key then has no ':' (a quirk of the local toolchain,
71 // unrelated to the service).
72 realGo, _ := exec.LookPath("go")
73 shim := "#!/bin/sh\n" + realGo + " \"$@\" | sed 's/-X:[a-z0-9]*//g'\n"
74 if err := os.WriteFile(filepath.Join(root, "bin", "go"), []byte(shim), 0o755); err != nil {
75 t.Fatal(err)
76 }
77
78 // step runs one entry point of the action as the job would run it.
79 step := func(j *job, script string, extra ...string) (out, state, output string) {
80 t.Helper()
81 for _, f := range []string{"state", "output", "env", "path"} {
82 if err := os.WriteFile(filepath.Join(root, f+".file"), nil, 0o644); err != nil {
83 t.Fatal(err)
84 }
85 }
86 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
87 defer cancel()
88 cmd := exec.CommandContext(ctx, "node", filepath.Join(dist, script, "index.js"))
89 cmd.Dir = work
90 cmd.Env = append([]string{
91 "PATH=" + filepath.Join(root, "bin") + ":" + os.Getenv("PATH"), "HOME=" + root,
92 "GITHUB_SERVER_URL=http://forge.test", "GITHUB_ACTIONS=true", "GITHUB_WORKSPACE=" + work,
93 "RUNNER_OS=Linux", "ImageOS=ubuntu24", "RUNNER_TEMP=" + filepath.Join(root, "tmp"), "RUNNER_TOOL_CACHE=" + filepath.Join(root, "tc"),
94 // What the executor sets for node actions (executor.addRuntimeEnv).
95 "ACTIONS_CACHE_URL=" + j.r.srv.URL + "/", "ACTIONS_RUNTIME_URL=" + j.r.srv.URL + "/", "ACTIONS_RUNTIME_TOKEN=" + j.token,
96 "GITHUB_STATE=" + filepath.Join(root, "state.file"), "GITHUB_OUTPUT=" + filepath.Join(root, "output.file"),
97 "GITHUB_ENV=" + filepath.Join(root, "env.file"), "GITHUB_PATH=" + filepath.Join(root, "path.file"),
98 "GOMODCACHE=" + modcache, "GOCACHE=" + gocache, "GOPATH=" + filepath.Join(root, "gopath"),
99 "INPUT_CACHE=true", "INPUT_CHECK-LATEST=false", "INPUT_GO-VERSION=", "INPUT_CACHE-DEPENDENCY-PATH=" + filepath.Join(work, "go.sum"),
100 }, extra...)
101 var buf bytes.Buffer
102 cmd.Stdout, cmd.Stderr = &buf, &buf
103 cmd.WaitDelay = 5 * time.Second
104 // Run is synchronous; the output is read after it returns.
105 if err := cmd.Run(); err != nil {
106 t.Fatalf("%s: %v\n%s", script, err, buf.String())
107 }
108 st, _ := os.ReadFile(filepath.Join(root, "state.file"))
109 op, _ := os.ReadFile(filepath.Join(root, "output.file"))
110 return buf.String(), string(st), string(op)
111 }
112 // value reads "name<<delimiter\nvalue\ndelimiter" of a file command file.
113 value := func(file, name string) string {
114 lines := strings.Split(file, "\n")
115 for i, l := range lines {
116 if strings.HasPrefix(l, name+"<<") && i+1 < len(lines) {
117 return lines[i+1]
118 }
119 }
120 return ""
121 }
122
123 a := r.job("run1", "bnema/app", main_, main_)
124 out, state, output := step(a, "setup")
125 if !strings.Contains(out, "Cache is not found") || value(output, "cache-hit") != "false" {
126 t.Fatalf("run 1 must miss:\n%s", out)
127 }
128 key := value(state, "CACHE_KEY")
129 if !strings.HasPrefix(key, "setup-go-Linux-") {
130 t.Fatalf("state: %q", state)
131 }
132 out, _, _ = step(a, "cache-save", "STATE_CACHE_KEY="+key)
133 if !strings.Contains(out, "Cache saved successfully") {
134 t.Fatalf("save:\n%s", out)
135 }
136 if n, _, _ := r.svc.store.stats(); n != 1 {
137 t.Fatalf("entries after save: %d", n)
138 }
139
140 // Run 2: a new job of the same repository and ref, with the caches gone.
141 remove()
142 out, _, output = step(r.job("run2", "bnema/app", main_, main_), "setup")
143 if !strings.Contains(out, "Cache restored from key: "+key) || value(output, "cache-hit") != "true" {
144 t.Fatalf("run 2 must hit:\n%s", out)
145 }
146 for p, want := range cached {
147 if got, err := os.ReadFile(p); err != nil || string(got) != want {
148 t.Errorf("%s not restored (%v)", p, err)
149 }
150 }
151
152 // Another repository, and another ref that does not read main, restore nothing.
153 for name, j := range map[string]*job{
154 "another repository": r.job("run3", "bnema/other", main_, main_),
155 "another ref": r.job("run4", "bnema/app", feat, feat),
156 } {
157 remove()
158 out, _, output = step(j, "setup")
159 if !strings.Contains(out, "Cache is not found") || value(output, "cache-hit") != "false" {
160 t.Fatalf("%s must miss:\n%s", name, out)
161 }
162 if _, err := os.Stat(filepath.Join(gocache, "blob")); err == nil {
163 t.Fatalf("%s: files were restored", name)
164 }
165 }
166}