Files
7504b97235
werkator build succeeded after 00:06
A Dockerfile builds on the target, and two backends for one interface (0.12.0) (#14)
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
2026-10-20 15:41:48 +02:00

129 lines
4.7 KiB
Go

package build
import (
"reflect"
"strings"
"testing"
)
func TestParseReadsTheStageOneInstructions(t *testing.T) {
tests := []struct {
name string
in string
want []Instruction
}{
{
"comments and blank lines are dropped",
"# a build environment\n\nFROM debian:trixie\n\n# install\nRUN apt-get update\n",
[]Instruction{
{Keyword: "FROM", Args: "debian:trixie", Line: 3},
{Keyword: "RUN", Args: "apt-get update", Line: 6},
},
},
{
"a line continuation joins into one instruction",
"FROM debian:trixie\nRUN apt-get update \\\n && apt-get install -y jq \\\n && rm -rf /var/lib/apt/lists/*\n",
[]Instruction{
{Keyword: "FROM", Args: "debian:trixie", Line: 1},
{Keyword: "RUN", Args: "apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*", Line: 2},
},
},
{
"a comment inside a continuation is dropped, as in docker",
"FROM base\nRUN one \\\n# explaining the second half\n && two\n",
[]Instruction{
{Keyword: "FROM", Args: "base", Line: 1},
{Keyword: "RUN", Args: "one && two", Line: 2},
},
},
{
"keywords are case-insensitive and normalized",
"from base\nrun true\ncmd [\"/bin/sh\"]\n",
[]Instruction{
{Keyword: "FROM", Args: "base", Line: 1},
{Keyword: "RUN", Args: "true", Line: 2},
{Keyword: "CMD", Args: `["/bin/sh"]`, Line: 3},
},
},
{
"the metadata instructions pass through verbatim",
"FROM base\nENV JAVA_HOME=/opt/java\nWORKDIR /work\nLABEL org.opencontainers.image.title=\"build\"\nENTRYPOINT [\"/entry\"]\nCMD [\"-v\"]\nVOLUME /data\nUSER 1000\n",
[]Instruction{
{Keyword: "FROM", Args: "base", Line: 1},
{Keyword: "ENV", Args: "JAVA_HOME=/opt/java", Line: 2},
{Keyword: "WORKDIR", Args: "/work", Line: 3},
{Keyword: "LABEL", Args: `org.opencontainers.image.title="build"`, Line: 4},
{Keyword: "ENTRYPOINT", Args: `["/entry"]`, Line: 5},
{Keyword: "CMD", Args: `["-v"]`, Line: 6},
{Keyword: "VOLUME", Args: "/data", Line: 7},
{Keyword: "USER", Args: "1000", Line: 8},
},
},
{
"trailing whitespace and a final line without a newline",
"FROM base \nCOPY app /srv/app",
[]Instruction{
{Keyword: "FROM", Args: "base", Line: 1},
{Keyword: "COPY", Args: "app /srv/app", Line: 2},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(strings.NewReader(tt.in))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("instructions:\n got %+v\nwant %+v", got, tt.want)
}
})
}
}
func TestParseRefusesWithTheReason(t *testing.T) {
tests := []struct {
name string
in string
wantErr string
}{
{"no FROM first", "RUN true\n", "begins with FROM"},
{"empty file", "# nothing but a comment\n", "holds no instruction"},
{"unknown instruction", "FROM base\nFRobNICATE x\n", "unknown instruction FROBNICATE"},
{"EXPOSE", "FROM base\nEXPOSE 8080\n", "shares the host's network"},
{"HEALTHCHECK", "FROM base\nHEALTHCHECK CMD curl -f http://localhost/\n", "no daemon to poll"},
{"STOPSIGNAL", "FROM base\nSTOPSIGNAL SIGQUIT\n", "stop sends SIGTERM"},
{"ONBUILD", "FROM base\nONBUILD RUN true\n", "ONBUILD trigger"},
{"MAINTAINER", "FROM base\nMAINTAINER me\n", "deprecated by docker itself"},
{"ARG is a later stage", "FROM base\nARG VERSION=1\n", "stage 2 of RFC 0006"},
{"SHELL is a later stage", "FROM base\nSHELL [\"/bin/bash\", \"-c\"]\n", "stage 2 of RFC 0006"},
{"ADD is a later stage", "FROM base\nADD app.tar /srv\n", "stage 2 of RFC 0006"},
{"an instruction without an argument", "FROM base\nRUN\n", "RUN needs an argument"},
{"the refusal names the line", "FROM base\nRUN true\nEXPOSE 80\n", "line 3"},
{"RUN --mount", "FROM base\nRUN --mount=type=cache,target=/var/cache/apt apt-get update\n", "RUN --mount is not supported: a RUN step cannot mount"},
{"RUN --network", "FROM base\nRUN --network=none true\n", "RUN --network is not supported"},
{"RUN --security", "FROM base\nRUN --security=insecure true\n", "RUN --security is not supported"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(strings.NewReader(tt.in))
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("got error %v, want it to contain %q", err, tt.wantErr)
}
})
}
}
func TestChangesTreeSeparatesStepsFromMetadata(t *testing.T) {
for _, keyword := range []string{"FROM", "RUN", "COPY"} {
if !(Instruction{Keyword: keyword}).changesTree() {
t.Errorf("%s changes the work tree", keyword)
}
}
for _, keyword := range []string{"ENTRYPOINT", "CMD", "ENV", "WORKDIR", "USER", "VOLUME", "LABEL"} {
if (Instruction{Keyword: keyword}).changesTree() {
t.Errorf("%s is metadata, not a tree change", keyword)
}
}
}