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

91 lines
4.1 KiB
Go

// Package build builds an image from a Dockerfile (RFC 0006): the
// base image is resolved through the store, one writable work tree is
// created over it, the steps run against that tree, the metadata
// instructions go into image.json through store.ApplyChange, and the
// result is an ordinary flat werkdock image.
//
// Everything that depends on the bwrap version lives behind Backend.
// The parser, the instructions, COPY, the uid levers, the CLI, and the
// commit are the same behind either implementation.
package build
import (
"werkdock/internal/engine"
)
// Backend opens the writable work tree a build's steps run against.
//
// It is the ONLY part of the build that depends on what bwrap can do.
// Two implementations are foreseen (RFC 0006):
//
// - CopyBackend, the one implemented here, works on the bwrap 0.8 the
// Hostsharing webspaces run today: a real full copy of the base
// rootfs, bound read-write, committed by renaming the staging
// directory. No per-step cache.
// - An overlay backend, possible from bwrap 0.9 (Debian 13 brings
// 0.11) when bwrap is not installed setuid: `--overlay-src` on the
// base, stacked over the upper directories of the steps before it,
// `--overlay` on this step's own. Measured to work unprivileged,
// and the upper directory is already a layer diff — deletions
// appear as character devices 0:0, which is what OCI writes as
// `.wh.NAME`. That makes a per-step cache nearly free, which is
// why the cache waits for it: without a union mount every cached
// step would cost a whole tree copy.
//
// The copy backend stays after that upgrade — as the fallback for a
// bwrap that is too old or setuid, and for single-shot builds, whose
// commit is a rename rather than a walk over upper directories.
type Backend interface {
// Name identifies the backend in build output and in doctor.
Name() string
// CachesSteps reports whether a step's result can be kept and
// reused by a later build. Only an overlay backend can.
CachesSteps() bool
// Open prepares a work tree over base — an image's rootfs — below
// dir, which the caller owns and removes.
Open(base, dir string) (Tree, error)
}
// Tree is one build's writable rootfs.
type Tree interface {
// Step prepares step n (0-based, in build order).
Step(n int) (Step, error)
// Commit returns the host path of the finished flat rootfs, which
// lies at <dir>/rootfs — where the store's staging expects it.
// The caller owns it afterwards and renames it into the store.
Commit() (rootfs string, err error)
// Close releases what the tree holds — mounts, overlay work
// directories, staging it did not hand over. It is safe to call
// after Commit.
Close() error
}
// Step is how one build step reaches the tree.
//
// Nothing outside a backend may set a RunSpec's root fields, and
// Writable is a host directory rather than a tree: that is what keeps
// the boundary where it belongs. An overlay backend returns its own
// step's upper directory here, which is where a COPY of a whole file
// has to land anyway. It would not be where a deletion lands — in an
// upper directory that is a character device 0:0, and mknod is EPERM
// in the sandbox (CapEff is 0, measured) — but no instruction of
// stages 1 to 3 deletes, and an overlay commit reads whiteouts on the
// host, where creating one is not needed.
type Step interface {
// Root sets the root-of-sandbox fields of a RUN step's spec: the
// copy backend sets RootFS and WritableRoot, an overlay backend
// the overlay sources and this step's upper directory.
Root(spec *engine.RunSpec)
// Writable is the host directory this step's own file writes land
// in, with the image's paths below it.
Writable() string
}
// Select returns the backend to build with on this host, and the
// reason. Today that is always the copy backend; the overlay backend
// plugs in here, behind a probe of bwrap's version and of whether it
// is installed setuid (bwrap refuses overlay then, per its NEWS).
func Select() (Backend, string) {
return CopyBackend{}, "copy backend: a full copy of the base rootfs, bound read-write; no per-step cache"
}