1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "path/filepath"
10 "strings"
11 "sync/atomic"
12 "testing"
13
14 "github.com/bnema/zerowrap"
15
16 ermite "bnema.dev/ermite-api/gen/ermite"
17
18 "bnema.dev/ermite-runner/internal/actionscache"
19 "bnema.dev/ermite-runner/internal/cachesvc"
20 "bnema.dev/ermite-runner/internal/execstream/execpb"
21)
22
23type archiveRig struct {
24 r *Runner
25 srv *httptest.Server
26 hits atomic.Int32
27 status atomic.Int32
28 archive *actionscache.Cache
29}
30
31func newArchiveRig(t *testing.T) *archiveRig {
32 t.Helper()
33 a := &archiveRig{}
34 a.status.Store(http.StatusOK)
35 a.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36 a.hits.Add(1)
37 if s := int(a.status.Load()); s != http.StatusOK {
38 http.Error(w, "slow down", s)
39 return
40 }
41 _, _ = w.Write([]byte("tarball of " + r.URL.Path))
42 }))
43 t.Cleanup(a.srv.Close)
44 ac, err := actionscache.New(actionscache.Config{Dir: filepath.Join(t.TempDir(), "a"), Max: 1 << 20})
45 if err != nil {
46 t.Fatal(err)
47 }
48 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "c"), Archives: ac})
49 if err != nil {
50 t.Fatal(err)
51 }
52 t.Cleanup(func() { _ = svc.CloseAll() })
53 a.archive = ac
54 a.r = &Runner{Cache: svc, Archives: ac}
55 return a
56}
57
58func (a *archiveRig) job(id string) *jobRun {
59 j := &jobRun{r: a.r, log: zerowrap.Logger{}, ctx: context.Background(),
60 job: &ermite.Job{JobId: id, Ref: "refs/heads/main"},
61 contexts: `{"github":{"repository":"o/r"}}`}
62 return j
63}
64
65func (a *archiveRig) action(sha string) (*ermite.ResolvedAction, *execpb.ResolvedAction) {
66 o := &ermite.ResolvedAction{Ref: "x/y@v1", NameWithOwner: "x/y", ResolvedSha: sha, TarballUrl: a.srv.URL + "/x/y/tarball/" + sha, Token: "ignored-for-github"}
67 c := &execpb.ResolvedAction{Ref: o.Ref, NameWithOwner: "x/y", ResolvedSha: sha, TarballUrl: o.TarballUrl, Token: "t", AuthOrigin: "o"}
68 return o, c
69}
70
71// Two jobs resolving the same commit cost one download, and the sandbox is
72// pointed at the cache with no credentials.
73func TestCacheArchivesSharedAcrossJobs(t *testing.T) {
74 a := newArchiveRig(t)
75 sha := strings.Repeat("a", 40)
76 for _, id := range []string{"j1", "j2"} {
77 o, c := a.action(sha)
78 a.job(id).cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
79 if !strings.HasPrefix(c.TarballUrl, "http://127.0.0.1:9089"+cachesvc.ArchivePath) || c.Token != "" || c.AuthOrigin != "" {
80 t.Fatalf("%s: not redirected to the cache: %+v", id, c)
81 }
82 if !strings.Contains(c.TarballUrl, "job="+id) {
83 t.Errorf("%s: the URL is not bound to the job: %s", id, c.TarballUrl)
84 }
85 }
86 if a.hits.Load() != 1 {
87 t.Fatalf("%d downloads for one commit", a.hits.Load())
88 }
89}
90
91// A moved tag resolves to a new sha, hence a new entry: the ref is never a key.
92func TestCacheArchivesKeyedBySha(t *testing.T) {
93 a := newArchiveRig(t)
94 for _, sha := range []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} {
95 o, c := a.action(sha)
96 a.job("j").cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
97 }
98 if a.hits.Load() != 2 {
99 t.Fatalf("%d downloads for two commits", a.hits.Load())
100 }
101}
102
103// Whatever cannot be cached keeps its original URL and token, so the sandbox
104// downloads it with its own retries and messages.
105func TestCacheArchivesFallsBack(t *testing.T) {
106 a := newArchiveRig(t)
107 a.status.Store(http.StatusTooManyRequests)
108 o, c := a.action(strings.Repeat("a", 40))
109 origURL := c.TarballUrl
110 a.job("j").cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
111 if c.TarballUrl != origURL || c.Token != "t" || c.AuthOrigin != "o" {
112 t.Errorf("429: %+v", c)
113 }
114
115 a.status.Store(http.StatusOK)
116 for name, mutate := range map[string]func(*ermite.ResolvedAction){
117 "a ref instead of a sha": func(o *ermite.ResolvedAction) { o.ResolvedSha = "v1" },
118 "no sha": func(o *ermite.ResolvedAction) { o.ResolvedSha = "" },
119 "an error": func(o *ermite.ResolvedAction) { o.Error = "boom" },
120 "no url": func(o *ermite.ResolvedAction) { o.TarballUrl = "" },
121 } {
122 o, c := a.action(strings.Repeat("c", 40))
123 mutate(o)
124 before := a.hits.Load()
125 a.job("j").cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
126 if c.Token != "t" || strings.Contains(c.TarballUrl, cachesvc.ArchivePath) || a.hits.Load() != before {
127 t.Errorf("%s: cached anyway: %+v", name, c)
128 }
129 }
130
131 // No cache channel for the job (no repository): the guest has no relay.
132 o, c = a.action(strings.Repeat("d", 40))
133 j := a.job("j")
134 j.contexts = `{}`
135 j.cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
136 if c.Token != "t" || strings.Contains(c.TarballUrl, cachesvc.ArchivePath) {
137 t.Errorf("job without a cache scope: %+v", c)
138 }
139
140 // Cache off.
141 a.r.Archives = nil
142 o, c = a.action(strings.Repeat("e", 40))
143 a.job("j").cacheArchives(context.Background(), []*ermite.ResolvedAction{o}, []*execpb.ResolvedAction{c})
144 if c.Token != "t" {
145 t.Errorf("cache off: %+v", c)
146 }
147}