Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · ci/agent/build_test.go

Line attribution on main · 268 line(s)

70b70264
Keenan Nemetz
15d ago
1 package agent
2
3 import (
77056ba8
Keenan Nemetz
2d ago
4 "errors"
70b70264
Keenan Nemetz
15d ago
5 "os"
6 "path/filepath"
7 "strings"
77056ba8
Keenan Nemetz
2d ago
8 "sync"
70b70264
Keenan Nemetz
15d ago
9 "testing"
10 "time"
11
d37d301f
Keenan Nemetz
16h ago
12 "rodent.software/vole/ci"
70b70264
Keenan Nemetz
15d ago
13 )
14
77056ba8
Keenan Nemetz
2d ago
15 // fakeImageRuntime puts a `docker` on PATH that answers the two verbs image-file
16 // building uses: whether a tag exists, and a build that records the Dockerfile and
17 // the fact that it ran. A build can be made slow (so two callers overlap) or made
18 // to fail. It returns the directory holding what the fake was asked to do.
19 func fakeImageRuntime(t *testing.T, sleep string, fail bool) string {
20 t.Helper()
70b70264
Keenan Nemetz
15d ago
21 dir := t.TempDir()
22 state := filepath.Join(dir, "state")
23 if err := os.MkdirAll(state, 0o755); err != nil {
24 t.Fatal(err)
25 }
26 script := `#!/bin/sh
27 cmd="$1"; shift
28 state="${FAKE_STATE:?}"
29 case "$cmd" in
30 image)
31 tag="$2"
32 m=$(printf '%s' "$tag" | tr '/:' '__')
33 [ -f "$state/$m" ] && exit 0
34 exit 1
35 ;;
36 build)
37 tag=; df=
38 while [ $# -gt 0 ]; do
39 case "$1" in
40 -t) tag=$2; shift 2 ;;
41 -f) df=$2; shift 2 ;;
42 *) shift ;;
43 esac
44 done
45 cp "$df" "$state/Dockerfile.captured"
46 m=$(printf '%s' "$tag" | tr '/:' '__')
47 printf 'build\n' >> "$state/builds"
48 echo "fake build"
77056ba8
Keenan Nemetz
2d ago
49 sleep "${FAKE_BUILD_SLEEP:-0}"
50 [ "${FAKE_BUILD_FAIL:-0}" = 0 ] || exit 1
51 : > "$state/$m"
70b70264
Keenan Nemetz
15d ago
52 exit 0
53 ;;
54 *) exit 0 ;;
55 esac
56 `
57 if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
58 t.Fatal(err)
59 }
60 t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
61 t.Setenv("FAKE_STATE", state)
77056ba8
Keenan Nemetz
2d ago
62 t.Setenv("FAKE_BUILD_SLEEP", sleep)
63 if fail {
64 t.Setenv("FAKE_BUILD_FAIL", "1")
65 }
66 return state
67 }
70b70264
Keenan Nemetz
15d ago
68
77056ba8
Keenan Nemetz
2d ago
69 func buildCount(t *testing.T, state string) int {
70 t.Helper()
71 builds, err := os.ReadFile(filepath.Join(state, "builds"))
70b70264
Keenan Nemetz
15d ago
72 if err != nil {
77056ba8
Keenan Nemetz
2d ago
73 if os.IsNotExist(err) {
74 return 0
75 }
70b70264
Keenan Nemetz
15d ago
76 t.Fatal(err)
77 }
77056ba8
Keenan Nemetz
2d ago
78 return strings.Count(string(builds), "build")
79 }
80
81 func imageJob() *runnerJob {
70b70264
Keenan Nemetz
15d ago
82 content := "version: 1\nfrom: alpine:3.22\nenv:\n X: \"1\"\nrun:\n - echo hello\n"
77056ba8
Keenan Nemetz
2d ago
83 return &runnerJob{
70b70264
Keenan Nemetz
15d ago
84 ID: "run1", Attempt: 1,
85 Image: "file:go", ImageFile: "go", ImageDefinition: content, ImageSHA: ci.ImageDigest(content),
86 }
77056ba8
Keenan Nemetz
2d ago
87 }
88
89 // A definition is built once, tagged by its digest, and reused by a later job.
90 // The fake runtime records the Dockerfile the agent generated and the build count.
91 func TestResolveImageBuildsAndCachesDefinition(t *testing.T) {
92 state := fakeImageRuntime(t, "0", false)
93
94 cfg, err := containerOptions(Options{ContainerRuntime: "docker"}, t.TempDir())
95 if err != nil {
96 t.Fatal(err)
97 }
98 job := imageJob()
70b70264
Keenan Nemetz
15d ago
99 log := &strings.Builder{}
100 s := &runnerSession{}
101 tag, err := s.resolveImage(job, cfg, log, time.Hour)
102 if err != nil {
103 t.Fatalf("resolveImage: %v", err)
104 }
105 if !strings.HasPrefix(tag, "vole/image-file:go-") {
106 t.Fatalf("tag = %q", tag)
107 }
108 captured, err := os.ReadFile(filepath.Join(state, "Dockerfile.captured"))
109 if err != nil {
110 t.Fatal(err)
111 }
112 for _, want := range []string{"FROM alpine:3.22", `ENV X="1"`, `RUN ["/bin/sh", "-c", "echo hello"]`} {
113 if !strings.Contains(string(captured), want) {
114 t.Fatalf("Dockerfile does not contain %q:\n%s", want, captured)
115 }
116 }
117
118 // The second call sees the digest tag and does not build again.
119 log.Reset()
120 again, err := s.resolveImage(job, cfg, log, time.Hour)
121 if err != nil || again != tag {
122 t.Fatalf("cached resolve = %q, %v; want %q", again, err, tag)
123 }
124 if !strings.Contains(log.String(), "using cached") {
125 t.Fatalf("second build was not cached:\n%s", log)
126 }
77056ba8
Keenan Nemetz
2d ago
127 if n := buildCount(t, state); n != 1 {
128 t.Fatalf("build count = %d, want 1", n)
129 }
130 }
131
132 // Two slots, one digest: the first builds it, the second joins that build instead
133 // of spending the time and the disk again. Both end up with the same tag.
134 func TestTwoJobsBuildOneImage(t *testing.T) {
135 state := fakeImageRuntime(t, "0.3", false)
136 cfg, err := containerOptions(Options{ContainerRuntime: "docker"}, t.TempDir())
137 if err != nil {
138 t.Fatal(err)
139 }
140 job := imageJob()
141
142 var (
143 wg sync.WaitGroup
144 tags [2]string
145 errs [2]error
146 logs [2]strings.Builder
147 )
148 start := make(chan struct{})
149 for i := range tags {
150 wg.Add(1)
151 go func(i int) {
152 defer wg.Done()
153 <-start
154 // Two sessions, as two slots have: the lease is long enough that neither
155 // has to heartbeat during a build this short.
156 tags[i], errs[i] = (&runnerSession{}).resolveImage(job, cfg, &logs[i], time.Hour)
157 }(i)
158 }
159 close(start)
160 wg.Wait()
161
162 for i := range tags {
163 if errs[i] != nil {
164 t.Fatalf("job %d: %v", i, errs[i])
165 }
166 if tags[i] != tags[0] {
167 t.Fatalf("two jobs got two tags: %q and %q", tags[0], tags[i])
168 }
169 }
170 if n := buildCount(t, state); n != 1 {
171 t.Fatalf("%d builds for one digest, want 1", n)
172 }
173 // One of them waited, and said so in its own log: a reader of that job's log has
174 // to be able to tell why the steps had not started.
175 waited := strings.Contains(logs[0].String(), "being built by another job") ||
176 strings.Contains(logs[1].String(), "being built by another job")
177 if !waited {
178 t.Fatalf("neither job said it was waiting:\n%s\n%s", logs[0].String(), logs[1].String())
179 }
180 }
181
182 // A build that fails fails every job waiting on it: the same definition would fail
183 // the same way, and a second attempt only spends the time again.
184 func TestAFailedBuildFailsTheJobsWaitingOnIt(t *testing.T) {
185 fakeImageRuntime(t, "0.2", true)
186 cfg, err := containerOptions(Options{ContainerRuntime: "docker"}, t.TempDir())
70b70264
Keenan Nemetz
15d ago
187 if err != nil {
188 t.Fatal(err)
189 }
77056ba8
Keenan Nemetz
2d ago
190 job := imageJob()
191
192 var (
193 wg sync.WaitGroup
194 errs [2]error
195 )
196 start := make(chan struct{})
197 for i := range errs {
198 wg.Add(1)
199 go func(i int) {
200 defer wg.Done()
201 <-start
202 _, errs[i] = (&runnerSession{}).resolveImage(job, cfg, &strings.Builder{}, time.Hour)
203 }(i)
204 }
205 close(start)
206 wg.Wait()
207
208 for i := range errs {
209 if errs[i] == nil {
210 t.Fatalf("job %d saw a successful build", i)
211 }
212 if !strings.Contains(errs[i].Error(), "go") {
213 t.Fatalf("job %d: %v does not name the image-file", i, errs[i])
214 }
215 }
216 }
217
218 // The map is the whole mechanism, so its edges are worth pinning: one builder at a
219 // time, and a finished build is forgotten rather than answered forever.
220 func TestImageBuildsJoinAndLeave(t *testing.T) {
221 t.Parallel()
222 builds := &imageBuilds{}
223 first, mine := builds.join("tag")
224 if !mine {
225 t.Fatal("the first join is not the builder")
226 }
227 second, mine := builds.join("tag")
228 if mine || second != first {
229 t.Fatal("the second join started its own build of the same digest")
230 }
231 other, mine := builds.join("another")
232 if !mine || other == first {
233 t.Fatal("a different digest joined the wrong build")
234 }
235 builds.leave("tag")
236 again, mine := builds.join("tag")
237 if !mine || again == first {
238 t.Fatal("a forgotten digest was not rebuilt")
239 }
240 }
241
242 // waitForBuild is what keeps a waiting job's lease alive. It has to end for each of
243 // the three reasons a wait can end: the build finished, the lease went away, or a
244 // maintainer stopped the run.
245 func TestWaitForBuildEnds(t *testing.T) {
246 t.Parallel()
247 lease := 3 * time.Second // a one-second heartbeat interval
248
249 done := &imageBuild{done: make(chan struct{})}
250 close(done.done)
251 if err := (&runnerSession{}).waitForBuild(done, lease, "run", &strings.Builder{}); err != nil {
252 t.Fatalf("a finished build returned %v", err)
253 }
254
255 lost := pipeSession(t, runnerReply{Op: "heartbeat", OK: false})
256 if err := lost.waitForBuild(&imageBuild{done: make(chan struct{})}, lease, "run", &strings.Builder{}); !errors.Is(err, errLeaseLost) {
257 t.Fatalf("a lost lease returned %v, want errLeaseLost", err)
258 }
259
260 canceled := pipeSession(t, runnerReply{Op: "heartbeat", OK: true, Cancel: "maintainer"})
261 log := &strings.Builder{}
262 if err := canceled.waitForBuild(&imageBuild{done: make(chan struct{})}, lease, "run", log); !errors.Is(err, errCanceled) {
263 t.Fatalf("a canceled run returned %v, want errCanceled", err)
264 }
265 if !strings.Contains(log.String(), "maintainer") {
266 t.Fatalf("the log does not say who stopped the run:\n%s", log)
70b70264
Keenan Nemetz
15d ago
267 }
268 }