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

145 lines
5.6 KiB
Go

package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"werkdock/internal/build"
"werkdock/internal/registry"
"werkdock/internal/store"
)
// buildOptions is the parsed form of `werkdock build` flags,
// separated from execution so the parsing is testable (as parseRun is).
type buildOptions struct {
Dockerfile string
Context string
Tag string
Quiet bool
NoCache bool
}
// buildCmd is `docker build`: an image from a Dockerfile, built on this
// host (RFC 0006). `werkdock build [-f Dockerfile] -t IMAGE CONTEXT`.
func buildCmd(args []string) int {
opts, err := parseBuild(args)
if err != nil {
return fail(err)
}
st, err := store.Default()
if err != nil {
return fail(err)
}
backend, how := build.Select()
log := io.Writer(os.Stdout)
if opts.Quiet {
log = io.Discard
}
fmt.Fprintf(log, "werkdock: %s\n", how)
if opts.NoCache && !backend.CachesSteps() {
fmt.Fprintf(log, "werkdock: --no-cache is a no-op here; this backend caches no steps\n")
}
result, err := build.Run(st, build.Options{
Dockerfile: opts.Dockerfile,
Context: opts.Context,
Tag: opts.Tag,
Backend: backend,
Resolve: resolveBase(st),
Stdout: log,
Stderr: os.Stderr,
})
if err != nil {
return fail(err)
}
if opts.Quiet {
fmt.Println(result.ID)
}
return 0
}
// parseBuild parses the docker-shaped build flags. Docker flags whose
// promise werkdock cannot keep are registered and refused with a
// reason — never silently ignored (RFC 0002).
func parseBuild(args []string) (*buildOptions, error) {
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var tags stringList
dockerfile := fs.String("f", "", "Dockerfile to read (default CONTEXT/Dockerfile)")
fs.StringVar(dockerfile, "file", "", "Dockerfile to read (default CONTEXT/Dockerfile)")
fs.Var(&tags, "t", "reference for the built image, REPOSITORY[:TAG]")
fs.Var(&tags, "tag", "reference for the built image, REPOSITORY[:TAG]")
quiet := fs.Bool("q", false, "print the image id only")
fs.BoolVar(quiet, "quiet", false, "print the image id only")
// docker's --no-cache asks for what the copy backend does anyway.
noCache := fs.Bool("no-cache", false, "do not use a build cache (the copy backend has none)")
refuse(fs, "build-arg", "ARG and --build-arg are stage 2 of RFC 0006")
refuse(fs, "target", "--target needs multi-stage builds, which are stage 3 of RFC 0006")
refuse(fs, "platform", "werkdock builds for the host's platform; there is no emulation")
refuse(fs, "pull", "the base image is pulled when the store does not have it; werkdock rmi it to force a refetch")
refuse(fs, "squash", "a werkdock image is one flat tree already; there is nothing to squash")
refuse(fs, "cache-from", "there is no layer cache to import from (RFC 0006, What \"no layers\" means)")
refuse(fs, "cache-to", "there is no layer cache to export (RFC 0006, What \"no layers\" means)")
refuse(fs, "secret", "build secrets are BuildKit's; a RUN step cannot mount anything (CapEff is 0 in the sandbox)")
refuse(fs, "ssh", "SSH agent forwarding into a build is BuildKit's")
refuse(fs, "network", "the sandbox shares the host's network by contract; there is no build network to choose")
refuse(fs, "output", "--output is BuildKit's exporter; the result is a store image, and save writes an archive")
refuse(fs, "load", "--load is buildx's; the image is in the store when the build succeeded")
refuse(fs, "push", "--push is buildx's; werkdock has no push verb")
refuse(fs, "progress", "werkdock prints the steps and the commands' own output, nothing else")
if err := fs.Parse(args); err != nil {
return nil, err
}
rest := fs.Args()
if len(rest) == 0 {
return nil, errors.New("build needs a CONTEXT directory (werkdock build [-f Dockerfile] -t IMAGE CONTEXT)")
}
if len(rest) > 1 {
return nil, fmt.Errorf("unexpected argument %q", rest[1])
}
if rest[0] == "-" {
return nil, errors.New("a Dockerfile on stdin needs no context and is not supported; pass a context directory")
}
if len(tags) == 0 {
return nil, errors.New("build needs -t REPOSITORY[:TAG]: a werkdock image is named, there is no untagged image")
}
if len(tags) > 1 {
return nil, errors.New("one -t per build: a second reference for the same image needs a tag verb, which werkdock does not have yet")
}
if _, err := store.ParseReference(tags[0]); err != nil {
return nil, err
}
opts := &buildOptions{Context: rest[0], Tag: tags[0], Quiet: *quiet, NoCache: *noCache, Dockerfile: *dockerfile}
if opts.Dockerfile == "" {
opts.Dockerfile = filepath.Join(opts.Context, "Dockerfile")
}
return opts, nil
}
// resolveBase is FROM's lookup: the store first, then a pull, as
// `docker build` pulls a base it does not have. Without a registry
// client the error names the two verbs that would put the image there.
func resolveBase(st store.Store) func(string) (string, store.ImageMeta, error) {
return func(reference string) (string, store.ImageMeta, error) {
rootfs, err := st.RootFS(reference)
if err == nil {
meta, metaErr := st.Meta(reference)
return rootfs, meta, metaErr
}
fmt.Fprintf(os.Stderr, "werkdock: %s is not in the store, pulling it\n", reference)
if _, pullErr := registry.Pull(st, reference, registry.DefaultPlatform(), os.Stderr); pullErr != nil {
return "", store.ImageMeta{}, fmt.Errorf("FROM %s: not in the store and could not be pulled: %w\n"+
"\tput it there with: werkdock pull %s — or werkdock load -i ARCHIVE", reference, pullErr, reference)
}
rootfs, err = st.RootFS(reference)
if err != nil {
return "", store.ImageMeta{}, err
}
meta, err := st.Meta(reference)
return rootfs, meta, err
}
}