package build import ( "bytes" "os" "os/exec" "path/filepath" "strings" "testing" "werkdock/internal/engine" "werkdock/internal/store" ) // requireSandbox skips where bwrap or unprivileged user namespaces are // not usable, exactly as the engine's integration tests do. func requireSandbox(t *testing.T) { t.Helper() if _, err := exec.LookPath("bwrap"); err != nil { t.Skip("bwrap not installed") } if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil { t.Skipf("unprivileged user namespaces not usable here: %v", err) } } // TestBuildAndRunInARealSandbox builds a small image from a Dockerfile // in a real bwrap sandbox and then runs the result, the way `werkdock // run` would. The base image is assembled from the host's own shell // and its shared libraries, so the test needs no network and no // registry. The apt path — the one the uid levers exist for — is in // TestBuildInstallsWithApt below. func TestBuildAndRunInARealSandbox(t *testing.T) { requireSandbox(t) base := hostToolsImage(t, "/bin/sh", "/bin/cat") dir := t.TempDir() st := store.Store{Root: filepath.Join(dir, "store")} context := filepath.Join(dir, "context") if err := os.MkdirAll(context, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(context, "greeting"), []byte("from the context\n"), 0o644); err != nil { t.Fatal(err) } dockerfile := filepath.Join(dir, "Dockerfile") if err := os.WriteFile(dockerfile, []byte(` FROM base:probe WORKDIR /srv COPY greeting /srv/greeting RUN echo "from a RUN step" > /srv/built \ && cat /srv/greeting >> /srv/built CMD ["/bin/cat", "/srv/built"] `), 0o644); err != nil { t.Fatal(err) } log := &bytes.Buffer{} result, err := Run(st, Options{ Dockerfile: dockerfile, Context: context, Tag: "probe-built:1", Backend: CopyBackend{}, Resolve: staticBase(base), Stdout: log, Stderr: log, }) if err != nil { t.Fatalf("build failed: %v\n%s", err, log.String()) } if !strings.Contains(log.String(), "Successfully tagged probe-built:1") { t.Errorf("build log:\n%s", log.String()) } // Now run the built image the way `werkdock run` does: its CMD, // the rootfs bound read-only. rootfs, err := st.RootFS(result.Reference) if err != nil { t.Fatal(err) } meta, err := st.Meta(result.Reference) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code, err := (&engine.Bwrap{Stdout: &stdout, Stderr: &stderr}).Run(engine.RunSpec{ RootFS: rootfs, Workdir: meta.Config.WorkingDir, Command: meta.Config.Cmd, }) if err != nil || code != 0 { t.Fatalf("running the built image: code %d, err %v, stderr %q", code, err, stderr.String()) } if want := "from a RUN step\nfrom the context\n"; stdout.String() != want { t.Errorf("the built image printed %q, want %q", stdout.String(), want) } if meta.Config.WorkingDir != "/srv" { t.Errorf("workdir %q", meta.Config.WorkingDir) } } // TestBuildInstallsWithApt is the test the two uid levers exist for: // without `APT::Sandbox::User root` apt's methods die on seteuid, and // without fakeroot plus FAKEROOTDONTTRYCHOWN a package whose postinst // chowns (nginx-common) fails in dpkg — both measured (RFC 0006). // // It needs a Debian-shaped base image with apt and fakeroot, and // network access to a mirror, so it is pointed at one explicitly: // // WERKDOCK_TEST_BASE_ROOTFS=/path/to/unpacked/rootfs go test ./internal/build/ // // The rootfs is only read; the build works in a copy of it. func TestBuildInstallsWithApt(t *testing.T) { requireSandbox(t) base := os.Getenv("WERKDOCK_TEST_BASE_ROOTFS") if base == "" { t.Skip("set WERKDOCK_TEST_BASE_ROOTFS to an unpacked Debian rootfs with apt and fakeroot") } if fi, err := os.Stat(filepath.Join(base, "usr/bin/fakeroot")); err != nil || fi.IsDir() { t.Skipf("%s has no fakeroot; the apt path needs one in the base image", base) } dir := t.TempDir() st := store.Store{Root: filepath.Join(dir, "store")} context := filepath.Join(dir, "context") if err := os.MkdirAll(context, 0o755); err != nil { t.Fatal(err) } dockerfile := filepath.Join(dir, "Dockerfile") if err := os.WriteFile(dockerfile, []byte(` FROM base:trixie ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends nginx jq \ && rm -rf /var/lib/apt/lists/* CMD ["/usr/sbin/nginx", "-v"] `), 0o644); err != nil { t.Fatal(err) } log := &bytes.Buffer{} result, err := Run(st, Options{ Dockerfile: dockerfile, Context: context, Tag: "apt-built:1", Backend: CopyBackend{}, Resolve: staticBase(base), Stdout: log, Stderr: log, }) if err != nil { t.Fatalf("build failed: %v\n%s", err, log.String()) } rootfs, err := st.RootFS(result.Reference) if err != nil { t.Fatal(err) } // nginx-common's postinst is where a build without the levers // stops; dpkg records the package as installed only if it ran. var stdout, stderr bytes.Buffer code, err := (&engine.Bwrap{Stdout: &stdout, Stderr: &stderr}).Run(engine.RunSpec{ RootFS: rootfs, Command: []string{"/usr/bin/dpkg-query", "-W", "-f", "${Status}", "nginx"}, }) if err != nil || code != 0 { t.Fatalf("dpkg-query in the built image: code %d, err %v, stderr %q", code, err, stderr.String()) } if !strings.Contains(stdout.String(), "install ok installed") { t.Errorf("nginx is %q in the built image, want it configured", stdout.String()) } 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 built image: %v", err) } } func staticBase(rootfs string) func(string) (string, store.ImageMeta, error) { return func(string) (string, store.ImageMeta, error) { return rootfs, store.ImageMeta{}, nil } } // hostToolsImage assembles a base rootfs from host binaries and the // shared libraries ldd reports for them — a real, runnable image // without a registry. The test skips when a tool or ldd is missing. func hostToolsImage(t *testing.T, binaries ...string) string { t.Helper() if _, err := exec.LookPath("ldd"); err != nil { t.Skip("ldd not available to assemble a base rootfs") } rootfs := filepath.Join(t.TempDir(), "base") for _, binary := range binaries { for _, file := range append([]string{binary}, sharedLibraries(t, binary)...) { copyHostFile(t, rootfs, file) } } // A base image needs the mountpoints a sandbox brings in. for _, dir := range []string{"proc", "dev", "tmp", "root", "run", "etc"} { if err := os.MkdirAll(filepath.Join(rootfs, dir), 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(rootfs, "etc/resolv.conf"), nil, 0o644); err != nil { t.Fatal(err) } return rootfs } func sharedLibraries(t *testing.T, binary string) []string { t.Helper() out, err := exec.Command("ldd", binary).Output() if err != nil { t.Skipf("ldd %s: %v", binary, err) } var libraries []string for _, line := range strings.Split(string(out), "\n") { for _, field := range strings.Fields(line) { if strings.HasPrefix(field, "/") && strings.Contains(field, ".so") { libraries = append(libraries, field) } } } return libraries } // copyHostFile places one host file in the rootfs at its own path, // following a symlink to its target and keeping the link as well, so // that /bin/sh -> dash works inside the sandbox. func copyHostFile(t *testing.T, rootfs, file string) { t.Helper() target := filepath.Join(rootfs, file) if _, err := os.Lstat(target); err == nil { return } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { t.Fatal(err) } fi, err := os.Lstat(file) if err != nil { t.Skipf("%s is not on this host: %v", file, err) } if fi.Mode()&os.ModeSymlink != 0 { link, err := os.Readlink(file) if err != nil { t.Fatal(err) } resolved := link if !filepath.IsAbs(resolved) { resolved = filepath.Join(filepath.Dir(file), link) } copyHostFile(t, rootfs, resolved) if err := os.Symlink(link, target); err != nil { t.Fatal(err) } return } if err := copyFile(file, target, 0o755); err != nil { t.Fatal(err) } }