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}