package builder import ( "context" "errors" "os" "path/filepath" "strings" "testing" ) // Issue 1834: a build node's root disk filled and every check there ended "the store never became ready". A node // frees what each build and check leaves, refuses to take work under a floor of free space, and a store that does // not come up says why. func init() { // The disk the tests run on is none of their business; the tests of the floor say their own. FreeBytes = func(string) (uint64, error) { return 500 << 30, nil } } func withFree(t *testing.T, bytes uint64) { t.Helper() was := FreeBytes FreeBytes = func(string) (uint64, error) { return bytes, nil } t.Cleanup(func() { FreeBytes = was }) } // 1834/AC2: given 3 GiB free, when a check or a build is asked, then it refuses at once, saying so, running nothing. func TestACheckUnderTheFloorOfFreeSpaceRefusesSayingSo(t *testing.T) { withFree(t, 3<<30) var ran []string run := func(_ context.Context, _ string, name string, args ...string) (string, error) { ran = append(ran, name+" "+strings.Join(args, " ")) return "", nil } _, err := Check(context.Background(), run, CheckSpec{ID: "build-1"}, t.TempDir(), "registry.invalid", GitCredential{}, nil) if err == nil || !strings.Contains(err.Error(), "the build node has 3 GiB free on /, under the floor of 20 GiB") { t.Fatalf("a check on a full disk said %v", err) } if len(ran) != 0 { t.Errorf("a refused check ran %v", ran) } } func TestABuildUnderTheFloorOfFreeSpaceRefusesSayingSo(t *testing.T) { withFree(t, 3<<30) r := &recorded{} _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) if err == nil || !strings.Contains(err.Error(), "under the floor of 20 GiB") { t.Fatalf("a build on a full disk said %v", err) } if len(r.ran) != 0 { t.Errorf("a refused build ran %v", r.ran) } } // The workspace's filesystem is read too, and a workspace not made yet is read at its nearest folder. func TestTheWorkspacesFilesystemIsReadToo(t *testing.T) { was := FreeBytes t.Cleanup(func() { FreeBytes = was }) missing := filepath.Join(t.TempDir(), "not", "yet") FreeBytes = func(p string) (uint64, error) { if p == "/" { return 100 << 30, nil } if _, err := os.Stat(p); err != nil { t.Errorf("read the free space of %s, which is not there", p) } return 1 << 30, nil } err := RoomToWork("/", missing) if err == nil || !strings.Contains(err.Error(), "1 GiB free on "+missing) { t.Errorf("the workspace's disk, under the floor, said %v", err) } FreeBytes = func(string) (uint64, error) { return 0, errors.New("no") } if RoomToWork("/") == nil { t.Error("a disk that cannot be read was taken as having room") } } // 1834/AC3: given a store that stops at once, when the check waits for it, then its exit code and last log lines are // in the error and the container is removed afterwards, not by --rm before anyone could read it. func TestAStoreThatDiesSaysWhyItDied(t *testing.T) { var ran []string run := func(_ context.Context, _ string, name string, args ...string) (string, error) { line := name + " " + strings.Join(args, " ") ran = append(ran, line) switch { case args[0] == "exec": return "", errors.New("Error response from daemon: container is not running") case args[0] == "inspect": return "exited exit code 1\n", nil case args[0] == "logs": return "initdb: error: could not create directory: No space left on device\n", nil } return "", nil } err := waitReady(context.Background(), run, "build-1-store", []string{"pg_isready"}, 1) for _, want := range []string{"build-1-store never became ready", "exit code 1", "No space left on device"} { if err == nil || !strings.Contains(err.Error(), want) { t.Errorf("the verdict %q lacks %q", err, want) } } logs := "" for _, l := range ran { if strings.HasPrefix(l, "docker logs") { logs = l } } if logs != "docker logs --tail 20 build-1-store" { t.Errorf("the log was read as %q", logs) } } func TestTheThrowawayStoreIsNotStartedWithRm(t *testing.T) { var started []string run := func(_ context.Context, _ string, name string, args ...string) (string, error) { if args[0] == "run" { started = args } return "127.0.0.1:40000\n", nil } if _, err := throwaway(context.Background(), run, run, "build-1-store", "postgres:17-alpine", 5432, nil, nil); err != nil { t.Fatal(err) } for _, a := range started { if a == "--rm" { t.Errorf("started with --rm, which erases why it died: %v", started) } } } // 1834/AC1: given an image built for a check or a walk and published to the registry, when the build ends, then the // local image tag, the narrowed source and the context clone it left are gone; an image published nowhere (a mesh // being raised) is kept, since it is the artifact. // pushedNamed is a publisher that tags the image as / to push it, as the registry one does. type pushedNamed struct{ *recorded } func (pushedNamed) PushedNames(repository string) []string { return []string{"registry.invalid/" + repository} } func TestABuildRemovesWhatItLeftOnTheNode(t *testing.T) { r := &recorded{ contents: map[string]string{"modules/m/" + ManifestName: anImageWithAContext, "modules/m/Dockerfile": "FROM scratch\n"}, secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": {"go.mod": "module x\n"}}, } workspace := t.TempDir() if _, err := Build(context.Background(), r.run, pushedNamed{r}, "https://forge.invalid/c.git", "modules/m", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil { t.Fatal(err) } // Both names go in one rmi: the layers stay while either is left (the push tagged the image with the registry's). if !strings.Contains(strings.Join(r.ran, "\n"), "docker rmi m-server:c0ffeec0 registry.invalid/m/server") { t.Errorf("the image built for it and its pushed name were not removed: %v", r.ran) } left, _ := filepath.Glob(filepath.Join(workspace, "*")) if len(left) != 0 { t.Errorf("the build left %v in its workspace", left) } } const anImageWithAContext = `{"module":"m","version":"1", "build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile", "context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}}]}}` func TestAnImageKeptLocallyIsNotRemoved(t *testing.T) { r := &recorded{contents: map[string]string{"modules/m/" + ManifestName: anImageWithAContext, "modules/m/Dockerfile": "FROM scratch\n"}} local := Local{Run: func(ctx context.Context, dir, name string, args ...string) (string, error) { if args[0] == "image" { return "sha256:" + strings.Repeat("a", 64) + "\n", nil } return r.run(ctx, dir, name, args...) }} if _, err := Build(context.Background(), local.Run, local, "https://forge.invalid/c.git", "modules/m", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil); err != nil { t.Fatal(err) } if strings.Contains(strings.Join(r.ran, "\n"), "docker rmi") { t.Errorf("the artifact of a mesh being raised was removed: %v", r.ran) } } // The held image the comparison pulls to read is let go of once read. func TestTheHeldImageAComparisonPullsIsRemovedAfterReading(t *testing.T) { var ran []string run := func(_ context.Context, _ string, name string, args ...string) (string, error) { ran = append(ran, name+" "+strings.Join(args, " ")) if args[0] == "create" { return "abcdef0123456789\n", nil } if args[0] == "image" { return "", errors.New("stop here") } return "", nil } _, _ = imageContent(context.Background(), run, t.TempDir(), "reg.invalid/m/server@sha256:"+strings.Repeat("a", 64)) if got := ran[len(ran)-1]; !strings.HasPrefix(got, "docker rmi reg.invalid/m/server@sha256:") { t.Errorf("the last command was %q, not the removal of the pulled image: %v", got, ran) } } func TestAnImageThatCannotBeRemovedIsNotSaidRemoved(t *testing.T) { var said []string run := func(context.Context, string, string, ...string) (string, error) { return "", errors.New("image is being used") } releaseImage(context.Background(), run, []string{"a:1"}, func(step, format string, args ...any) { said = append(said, format) }) for _, s := range said { if strings.Contains(s, "is removed") { t.Errorf("said removed: %q", s) } } }