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.
|
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 | } |