package e2e import ( "archive/tar" "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/url" "strconv" "strings" "testing" "time" "hearthforge/internal/ci" ) // build_image runs in a build VM container. The mock engine plays that // container: it prints a log, exits with a code, and serves the image tar. const ciBuildTOML = ` image = "debian:latest" clone_project_to = "/ci/project" [on] manual = true [[steps]] name = "image" build_image = { image = "web", tags = ["$CI_COMMIT_SHORT_SHA", "latest", "$CI_COMMIT_TAG"], args = { REPO = "$CI_REPO_NAME" }, secrets = ["NPM_TOKEN"] } ` // dirTar builds a directory tar as the archive endpoint returns it. func dirTar(t *testing.T, top string, files map[string]string) []byte { t.Helper() var buf bytes.Buffer tw := tar.NewWriter(&buf) if err := tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { t.Fatal(err) } for name, body := range files { hdr := &tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(body))} if err := tw.WriteHeader(hdr); err != nil { t.Fatal(err) } tw.Write([]byte(body)) } tw.Close() return buf.Bytes() } func hexOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } // extraBlob is a blob the archive carries but the manifest does not name. var extraBlob = []byte("not-in-the-manifest") // builtImage returns a tar of a containers-image dir: layout, as the build // VM writes it, and the manifest digest. variant is "tamper" to corrupt the // layer, "no-layer" to leave it out, or "" for a good image. Every variant // carries extraBlob. func builtImage(t *testing.T, variant string) ([]byte, string) { t.Helper() config := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`) layer := []byte("layer-bytes") manifest := fmtManifest(hexOf(config), len(config), hexOf(layer), len(layer)) files := map[string]string{ "manifest.json": string(manifest), "version": "Directory Transport Version: 1.1\n", hexOf(config): string(config), hexOf(layer): string(layer), hexOf(extraBlob): string(extraBlob), } switch variant { case "tamper": files[hexOf(layer)] = "other-bytes" case "no-layer": delete(files, hexOf(layer)) } return dirTar(t, ".", files), regDigest(manifest) } // buildContainerRemoved reports whether the run's build VM container was // deleted after it was created. func buildContainerRemoved(m *mockDocker, runID int64) bool { name := "hearthforge-ci-" + strconv.FormatInt(runID, 10) + "-build" created := false for _, req := range m.containerRequests() { switch req { case "POST create?name=" + name: created = true case "DELETE " + name: if created { return true } } } return false } func fmtManifest(config string, configSize int, layer string, layerSize int) []byte { b, _ := json.Marshal(map[string]any{ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": map[string]any{ "mediaType": "application/vnd.docker.container.image.v1+json", "digest": "sha256:" + config, "size": configSize, }, "layers": []map[string]any{{ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "digest": "sha256:" + layer, "size": layerSize, }}, }) return b } // ciBuildEnv seeds the build config, the secret it names, and the context // directory in the CI container. extraEnv goes to ciEnv. func ciBuildEnv(t *testing.T, extraEnv ...string) (*env, *mockDocker, *session, string) { t.Helper() e, m, admin := ciEnv(t, extraEnv...) admin.post("/ci-repo/settings/ci-secrets", url.Values{ "name": {"NPM_TOKEN"}, "value": {"npm-s3cret"}, }).mustRedirect("/ci-repo/settings") sha := ciSeedToml(e, ciBuildTOML) m.reset() m.setArchive("/ci/project", dirTar(t, "project", map[string]string{"Containerfile": "FROM scratch\n"})) return e, m, admin, sha } func TestCIBuildImage(t *testing.T) { e, m, admin, sha := ciBuildEnv(t) image, digest := builtImage(t, "") m.programBuild("STEP 1/1: FROM scratch\nnpm-s3cret\n", 0, image) runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "success" { t.Fatalf("run status = %q, log %q", status, ciStep(e, runID, "image").Log) } step := ciStep(e, runID, "image") host := strings.TrimPrefix(e.Base, "http://") for _, want := range []string{ "STEP 1/1: FROM scratch", "Pushed " + host + "/ci-repo/web:" + sha[:8], "Pushed " + host + "/ci-repo/web:latest", "Digest " + digest, } { if !strings.Contains(step.Log, want) { t.Errorf("log lacks %q:\n%s", want, step.Log) } } if strings.Contains(step.Log, "npm-s3cret") { t.Error("the secret is not masked in the build log") } for _, tag := range []string{"latest", sha[:8]} { r := regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/manifests/"+tag, nil).mustStatus(200) if got := r.Header.Get("Docker-Content-Digest"); got != digest { t.Errorf("%s digest = %s, want %s", tag, got, digest) } } if tags := regTags(t, e, "ci-repo/web", ""); len(tags) != 2 { t.Errorf("tags = %v, the empty $CI_COMMIT_TAG must be dropped", tags) } vmImage, err := ci.DefaultVMImage() if err != nil { t.Fatal(err) } t.Run("the VM image is built from the embedded vm/ and logged", func(t *testing.T) { builds := m.builtImages() if len(builds) != 1 || builds[0].tag != vmImage { t.Fatalf("image builds = %v, want one of %s", builds, vmImage) } for _, f := range []string{"Containerfile", "run-vm", "guest/init"} { if !contains(builds[0].files, f) { t.Errorf("build context %v lacks %s", builds[0].files, f) } } for _, want := range []string{"Building the build VM image " + vmImage, "Step 1/20 : ARG ALPINE"} { if !strings.Contains(step.Log, want) { t.Errorf("log lacks %q", want) } } if len(m.pulledImages()) != 1 { t.Errorf("pulls = %v, want only the CI image", m.pulledImages()) } }) t.Run("the VM container gets KVM and nothing of the host", func(t *testing.T) { body := m.buildBody() if body["Image"] != vmImage { t.Errorf("Image = %v", body["Image"]) } host, _ := body["HostConfig"].(map[string]any) devices, _ := json.Marshal(host["Devices"]) if !strings.Contains(string(devices), `"PathOnHost":"/dev/kvm"`) { t.Errorf("Devices = %s", devices) } if host["Binds"] != nil || host["Privileged"] != nil { t.Errorf("HostConfig = %v", host) } }) t.Run("the job carries the context, args and secrets", func(t *testing.T) { if got := tarFiles(t, m.uploadTo("/in/context"))["project/Containerfile"]; got != "FROM scratch\n" { t.Errorf("context Containerfile = %q", got) } files := tarFiles(t, m.uploadTo("/in")) want := map[string]string{ "job/args/REPO": "ci-repo", "job/secrets/NPM_TOKEN": "npm-s3cret", } for name, body := range want { if files[name] != body { t.Errorf("%s = %q, want %q", name, files[name], body) } } }) t.Run("a blob the manifest does not name is not stored", func(t *testing.T) { regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/blobs/sha256:"+hexOf(extraBlob), nil).mustStatus(404) }) t.Run("the build VM container is removed", func(t *testing.T) { if !buildContainerRemoved(m, runID) { t.Errorf("requests = %v", m.containerRequests()) } }) } // tarFiles maps the regular files of a tar to their content. func tarFiles(t *testing.T, data []byte) map[string]string { t.Helper() out := map[string]string{} tr := tar.NewReader(bytes.NewReader(data)) for { h, err := tr.Next() if err != nil { break } var b bytes.Buffer b.ReadFrom(tr) if h.Typeflag == tar.TypeReg { out[h.Name] = b.String() } } return out } func TestCIBuildImageFailures(t *testing.T) { cases := []struct { name string exit int variant string env []string wantLog string }{ {"build fails", 1, "", nil, "Image build failed: the build failed"}, {"image too large", 3, "", nil, "Image build failed: the image is larger than CI_MAX_IMAGE_BYTES"}, {"VM does not start", 2, "", nil, "Image build failed: the build VM did not run (exit code 2)"}, {"layer does not match its digest", 0, "tamper", nil, "digest does not match"}, {"layer missing from the archive", 0, "no-layer", nil, "not uploaded"}, // run-vm enforces the cap too. This is the check that does not trust it. {"image over the cap despite exit 0", 0, "", []string{"CI_MAX_IMAGE_BYTES", "64"}, "larger than CI_MAX_IMAGE_BYTES"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e, m, admin, sha := ciBuildEnv(t, append([]string{"CI_VM_IMAGE", "localhost/test-vm:1"}, c.env...)...) m.setLocalImages("localhost/test-vm:1") image, _ := builtImage(t, c.variant) m.programBuild("hearthforge: failure\n", c.exit, image) runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "failure" { t.Fatalf("run status = %q", status) } if log := ciStep(e, runID, "image").Log; !strings.Contains(log, c.wantLog) { t.Errorf("log = %q, want %q", log, c.wantLog) } regAdmin(t, e, http.MethodGet, "/v2/ci-repo/web/manifests/latest", nil).mustStatus(404) if pulls := m.pulledImages(); contains(pulls, "localhost/test-vm") { t.Errorf("pulls = %v, the local VM image must not be pulled", pulls) } if builds := m.builtImages(); len(builds) != 0 { t.Errorf("image builds = %v, CI_VM_IMAGE must not be built", builds) } if !buildContainerRemoved(m, runID) { t.Errorf("build VM container not removed: %v", m.containerRequests()) } }) } } func TestCIBuildImageTimeout(t *testing.T) { e, m, admin := ciEnv(t, "CI_VM_IMAGE", "localhost/test-vm:1") sha := ciSeedToml(e, ` image = "debian:latest" clone_project_to = "/ci/project" [on] manual = true [[steps]] name = "image" timeout = 1 build_image = {} `) m.reset() m.setLocalImages("localhost/test-vm:1") m.setArchive("/ci/project", dirTar(t, "project", map[string]string{"Containerfile": "FROM scratch\n"})) m.programBuild("STEP 1/1\n", 0, nil) m.delayBuild(time.Minute) runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "failure" { t.Fatalf("run status = %q", status) } if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "Step timed out after 1s") { t.Errorf("log = %q", log) } if !buildContainerRemoved(m, runID) { t.Errorf("build VM container not removed: %v", m.containerRequests()) } } func TestCIBuildImageMissingSecret(t *testing.T) { e, m, admin := ciEnv(t) sha := ciSeedToml(e, ciBuildTOML) m.reset() runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "failure" { t.Fatalf("run status = %q", status) } if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "secret NPM_TOKEN is not set") { t.Errorf("log = %q", log) } if m.buildBody() != nil { t.Error("a build VM container was created") } } func TestCIBuildImageVMImage(t *testing.T) { t.Run("a second run reuses the built VM image", func(t *testing.T) { e, m, admin, sha := ciBuildEnv(t) image, _ := builtImage(t, "") for range 2 { m.programBuild("", 0, image) if status := ciWaitForRun(e, ciTrigger(e, admin, sha, nil)); status != "success" { t.Fatalf("run status = %q", status) } } if builds := m.builtImages(); len(builds) != 1 { t.Errorf("image builds = %d, want 1", len(builds)) } }) t.Run("a failed VM image build fails the step", func(t *testing.T) { e, m, admin, sha := ciBuildEnv(t) m.failImageBuild("apk: network unreachable") runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "failure" { t.Fatalf("run status = %q", status) } log := ciStep(e, runID, "image").Log if !strings.Contains(log, "cannot build the build VM image: apk: network unreachable") { t.Errorf("log = %q", log) } if m.buildBody() != nil { t.Error("a build VM container was created") } }) t.Run("a missing CI_VM_IMAGE is pulled, not built", func(t *testing.T) { e, m, admin, sha := ciBuildEnv(t, "CI_VM_IMAGE", "registry.example.com/buildvm:1") image, _ := builtImage(t, "") m.programBuild("", 0, image) runID := ciTrigger(e, admin, sha, nil) if status := ciWaitForRun(e, runID); status != "success" { t.Fatalf("run status = %q", status) } if !contains(m.pulledImages(), "registry.example.com/buildvm") || len(m.builtImages()) != 0 { t.Errorf("pulls = %v, builds = %v", m.pulledImages(), m.builtImages()) } if log := ciStep(e, runID, "image").Log; !strings.Contains(log, "Pulling the build VM image registry.example.com/buildvm:1") { t.Errorf("log = %q", log) } }) }