ci_build_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "archive/tar" |
| 5 | "bytes" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "hearthforge/internal/ci" |
| 17 | ) |
| 18 | |
| 19 | // build_image runs in a build VM container. The mock engine plays that |
| 20 | // container: it prints a log, exits with a code, and serves the image tar. |
| 21 | |
| 22 | const ciBuildTOML = ` |
| 23 | image = "debian:latest" |
| 24 | clone_project_to = "/ci/project" |
| 25 | |
| 26 | [on] |
| 27 | manual = true |
| 28 | |
| 29 | [[steps]] |
| 30 | name = "image" |
| 31 | build_image = { image = "web", tags = ["$CI_COMMIT_SHORT_SHA", "latest", "$CI_COMMIT_TAG"], args = { REPO = "$CI_REPO_NAME" }, secrets = ["NPM_TOKEN"] } |
| 32 | ` |
| 33 | |
| 34 | // dirTar builds a directory tar as the archive endpoint returns it. |
| 35 | func dirTar(t *testing.T, top string, files map[string]string) []byte { |
| 36 | t.Helper() |
| 37 | var buf bytes.Buffer |
| 38 | tw := tar.NewWriter(&buf) |
| 39 | if err := tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | for name, body := range files { |
| 43 | hdr := &tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(body))} |
| 44 | if err := tw.WriteHeader(hdr); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | tw.Write([]byte(body)) |
| 48 | } |
| 49 | tw.Close() |
| 50 | return buf.Bytes() |
| 51 | } |
| 52 | |
| 53 | func hexOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| 54 | |
| 55 | // extraBlob is a blob the archive carries but the manifest does not name. |
| 56 | var extraBlob = []byte("not-in-the-manifest") |
| 57 | |
| 58 | // builtImage returns a tar of a containers-image dir: layout, as the build |
| 59 | // VM writes it, and the manifest digest. variant is "tamper" to corrupt the |
| 60 | // layer, "no-layer" to leave it out, or "" for a good image. Every variant |
| 61 | // carries extraBlob. |
| 62 | func builtImage(t *testing.T, variant string) ([]byte, string) { |
| 63 | t.Helper() |
| 64 | config := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`) |
| 65 | layer := []byte("layer-bytes") |
| 66 | manifest := fmtManifest(hexOf(config), len(config), hexOf(layer), len(layer)) |
| 67 | files := map[string]string{ |
| 68 | "manifest.json": string(manifest), |
| 69 | "version": "Directory Transport Version: 1.1\n", |
| 70 | hexOf(config): string(config), |
| 71 | hexOf(layer): string(layer), |
| 72 | hexOf(extraBlob): string(extraBlob), |
| 73 | } |
| 74 | switch variant { |
| 75 | case "tamper": |
| 76 | files[hexOf(layer)] = "other-bytes" |
| 77 | case "no-layer": |
| 78 | delete(files, hexOf(layer)) |
| 79 | } |
| 80 | return dirTar(t, ".", files), regDigest(manifest) |
| 81 | } |
| 82 | |
| 83 | // buildContainerRemoved reports whether the run's build VM container was |
| 84 | // deleted after it was created. |
| 85 | func buildContainerRemoved(m *mockDocker, runID int64) bool { |
| 86 | name := "hearthforge-ci-" + strconv.FormatInt(runID, 10) + "-build" |
| 87 | created := false |
| 88 | for _, req := range m.containerRequests() { |
| 89 | switch req { |
| 90 | case "POST create?name=" + name: |
| 91 | created = true |
| 92 | case "DELETE " + name: |
| 93 | if created { |
| 94 | return true |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | return false |
| 99 | } |
| 100 | |
| 101 | func fmtManifest(config string, configSize int, layer string, layerSize int) []byte { |
| 102 | b, _ := json.Marshal(map[string]any{ |
| 103 | "schemaVersion": 2, |
| 104 | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", |
| 105 | "config": map[string]any{ |
| 106 | "mediaType": "application/vnd.docker.container.image.v1+json", |
| 107 | "digest": "sha256:" + config, "size": configSize, |
| 108 | }, |
| 109 | "layers": []map[string]any{{ |
| 110 | "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", |
| 111 | "digest": "sha256:" + layer, "size": layerSize, |
| 112 | }}, |
| 113 | }) |
| 114 | return b |
| 115 | } |
| 116 | |
| 117 | // ciBuildEnv seeds the build config, the secret it names, and the context |
| 118 | // directory in the CI container. extraEnv goes to ciEnv. |
| 119 | func ciBuildEnv(t *testing.T, extraEnv ...string) (*env, *mockDocker, *session, string) { |
| 120 | t.Helper() |
| 121 | e, m, admin := ciEnv(t, extraEnv...) |
| 122 | admin.post("/ci-repo/settings/ci-secrets", url.Values{ |
| 123 | "name": {"NPM_TOKEN"}, "value": {"npm-s3cret"}, |
| 124 | }).mustRedirect("/ci-repo/settings") |
| 125 | sha := ciSeedToml(e, ciBuildTOML) |
| 126 | m.reset() |
| 127 | m.setArchive("/ci/project", dirTar(t, "project", map[string]string{"Containerfile": "FROM scratch\n"})) |
| 128 | return e, m, admin, sha |
| 129 | } |
| 130 | |
| 131 | func TestCIBuildImage(t *testing.T) { |
| 132 | e, m, admin, sha := ciBuildEnv(t) |
| 133 | image, digest := builtImage(t, "") |
| 134 | m.programBuild("STEP 1/1: FROM scratch\nnpm-s3cret\n", 0, image) |
| 135 | |
| 136 | runID := ciTrigger(e, admin, sha, nil) |
| 137 | if status := ciWaitForRun(e, runID); status != "success" { |
| 138 | t.Fatalf("run status = %q, log %q", status, ciStep(e, runID, "image").Log) |
| 139 | } |
| 140 | |
| 141 | step := ciStep(e, runID, "image") |
| 142 | host := strings.TrimPrefix(e.Base, "http://") |
| 143 | for _, want := range []string{ |
| 144 | "STEP 1/1: FROM scratch", |
| 145 | "Pushed " + host + "/ci-repo/web:" + sha[:8], |
| 146 | "Pushed " + host + "/ci-repo/web:latest", |
| 147 | "Digest " + digest, |
| 148 | } { |
| 149 | if !strings.Contains(step.Log, want) { |
| 150 | t.Errorf("log lacks %q:\n%s", want, step.Log) |
| 151 | } |
| 152 | } |
| 153 | if strings.Contains(step.Log, "npm-s3cret") { |
| 154 | t.Error("the secret is not masked in the build log") |
| 155 | } |
| 156 | |
| 157 | for _, tag := range []string{"latest", sha[:8]} { |
| 158 | r := regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/manifests/"+tag, nil).mustStatus(200) |
| 159 | if got := r.Header.Get("Docker-Content-Digest"); got != digest { |
| 160 | t.Errorf("%s digest = %s, want %s", tag, got, digest) |
| 161 | } |
| 162 | } |
| 163 | if tags := regTags(t, e, "ci-repo/web", ""); len(tags) != 2 { |
| 164 | t.Errorf("tags = %v, the empty $CI_COMMIT_TAG must be dropped", tags) |
| 165 | } |
| 166 | |
| 167 | vmImage, err := ci.DefaultVMImage() |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | |
| 172 | t.Run("the VM image is built from the embedded vm/ and logged", func(t *testing.T) { |
| 173 | builds := m.builtImages() |
| 174 | if len(builds) != 1 || builds[0].tag != vmImage { |
| 175 | t.Fatalf("image builds = %v, want one of %s", builds, vmImage) |
| 176 | } |
| 177 | for _, f := range []string{"Containerfile", "run-vm", "guest/init"} { |
| 178 | if !contains(builds[0].files, f) { |
| 179 | t.Errorf("build context %v lacks %s", builds[0].files, f) |
| 180 | } |
| 181 | } |
| 182 | for _, want := range []string{"Building the build VM image " + vmImage, "Step 1/20 : ARG ALPINE"} { |
| 183 | if !strings.Contains(step.Log, want) { |
| 184 | t.Errorf("log lacks %q", want) |
| 185 | } |
| 186 | } |
| 187 | if len(m.pulledImages()) != 1 { |
| 188 | t.Errorf("pulls = %v, want only the CI image", m.pulledImages()) |
| 189 | } |
| 190 | }) |
| 191 | |
| 192 | t.Run("the VM container gets KVM and nothing of the host", func(t *testing.T) { |
| 193 | body := m.buildBody() |
| 194 | if body["Image"] != vmImage { |
| 195 | t.Errorf("Image = %v", body["Image"]) |
| 196 | } |
| 197 | host, _ := body["HostConfig"].(map[string]any) |
| 198 | devices, _ := json.Marshal(host["Devices"]) |
| 199 | if !strings.Contains(string(devices), `"PathOnHost":"/dev/kvm"`) { |
| 200 | t.Errorf("Devices = %s", devices) |
| 201 | } |
| 202 | if host["Binds"] != nil || host["Privileged"] != nil { |
| 203 | t.Errorf("HostConfig = %v", host) |
| 204 | } |
| 205 | }) |
| 206 | |
| 207 | t.Run("the job carries the context, args and secrets", func(t *testing.T) { |
| 208 | if got := tarFiles(t, m.uploadTo("/in/context"))["project/Containerfile"]; got != "FROM scratch\n" { |
| 209 | t.Errorf("context Containerfile = %q", got) |
| 210 | } |
| 211 | files := tarFiles(t, m.uploadTo("/in")) |
| 212 | want := map[string]string{ |
| 213 | "job/args/REPO": "ci-repo", |
| 214 | "job/secrets/NPM_TOKEN": "npm-s3cret", |
| 215 | } |
| 216 | for name, body := range want { |
| 217 | if files[name] != body { |
| 218 | t.Errorf("%s = %q, want %q", name, files[name], body) |
| 219 | } |
| 220 | } |
| 221 | }) |
| 222 | |
| 223 | t.Run("a blob the manifest does not name is not stored", func(t *testing.T) { |
| 224 | regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/blobs/sha256:"+hexOf(extraBlob), nil).mustStatus(404) |
| 225 | }) |
| 226 | |
| 227 | t.Run("the build VM container is removed", func(t *testing.T) { |
| 228 | if !buildContainerRemoved(m, runID) { |
| 229 | t.Errorf("requests = %v", m.containerRequests()) |
| 230 | } |
| 231 | }) |
| 232 | } |
| 233 | |
| 234 | // tarFiles maps the regular files of a tar to their content. |
| 235 | func tarFiles(t *testing.T, data []byte) map[string]string { |
| 236 | t.Helper() |
| 237 | out := map[string]string{} |
| 238 | tr := tar.NewReader(bytes.NewReader(data)) |
| 239 | for { |
| 240 | h, err := tr.Next() |
| 241 | if err != nil { |
| 242 | break |
| 243 | } |
| 244 | var b bytes.Buffer |
| 245 | b.ReadFrom(tr) |
| 246 | if h.Typeflag == tar.TypeReg { |
| 247 | out[h.Name] = b.String() |
| 248 | } |
| 249 | } |
| 250 | return out |
| 251 | } |
| 252 | |
| 253 | func TestCIBuildImageFailures(t *testing.T) { |
| 254 | cases := []struct { |
| 255 | name string |
| 256 | exit int |
| 257 | variant string |
| 258 | env []string |
| 259 | wantLog string |
| 260 | }{ |
| 261 | {"build fails", 1, "", nil, "Image build failed: the build failed"}, |
| 262 | {"image too large", 3, "", nil, "Image build failed: the image is larger than CI_MAX_IMAGE_BYTES"}, |
| 263 | {"VM does not start", 2, "", nil, "Image build failed: the build VM did not run (exit code 2)"}, |
| 264 | {"layer does not match its digest", 0, "tamper", nil, "digest does not match"}, |
| 265 | {"layer missing from the archive", 0, "no-layer", nil, "not uploaded"}, |
| 266 | // run-vm enforces the cap too. This is the check that does not trust it. |
| 267 | {"image over the cap despite exit 0", 0, "", []string{"CI_MAX_IMAGE_BYTES", "64"}, "larger than CI_MAX_IMAGE_BYTES"}, |
| 268 | } |
| 269 | for _, c := range cases { |
| 270 | t.Run(c.name, func(t *testing.T) { |
| 271 | e, m, admin, sha := ciBuildEnv(t, append([]string{"CI_VM_IMAGE", "localhost/test-vm:1"}, c.env...)...) |
| 272 | m.setLocalImages("localhost/test-vm:1") |
| 273 | image, _ := builtImage(t, c.variant) |
| 274 | m.programBuild("hearthforge: failure\n", c.exit, image) |
| 275 | |
| 276 | runID := ciTrigger(e, admin, sha, nil) |
| 277 | if status := ciWaitForRun(e, runID); status != "failure" { |
| 278 | t.Fatalf("run status = %q", status) |
| 279 | } |
| 280 | if log := ciStep(e, runID, "image").Log; !strings.Contains(log, c.wantLog) { |
| 281 | t.Errorf("log = %q, want %q", log, c.wantLog) |
| 282 | } |
| 283 | regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/manifests/latest", nil).mustStatus(404) |
| 284 | if pulls := m.pulledImages(); contains(pulls, "localhost/test-vm") { |
| 285 | t.Errorf("pulls = %v, the local VM image must not be pulled", pulls) |
| 286 | } |
| 287 | if builds := m.builtImages(); len(builds) != 0 { |
| 288 | t.Errorf("image builds = %v, CI_VM_IMAGE must not be built", builds) |
| 289 | } |
| 290 | if !buildContainerRemoved(m, runID) { |
| 291 | t.Errorf("build VM container not removed: %v", m.containerRequests()) |
| 292 | } |
| 293 | }) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | func TestCIBuildImageTimeout(t *testing.T) { |
| 298 | e, m, admin := ciEnv(t, "CI_VM_IMAGE", "localhost/test-vm:1") |
| 299 | sha := ciSeedToml(e, ` |
| 300 | image = "debian:latest" |
| 301 | clone_project_to = "/ci/project" |
| 302 | |
| 303 | [on] |
| 304 | manual = true |
| 305 | |
| 306 | [[steps]] |
| 307 | name = "image" |
| 308 | timeout = 1 |
| 309 | build_image = {} |
| 310 | `) |
| 311 | m.reset() |
| 312 | m.setLocalImages("localhost/test-vm:1") |
| 313 | m.setArchive("/ci/project", dirTar(t, "project", map[string]string{"Containerfile": "FROM scratch\n"})) |
| 314 | m.programBuild("STEP 1/1\n", 0, nil) |
| 315 | m.delayBuild(time.Minute) |
| 316 | |
| 317 | runID := ciTrigger(e, admin, sha, nil) |
| 318 | if status := ciWaitForRun(e, runID); status != "failure" { |
| 319 | t.Fatalf("run status = %q", status) |
| 320 | } |
| 321 | if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "Step timed out after 1s") { |
| 322 | t.Errorf("log = %q", log) |
| 323 | } |
| 324 | if !buildContainerRemoved(m, runID) { |
| 325 | t.Errorf("build VM container not removed: %v", m.containerRequests()) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | func TestCIBuildImageMissingSecret(t *testing.T) { |
| 330 | e, m, admin := ciEnv(t) |
| 331 | sha := ciSeedToml(e, ciBuildTOML) |
| 332 | m.reset() |
| 333 | runID := ciTrigger(e, admin, sha, nil) |
| 334 | if status := ciWaitForRun(e, runID); status != "failure" { |
| 335 | t.Fatalf("run status = %q", status) |
| 336 | } |
| 337 | if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "secret NPM_TOKEN is not set") { |
| 338 | t.Errorf("log = %q", log) |
| 339 | } |
| 340 | if m.buildBody() != nil { |
| 341 | t.Error("a build VM container was created") |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestCIBuildImageVMImage(t *testing.T) { |
| 346 | t.Run("a second run reuses the built VM image", func(t *testing.T) { |
| 347 | e, m, admin, sha := ciBuildEnv(t) |
| 348 | image, _ := builtImage(t, "") |
| 349 | for range 2 { |
| 350 | m.programBuild("", 0, image) |
| 351 | if status := ciWaitForRun(e, ciTrigger(e, admin, sha, nil)); status != "success" { |
| 352 | t.Fatalf("run status = %q", status) |
| 353 | } |
| 354 | } |
| 355 | if builds := m.builtImages(); len(builds) != 1 { |
| 356 | t.Errorf("image builds = %d, want 1", len(builds)) |
| 357 | } |
| 358 | }) |
| 359 | |
| 360 | t.Run("a failed VM image build fails the step", func(t *testing.T) { |
| 361 | e, m, admin, sha := ciBuildEnv(t) |
| 362 | m.failImageBuild("apk: network unreachable") |
| 363 | runID := ciTrigger(e, admin, sha, nil) |
| 364 | if status := ciWaitForRun(e, runID); status != "failure" { |
| 365 | t.Fatalf("run status = %q", status) |
| 366 | } |
| 367 | log := ciStep(e, runID, "image").Log |
| 368 | if !strings.Contains(log, "cannot build the build VM image: apk: network unreachable") { |
| 369 | t.Errorf("log = %q", log) |
| 370 | } |
| 371 | if m.buildBody() != nil { |
| 372 | t.Error("a build VM container was created") |
| 373 | } |
| 374 | }) |
| 375 | |
| 376 | t.Run("a missing CI_VM_IMAGE is pulled, not built", func(t *testing.T) { |
| 377 | e, m, admin, sha := ciBuildEnv(t, "CI_VM_IMAGE", "registry.example.com/buildvm:1") |
| 378 | image, _ := builtImage(t, "") |
| 379 | m.programBuild("", 0, image) |
| 380 | runID := ciTrigger(e, admin, sha, nil) |
| 381 | if status := ciWaitForRun(e, runID); status != "success" { |
| 382 | t.Fatalf("run status = %q", status) |
| 383 | } |
| 384 | if !contains(m.pulledImages(), "registry.example.com/buildvm") || len(m.builtImages()) != 0 { |
| 385 | t.Errorf("pulls = %v, builds = %v", m.pulledImages(), m.builtImages()) |
| 386 | } |
| 387 | if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "Pulling the build VM image registry.example.com/buildvm:1") { |
| 388 | t.Errorf("log = %q", log) |
| 389 | } |
| 390 | }) |
| 391 | } |
| 392 |