package build import ( "bytes" "os" "path/filepath" "strings" "syscall" "testing" "werkdock/internal/engine" "werkdock/internal/store" ) // fakeEngine records the specs it is asked to run and lets a test act // on the work tree the way a real RUN step would. type fakeEngine struct { specs []engine.RunSpec code int do func(spec engine.RunSpec) error } func (f *fakeEngine) Run(spec engine.RunSpec) (int, error) { f.specs = append(f.specs, spec) if f.do != nil { if err := f.do(spec); err != nil { return 0, err } } return f.code, nil } func (f *fakeEngine) Start(engine.RunSpec) (engine.Process, error) { panic("a build does not start background processes") } // baseImage writes a minimal base rootfs: enough of a Debian shape for // the apt fragment to have a home, plus whatever files the test wants. func baseImage(t *testing.T, files map[string]string) string { t.Helper() rootfs := filepath.Join(t.TempDir(), "base") for _, dir := range []string{"etc/apt/apt.conf.d", "usr/bin", "bin"} { if err := os.MkdirAll(filepath.Join(rootfs, dir), 0o755); err != nil { t.Fatal(err) } } for name, content := range files { path := filepath.Join(rootfs, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatal(err) } } return rootfs } func buildFixture(t *testing.T, dockerfile string, context map[string]string, base string) (store.Store, *fakeEngine, Options) { t.Helper() dir := t.TempDir() st := store.Store{Root: filepath.Join(dir, "store")} contextDir := filepath.Join(dir, "context") if err := os.MkdirAll(contextDir, 0o755); err != nil { t.Fatal(err) } for name, content := range context { path := filepath.Join(contextDir, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } path := filepath.Join(dir, "Dockerfile") if err := os.WriteFile(path, []byte(dockerfile), 0o644); err != nil { t.Fatal(err) } eng := &fakeEngine{} return st, eng, Options{ Dockerfile: path, Context: contextDir, Tag: "built:1", Backend: CopyBackend{}, Engine: eng, Resolve: func(string) (string, store.ImageMeta, error) { return base, store.ImageMeta{Config: store.ImageConfig{Env: []string{"PATH=/usr/bin:/bin"}}}, nil }, Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, } } func TestRunBuildsAnOrdinaryFlatImage(t *testing.T) { base := baseImage(t, map[string]string{"usr/bin/fakeroot": "#!/bin/sh\n", "etc/os-release": "ID=debian\n"}) st, eng, opts := buildFixture(t, ` # a build environment FROM base:trixie ENV LANG=C.UTF-8 WORKDIR /srv RUN apt-get update \ && apt-get install -y jq COPY app /srv/app COPY tools/ /usr/local/tools/ ENTRYPOINT ["/srv/app"] CMD ["--serve"] LABEL org.opencontainers.image.title="built" `, map[string]string{"app": "#!/bin/sh\necho hello\n", "tools/t1": "one\n", "tools/sub/t2": "two\n"}, base) eng.do = func(spec engine.RunSpec) error { // what a real RUN step would leave behind return os.WriteFile(filepath.Join(spec.RootFS, "usr/bin/jq"), []byte("jq\n"), 0o755) } result, err := Run(st, opts) if err != nil { t.Fatal(err) } if result.Reference != "built:1" { t.Errorf("reference %q", result.Reference) } rootfs, err := st.RootFS("built:1") if err != nil { t.Fatalf("the image is not in the store: %v", err) } for path, want := range map[string]string{ "usr/bin/jq": "jq\n", // the RUN step's result "srv/app": "#!/bin/sh\necho hello\n", "usr/local/tools/t1": "one\n", // a directory source copies its CONTENTS "usr/local/tools/sub/t2": "two\n", "etc/os-release": "ID=debian\n", // the base came along } { got, err := os.ReadFile(filepath.Join(rootfs, path)) if err != nil { t.Errorf("%s: %v", path, err) continue } if string(got) != want { t.Errorf("%s: got %q, want %q", path, got, want) } } meta, err := st.Meta("built:1") if err != nil { t.Fatal(err) } config := meta.Config if want := []string{"/srv/app"}; len(config.Entrypoint) != 1 || config.Entrypoint[0] != want[0] { t.Errorf("entrypoint %q", config.Entrypoint) } if len(config.Cmd) != 1 || config.Cmd[0] != "--serve" { t.Errorf("cmd %q", config.Cmd) } if config.WorkingDir != "/srv" { t.Errorf("workdir %q", config.WorkingDir) } if config.Labels["org.opencontainers.image.title"] != "built" { t.Errorf("labels %v", config.Labels) } if !contains(config.Env, "LANG=C.UTF-8") || !contains(config.Env, "PATH=/usr/bin:/bin") { t.Errorf("env %q: the base's env and the Dockerfile's ENV both belong in it", config.Env) } if meta.Source != opts.Dockerfile { t.Errorf("source %q, want the Dockerfile", meta.Source) } } func TestRunStepGetsTheWritableRootAndBothUidLevers(t *testing.T) { base := baseImage(t, map[string]string{"usr/bin/fakeroot": "#!/bin/sh\n"}) st, eng, opts := buildFixture(t, "FROM base\nWORKDIR /work\nENV TZ=UTC\nRUN apt-get install -y nginx\n", nil, base) var fragment string eng.do = func(spec engine.RunSpec) error { raw, err := os.ReadFile(filepath.Join(spec.RootFS, "etc/apt/apt.conf.d/00werkdock-sandbox")) fragment = string(raw) return err } if _, err := Run(st, opts); err != nil { t.Fatal(err) } if len(eng.specs) != 1 { t.Fatalf("%d specs, want one RUN step", len(eng.specs)) } spec := eng.specs[0] if !spec.WritableRoot { t.Error("a build step binds the root read-write") } if want := []string{"/usr/bin/fakeroot", "--", "/bin/sh", "-c", "apt-get install -y nginx"}; !equal(spec.Command, want) { t.Errorf("command %q, want %q", spec.Command, want) } if !hasEnv(spec.Env, "FAKEROOTDONTTRYCHOWN", "1") { t.Errorf("env %v: fakeroot without FAKEROOTDONTTRYCHOWN does not help (measured)", spec.Env) } if !hasEnv(spec.Env, "TZ", "UTC") { t.Errorf("env %v: the image's ENV applies to the following steps", spec.Env) } if spec.Workdir != "/work" { t.Errorf("workdir %q, want the image's WORKDIR", spec.Workdir) } if !strings.Contains(fragment, `APT::Sandbox::User "root"`) { t.Errorf("the apt fragment was not in the tree during the step: %q", fragment) } // ... and it is not in the finished image. rootfs, err := st.RootFS("built:1") if err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(rootfs, "etc/apt/apt.conf.d/00werkdock-sandbox")); !os.IsNotExist(err) { t.Errorf("the apt fragment stayed in the image: %v", err) } } func TestRunToleratesAStepThatRemovedTheAptFragment(t *testing.T) { base := baseImage(t, nil) st, eng, opts := buildFixture(t, "FROM base\nRUN rm -rf /etc/apt/apt.conf.d\n", nil, base) eng.do = func(spec engine.RunSpec) error { return os.RemoveAll(filepath.Join(spec.RootFS, "etc/apt/apt.conf.d")) } if _, err := Run(st, opts); err != nil { t.Fatalf("a step that removed the fragment must not fail the build: %v", err) } } func TestRunStepBindsTheResolverOnlyWhereTheBaseHasOne(t *testing.T) { if fi, err := os.Stat("/etc/resolv.conf"); err != nil || !fi.Mode().IsRegular() { t.Skip("the host has no regular /etc/resolv.conf to bind") } for _, c := range []struct { name string files map[string]string want int }{ {"with one", map[string]string{"etc/resolv.conf": "nameserver 127.0.0.1\n"}, 1}, {"without one", nil, 0}, } { base := baseImage(t, c.files) st, eng, opts := buildFixture(t, "FROM base\nRUN true\n", nil, base) if _, err := Run(st, opts); err != nil { t.Fatal(err) } if got := len(eng.specs[0].Mounts); got != c.want { t.Errorf("%s: %d mounts, want %d: %+v", c.name, got, c.want, eng.specs[0].Mounts) } } } func TestRunCommandNamesRunInAParseError(t *testing.T) { _, err := runCommand(`["/bin/true"`) if err == nil || !strings.HasPrefix(err.Error(), `RUN ["/bin/true": `) || strings.Contains(err.Error(), "CMD") { t.Errorf("the error must name RUN, not CMD: %v", err) } } func TestCopyRefusesASymlinkThatLeavesTheContext(t *testing.T) { outside := t.TempDir() secret := filepath.Join(outside, "id_rsa") if err := os.WriteFile(secret, []byte("host secret"), 0o600); err != nil { t.Fatal(err) } for _, c := range []struct{ name, link, target, dockerfile string }{ {"a linked file", "leak", secret, "FROM base\nCOPY leak /leak\n"}, {"a file below a linked directory", "sub", outside, "FROM base\nCOPY sub/id_rsa /leak\n"}, {"a linked file matched by a glob", "leak", secret, "FROM base\nCOPY le* /leak\n"}, } { t.Run(c.name, func(t *testing.T) { st, _, opts := buildFixture(t, c.dockerfile, nil, baseImage(t, nil)) if err := os.Symlink(c.target, filepath.Join(opts.Context, c.link)); err != nil { t.Fatal(err) } _, err := Run(st, opts) if err == nil || !strings.Contains(err.Error(), "leaves the build context") { t.Fatalf("a symlink out of the context must be refused, got %v", err) } if _, err := st.RootFS("built:1"); err == nil { t.Error("no image may be built from a host file") } }) } } func TestCopyFollowsASymlinkWithinTheContext(t *testing.T) { st, _, opts := buildFixture(t, "FROM base\nCOPY link /copied\nCOPY link /dir/\n", map[string]string{"real": "inside"}, baseImage(t, nil)) if err := os.Symlink("real", filepath.Join(opts.Context, "link")); err != nil { t.Fatal(err) } if _, err := Run(st, opts); err != nil { t.Fatal(err) } rootfs, err := st.RootFS("built:1") if err != nil { t.Fatal(err) } if raw, err := os.ReadFile(filepath.Join(rootfs, "copied")); err != nil || string(raw) != "inside" { t.Errorf("a link inside the context copies its target: %q, %v", raw, err) } if _, err := os.Stat(filepath.Join(rootfs, "dir", "link")); err != nil { t.Errorf("the copy into a directory keeps the link's name: %v", err) } } func TestRunCreatesTheWorkdirOfTheFinishedImage(t *testing.T) { st, _, opts := buildFixture(t, "FROM base\nWORKDIR /srv/app\nCMD [\"/bin/true\"]\n", nil, baseImage(t, nil)) if _, err := Run(st, opts); err != nil { t.Fatal(err) } rootfs, err := st.RootFS("built:1") if err != nil { t.Fatal(err) } if fi, err := os.Stat(filepath.Join(rootfs, "srv/app")); err != nil || !fi.IsDir() { t.Errorf("a run of the image needs its WORKDIR, as docker creates it: %v", err) } } func TestRunBuildsFromScratchWithoutAskingForIt(t *testing.T) { st, _, opts := buildFixture(t, "FROM scratch\nCOPY app /app\nENTRYPOINT [\"/app\"]\n", map[string]string{"app": "binary"}, "") opts.Resolve = func(reference string) (string, store.ImageMeta, error) { t.Errorf("scratch is no image to look up or pull, got a lookup of %s", reference) return "", store.ImageMeta{}, os.ErrNotExist } if _, err := Run(st, opts); err != nil { t.Fatal(err) } rootfs, err := st.RootFS("built:1") if err != nil { t.Fatal(err) } entries, err := os.ReadDir(rootfs) if err != nil || len(entries) != 1 || entries[0].Name() != "app" { t.Errorf("an image from scratch holds only what the steps put there: %v, %v", entries, err) } } func TestRunStepRunsUnwrappedWithoutFakerootInTheBase(t *testing.T) { base := baseImage(t, nil) st, eng, opts := buildFixture(t, "FROM base\nRUN true\n", nil, base) log := &bytes.Buffer{} opts.Stdout = log if _, err := Run(st, opts); err != nil { t.Fatal(err) } if want := []string{"/bin/sh", "-c", "true"}; !equal(eng.specs[0].Command, want) { t.Errorf("command %q, want %q", eng.specs[0].Command, want) } if !strings.Contains(log.String(), "no fakeroot") { t.Errorf("a base image without fakeroot must be reported: %q", log.String()) } } // The warning that belongs with store.copyTree: a build tree must be a // REAL copy. Through a hardlink into the base image a RUN step would // write into the base. func TestBuildTreeIsARealCopyOfTheBase(t *testing.T) { base := baseImage(t, map[string]string{"usr/bin/busybox": "binary\n"}) // two names, one inode — what busybox images look like if err := os.Link(filepath.Join(base, "usr/bin/busybox"), filepath.Join(base, "bin/ls")); err != nil { t.Fatal(err) } st, eng, opts := buildFixture(t, "FROM base\nRUN overwrite\n", nil, base) eng.do = func(spec engine.RunSpec) error { return os.WriteFile(filepath.Join(spec.RootFS, "usr/bin/busybox"), []byte("rewritten\n"), 0o755) } if _, err := Run(st, opts); err != nil { t.Fatal(err) } if got, err := os.ReadFile(filepath.Join(base, "usr/bin/busybox")); err != nil || string(got) != "binary\n" { t.Fatalf("the base image was modified by the build: %q (%v)", got, err) } rootfs, err := st.RootFS("built:1") if err != nil { t.Fatal(err) } // hardlinks inside the base stay hardlinks inside the copy if a, b := inode(t, filepath.Join(rootfs, "usr/bin/busybox")), inode(t, filepath.Join(rootfs, "bin/ls")); a != b { t.Errorf("the copy broke the base's internal hardlink: %d != %d", a, b) } if a, b := inode(t, filepath.Join(rootfs, "bin/ls")), inode(t, filepath.Join(base, "bin/ls")); a == b { t.Error("the copy is hardlinked to the base image, so a RUN step would write into the base") } } func TestRunLeavesNoHalfImageBehind(t *testing.T) { base := baseImage(t, nil) st, eng, opts := buildFixture(t, "FROM base\nRUN false\n", nil, base) eng.code = 1 _, err := Run(st, opts) if err == nil || !strings.Contains(err.Error(), "non-zero code: 1") { t.Fatalf("got %v, want the step's exit code", err) } if _, err := st.Dir("built:1"); err == nil { t.Error("a failed build must leave no image") } images, err := st.List() if err != nil { t.Fatal(err) } if len(images) != 0 { t.Errorf("store holds %v after a failed build", images) } if entries, err := os.ReadDir(filepath.Join(st.Root, "images")); err == nil && len(entries) != 0 { t.Errorf("a failed build left %v below images/", entries) } } func TestRunRefusesAnExistingReference(t *testing.T) { base := baseImage(t, nil) st, _, opts := buildFixture(t, "FROM base\nRUN true\n", nil, base) if _, err := Run(st, opts); err != nil { t.Fatal(err) } _, err := Run(st, opts) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Errorf("got %v, want a refusal naming rmi", err) } } func TestRunRefusesWhatStagesTwoAndThreeWillBring(t *testing.T) { base := baseImage(t, nil) tests := []struct { name string dockerfile string wantErr string }{ {"multi-stage FROM", "FROM base AS builder\nRUN true\n", "stage 3 of RFC 0006"}, {"a second FROM", "FROM base\nRUN true\nFROM other\n", "stage 3 of RFC 0006"}, {"COPY --from", "FROM base\nCOPY --from=builder /a /b\n", "stage 3 of RFC 0006"}, {"COPY --chown", "FROM base\nCOPY --chown=1000:1000 a /b\n", "maps one uid"}, {"FROM --platform", "FROM --platform=linux/arm64 base\nRUN true\n", "host's platform"}, {"COPY without a destination", "FROM base\nCOPY only-one\n", "at least one source"}, {"COPY outside the context", "FROM base\nCOPY ../outside /a\n", "leaves the build context"}, {"COPY of a missing file", "FROM base\nCOPY nothing-here /a\n", "no such file or directory"}, {"an absolute COPY source", "FROM base\nCOPY /etc/passwd /a\n", "relative to the build context"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { st, _, opts := buildFixture(t, tt.dockerfile, nil, base) _, err := Run(st, opts) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("got %v, want it to contain %q", err, tt.wantErr) } }) } } func TestRunNeedsATag(t *testing.T) { base := baseImage(t, nil) st, _, opts := buildFixture(t, "FROM base\nRUN true\n", nil, base) opts.Tag = "" if _, err := Run(st, opts); err == nil || !strings.Contains(err.Error(), "-t REPOSITORY") { t.Errorf("got %v, want a refusal naming -t", err) } } func TestCopyBackendSaysWhatItCannotDo(t *testing.T) { backend, how := Select() if backend.Name() != "copy" { t.Errorf("backend %q", backend.Name()) } if backend.CachesSteps() { t.Error("the copy backend caches no steps: a cached step would cost a whole tree copy") } if !strings.Contains(how, "no per-step cache") { t.Errorf("Select must say what the backend does: %q", how) } } func contains(list []string, want string) bool { for _, e := range list { if e == want { return true } } return false } func equal(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } func hasEnv(env []engine.EnvVar, key, value string) bool { for _, e := range env { if e.Key == key { return e.Value == value } } return false } func inode(t *testing.T, path string) uint64 { t.Helper() fi, err := os.Stat(path) if err != nil { t.Fatal(err) } st, ok := fi.Sys().(*syscall.Stat_t) if !ok { t.Fatalf("%s: no stat", path) } return st.Ino }