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
65 lines
2.3 KiB
Go
65 lines
2.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseBuildTakesDockersFlags(t *testing.T) {
|
|
opts, err := parseBuild([]string{"-f", "docker/Dockerfile.build", "-t", "ghcr.io/example/build:2026.10", "--no-cache", "ctx"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if opts.Dockerfile != "docker/Dockerfile.build" {
|
|
t.Errorf("dockerfile %q", opts.Dockerfile)
|
|
}
|
|
if opts.Tag != "ghcr.io/example/build:2026.10" {
|
|
t.Errorf("tag %q", opts.Tag)
|
|
}
|
|
if opts.Context != "ctx" || !opts.NoCache {
|
|
t.Errorf("context %q, no-cache %v", opts.Context, opts.NoCache)
|
|
}
|
|
}
|
|
|
|
func TestParseBuildDefaultsTheDockerfileToTheContext(t *testing.T) {
|
|
opts, err := parseBuild([]string{"--tag", "built", "/srv/project"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := filepath.Join("/srv/project", "Dockerfile"); opts.Dockerfile != want {
|
|
t.Errorf("dockerfile %q, want %q", opts.Dockerfile, want)
|
|
}
|
|
}
|
|
|
|
func TestParseBuildRefusesLoudly(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string
|
|
}{
|
|
{"no context", []string{"-t", "x"}, "needs a CONTEXT"},
|
|
{"no tag", []string{"ctx"}, "-t REPOSITORY"},
|
|
{"two tags", []string{"-t", "a", "-t", "b", "ctx"}, "one -t per build"},
|
|
{"an invalid reference", []string{"-t", "Not A Ref", "ctx"}, "invalid"},
|
|
{"a second argument", []string{"-t", "x", "ctx", "extra"}, `unexpected argument "extra"`},
|
|
{"a Dockerfile on stdin", []string{"-t", "x", "-"}, "needs no context"},
|
|
{"--build-arg", []string{"-t", "x", "--build-arg", "V=1", "ctx"}, "stage 2 of RFC 0006"},
|
|
{"--target", []string{"-t", "x", "--target", "builder", "ctx"}, "stage 3 of RFC 0006"},
|
|
{"--platform", []string{"-t", "x", "--platform", "linux/arm64", "ctx"}, "host's platform"},
|
|
{"--secret", []string{"-t", "x", "--secret", "id=a", "ctx"}, "CapEff is 0"},
|
|
{"--cache-from", []string{"-t", "x", "--cache-from", "a", "ctx"}, "no layer cache"},
|
|
{"--squash", []string{"-t", "x", "--squash", "ctx"}, "one flat tree already"},
|
|
{"--push", []string{"-t", "x", "--push", "ctx"}, "no push verb"},
|
|
{"an unknown flag", []string{"-t", "x", "--frobnicate", "ctx"}, "flag provided but not defined"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := parseBuild(tt.args)
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("got %v, want it to contain %q", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|