werkator build succeeded after 00:06
RFC 0004 had ruled `build` out as "not planned: needs writable rootfs snapshots per step and `pull` for FROM". Both halves expired: `pull` arrived in 0.3.0, and per-step snapshots are what a build *cache* needs, not what a build needs — one writable tree over the base is enough. ## RFC 0006 [docs/rfcs/0006-dockerfile-builds.md](docs/rfcs/0006-dockerfile-builds.md) decides the build model, cuts the one version-dependent interface, and argues the "no layers" question from measurements rather than from the README's shorthand. Measured on a `debian:trixie` rootfs with bwrap 0.11.1 and a `0 <uid> 1` map — every number in the RFC was taken, not reasoned about: - A writable root bind (`--bind` instead of `--ro-bind`) works unprivileged. - `CapEff` is `0000000000000000`: `mount -t tmpfs` and `mknod` are both EPERM, so the sandbox can mount nothing itself and every mount must stand in bwrap's argv. **That** is what "no layers" was ever about. - Plain `apt-get update` dies on apt's `_apt` user (`seteuid 42 failed`, `Method http has died unexpectedly`); `APT::Sandbox::User root` fixes it. - `apt-get install nginx` still fails (`chown: changing ownership of '/var/log/nginx': Invalid argument`, dpkg exit 100). `fakeroot` **alone does not help** — libfakeroot is demonstrably active and its `chown` wrapper still reports the real syscall's EINVAL. `fakeroot` plus `FAKEROOTDONTTRYCHOWN=1` does. - Overlay works unprivileged on bwrap 0.11, `--overlay-src` stacks, and the upper directory is already a layer diff: changed files as themselves, deletions as character devices 0:0 — what OCI writes as `.wh.NAME`. Two snags: the workdir's `work/` is left at `d---------`, and bwrap refuses overlay when installed setuid. - With a writable root, bwrap creates a missing bind mountpoint itself and it stays in the tree — so a build binds only `/etc/resolv.conf`, which base images already have. ## The interface (the point of the RFC) `build.Backend` / `build.Tree` / `build.Step`. The boundary is exactly "how does a writable tree over a base come into being, and how is its result harvested"; no caller ever sets a `RunSpec`'s root fields. Everything else — parser, instructions, COPY, multi-stage, CLI, uid levers, commit — is identical in both worlds. - **Copy backend** (implemented): a real full copy of the base, root bound read-write, commit is the `rename`, no cache. It stays after the Debian 13 upgrade, as the fallback `doctor` picks when bwrap is too old or setuid, and for single-shot builds. - **Overlay backend** (deliberately *not* implemented): designed, with the measurements above as the evidence that it fits. The per-step cache waits for it, because without a union mount a cached step costs a whole tree copy. The warning that belongs with this, in the RFC and as a comment in the code: `store.copyTree` hardlinks so a second tag costs no data — there both copies are read-only. For a build tree it is unusable, because a `RUN` step writing in place through such a hardlink would modify the base image. ## Stage 1, implemented `werkdock build [-f Dockerfile] -t IMAGE CONTEXT` with docker's flags. `FROM`, `RUN`, `COPY` plus the metadata instructions `import -c` already knew, through the same `store.ApplyChange`. Staging and commit reuse the mechanics `import` has had since 0.11.0 (`<image>.tmp`, the per-image `flock`, `rename`), now exported as `store.Staging`. Werkdock sets both uid levers itself so the Dockerfile stays portable; the base image needs `fakeroot` installed, and `build` says so up front when it has not. `engine.RunSpec` gained `WritableRoot`, and a writable-root sandbox gets **no** implicit tmpfs over `/tmp`, `/root`, `/run` — with one, what a `RUN` step writes there would vanish in the next step while docker keeps it in the image. Refused loudly rather than ignored: `EXPOSE`, `HEALTHCHECK`, `STOPSIGNAL`, `ONBUILD`, `MAINTAINER` at parse time; `ARG`, `SHELL`, `ADD` and multi-stage named as stages 2 and 3; BuildKit's `RUN --mount` with the CapEff measurement as the reason; and the dozen `docker build` flags werkdock cannot keep the promise of. ## Verified - `go test ./...`, `go vet ./...`, `gofmt -l .` — green and empty. - A real sandbox integration test builds `FROM`/`WORKDIR`/`COPY`/`RUN`/`CMD` and then *runs* the result; it skips without bwrap/userns like the existing ones. - The apt path, which the levers exist for, is a second integration test pointed at a Debian base (`WERKDOCK_TEST_BASE_ROOTFS`). Run here: `nginx-common` configures, `dpkg-query` in the built image reports `install ok installed`, the apt fragment is gone from the result. - End to end through the CLI: `import` a 143 MB `debian:trixie` base with fakeroot, `build -t probe:1` with `apt-get install -y nginx jq` in the Dockerfile — **6.8 s** — then `run --rm probe:1` prints what its `RUN` steps and its `COPY` produced. Follow-ups the RFC names without deciding: store dedup between image versions, keeping layer blobs as a transport and cache format (the one with the highest running cost today), `doctor IMAGE`, and `ADD` from a URL. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: claude-code <claude-code@javagil.de> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #14
508 lines
16 KiB
Go
508 lines
16 KiB
Go
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
|
|
}
|