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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "rodent.software/vole/ci" |
| 13 | ) |
| 14 | |
| 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() |
| 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" |
| 49 | sleep "${FAKE_BUILD_SLEEP:-0}" |
| 50 | [ "${FAKE_BUILD_FAIL:-0}" = 0 ] || exit 1 |
| 51 | : > "$state/$m" |
| 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) |
| 62 | t.Setenv("FAKE_BUILD_SLEEP", sleep) |
| 63 | if fail { |
| 64 | t.Setenv("FAKE_BUILD_FAIL", "1") |
| 65 | } |
| 66 | return state |
| 67 | } |
| 68 | |
| 69 | func buildCount(t *testing.T, state string) int { |
| 70 | t.Helper() |
| 71 | builds, err := os.ReadFile(filepath.Join(state, "builds")) |
| 72 | if err != nil { |
| 73 | if os.IsNotExist(err) { |
| 74 | return 0 |
| 75 | } |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | return strings.Count(string(builds), "build") |
| 79 | } |
| 80 | |
| 81 | func imageJob() *runnerJob { |
| 82 | content := "version: 1\nfrom: alpine:3.22\nenv:\n X: \"1\"\nrun:\n - echo hello\n" |
| 83 | return &runnerJob{ |
| 84 | ID: "run1", Attempt: 1, |
| 85 | Image: "file:go", ImageFile: "go", ImageDefinition: content, ImageSHA: ci.ImageDigest(content), |
| 86 | } |
| 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() |
| 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 | } |
| 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()) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 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) |
| 267 | } |
| 268 | } |
| 269 |