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

392 lines
12 KiB
Go

package build
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"werkdock/internal/engine"
"werkdock/internal/rootpath"
"werkdock/internal/store"
)
// Options is one build.
type Options struct {
// Dockerfile is the path of the Dockerfile to read.
Dockerfile string
// Context is the build context directory COPY reads from.
Context string
// Tag is the reference the result is stored under. It is required:
// this store has no untagged images to fall back on.
Tag string
// Backend opens the work tree; Select picks the one for this host.
Backend Backend
// Resolve maps a FROM reference to the base image's rootfs and
// metadata. The CLI looks in the store and pulls on a miss; a test
// hands over a prepared tree.
Resolve func(reference string) (rootfs string, meta store.ImageMeta, err error)
// Engine runs the RUN steps; nil means bwrap.
Engine engine.Engine
// Stdout takes the step log, Stderr the steps' own output.
Stdout, Stderr io.Writer
}
// Result is what a finished build produced.
type Result struct {
Reference string
ID string
}
// aptSandboxUser is the apt configuration fragment every RUN step
// needs: apt drops to the user `_apt` (uid 42) to fetch, and an
// unprivileged user namespace maps exactly one uid, so the seteuid
// fails and every method dies ("Method http has died unexpectedly").
// Written into the work tree before the steps and removed again at
// commit, unless the base image had a file of that name already.
const (
aptSandboxUserPath = "/etc/apt/apt.conf.d/00werkdock-sandbox"
aptSandboxUserBody = "// written by werkdock build, removed at commit (RFC 0006)\nAPT::Sandbox::User \"root\";\n"
)
// Run builds the image described by opts and returns its reference.
func Run(st store.Store, opts Options) (Result, error) {
if opts.Tag == "" {
return Result{}, errors.New("build needs -t REPOSITORY[:TAG]: a werkdock image is named, there is no untagged image to fall back on")
}
if opts.Backend == nil {
opts.Backend, _ = Select()
}
if opts.Stdout == nil {
opts.Stdout = io.Discard
}
if opts.Stderr == nil {
opts.Stderr = io.Discard
}
if opts.Engine == nil {
// A step's own output goes where the step log goes, as docker
// build's does — so -q is quiet — while its stderr stays
// visible whatever -q says.
opts.Engine = &engine.Bwrap{Stdout: opts.Stdout, Stderr: opts.Stderr}
}
file, err := os.Open(opts.Dockerfile)
if err != nil {
return Result{}, err
}
instructions, err := Parse(file)
file.Close()
if err != nil {
return Result{}, fmt.Errorf("%s: %w", opts.Dockerfile, err)
}
context, err := filepath.Abs(opts.Context)
if err != nil {
return Result{}, err
}
if fi, err := os.Stat(context); err != nil || !fi.IsDir() {
return Result{}, fmt.Errorf("build context %s is not a directory", opts.Context)
}
opts.Context = context
return (&builder{opts: opts, store: st, instructions: instructions}).run()
}
type builder struct {
opts Options
store store.Store
instructions []Instruction
config store.ImageConfig
// aptFragment is set when this build wrote the apt fragment and
// has to remove it again at commit.
aptFragment bool
// fakeroot is the in-image path of the fakeroot binary, empty when
// the base image has none.
fakeroot string
}
func (b *builder) run() (Result, error) {
from := b.instructions[0]
reference, err := fromReference(from)
if err != nil {
return Result{}, err
}
b.step(1, from)
baseRootFS, baseMeta, err := b.base(reference)
if err != nil {
return Result{}, err
}
if reference == scratch {
defer os.RemoveAll(baseRootFS)
}
b.config = baseMeta.Config
staging, err := b.store.Stage(b.opts.Tag)
if err != nil {
return Result{}, err
}
defer staging.Close()
tree, err := b.opts.Backend.Open(baseRootFS, staging.Dir())
if err != nil {
return Result{}, err
}
defer tree.Close()
b.fakeroot = findFakeroot(baseRootFS)
if b.fakeroot == "" && b.hasRunStep() {
fmt.Fprintf(b.opts.Stdout, "werkdock: %s has no fakeroot; RUN steps run without it, and a package whose "+
"postinst chowns will fail with \"chown: ... Invalid argument\" and dpkg exit 1. "+
"Add fakeroot to the base image to avoid it.\n", reference)
}
for n, instruction := range b.instructions[1:] {
b.step(n+2, instruction)
if err := b.apply(tree, n, instruction); err != nil {
return Result{}, fmt.Errorf("line %d: %s: %w", instruction.Line, instruction.Keyword, err)
}
}
rootfs, err := tree.Commit()
if err != nil {
return Result{}, err
}
if err := b.removeAptFragment(rootfs); err != nil {
return Result{}, err
}
if err := ensureWorkdir(rootfs, b.config.WorkingDir); err != nil {
return Result{}, err
}
meta := store.NewMeta(b.opts.Dockerfile, b.config)
if err := staging.Commit(meta); err != nil {
return Result{}, err
}
result := Result{Reference: staging.Reference(), ID: meta.ID()}
fmt.Fprintf(b.opts.Stdout, "Successfully built %s\nSuccessfully tagged %s\n", result.ID, result.Reference)
return result, nil
}
// ensureWorkdir creates the finished image's WORKDIR, as docker does:
// a run binds the root read-only and cannot create it, so a Dockerfile
// whose WORKDIR no later step touched would give an image that fails.
func ensureWorkdir(rootfs, workdir string) error {
if workdir == "" {
return nil
}
host, err := rootpath.Resolve(rootfs, workdir)
if err != nil {
return err
}
return os.MkdirAll(host, 0o755)
}
// scratch is docker's reserved name of the empty base: no files, no
// metadata, and nothing to look up or pull.
const scratch = "scratch"
// base resolves FROM's reference to a rootfs and its metadata; for
// scratch an empty directory, which the caller removes.
func (b *builder) base(reference string) (string, store.ImageMeta, error) {
if reference != scratch {
return b.opts.Resolve(reference)
}
dir, err := os.MkdirTemp("", "werkdock-scratch-")
return dir, store.ImageMeta{}, err
}
func (b *builder) step(n int, instruction Instruction) {
fmt.Fprintf(b.opts.Stdout, "Step %d/%d : %s %s\n", n, len(b.instructions), instruction.Keyword, instruction.Args)
}
func (b *builder) hasRunStep() bool {
for _, i := range b.instructions {
if i.Keyword == "RUN" {
return true
}
}
return false
}
// apply executes one instruction. n counts the instructions after
// FROM, which is what a backend keys its steps by.
func (b *builder) apply(tree Tree, n int, instruction Instruction) error {
if !instruction.changesTree() {
if instruction.Keyword == "USER" {
fmt.Fprintf(b.opts.Stdout, "werkdock: USER %s applies to the finished image; the RUN steps keep running "+
"as uid 0, since an unprivileged user namespace maps one uid (RFC 0005)\n", instruction.Args)
}
return store.ApplyChange(&b.config, instruction.Keyword+" "+instruction.Args)
}
step, err := tree.Step(n)
if err != nil {
return err
}
switch instruction.Keyword {
case "FROM":
return errors.New("a second FROM is multi-stage, which is stage 3 of RFC 0006")
case "RUN":
return b.runStep(step, instruction)
case "COPY":
return b.copyStep(step, instruction)
default:
return fmt.Errorf("unhandled instruction %s", instruction.Keyword)
}
}
// runStep executes one RUN instruction in a sandbox over the work
// tree, with the two uid levers in place (RFC 0006): the apt fragment
// in the tree and FAKEROOTDONTTRYCHOWN in the environment, with the
// command wrapped in fakeroot when the base image has one. Without
// FAKEROOTDONTTRYCHOWN fakeroot does not help at all: its chown
// wrapper calls the real syscall and reports its EINVAL (measured).
func (b *builder) runStep(step Step, instruction Instruction) error {
if err := b.writeAptFragment(step.Writable()); err != nil {
return err
}
command, err := runCommand(instruction.Args)
if err != nil {
return err
}
if b.fakeroot != "" {
command = append([]string{b.fakeroot, "--"}, command...)
}
spec := engine.RunSpec{
Mounts: resolvConfMount(step.Writable()),
Env: append([]engine.EnvVar{{Key: "FAKEROOTDONTTRYCHOWN", Value: "1"}}, envVars(b.config.Env)...),
Workdir: b.config.WorkingDir,
Command: command,
}
step.Root(&spec)
code, err := b.opts.Engine.Run(spec)
if err != nil {
return err
}
if code != 0 {
return fmt.Errorf("the command returned a non-zero code: %d", code)
}
return nil
}
// runCommand is the RUN instruction's command: a JSON array is the
// exec form and runs as given, anything else is the shell form, which
// docker runs through /bin/sh -c (SHELL is stage 2).
func runCommand(args string) ([]string, error) {
if !strings.HasPrefix(args, "[") {
return []string{"/bin/sh", "-c", args}, nil
}
var config store.ImageConfig
if err := store.ApplyChange(&config, "CMD "+args); err != nil {
// the parse borrows CMD's; the message names what the user wrote
cause := errors.Unwrap(err)
if cause == nil {
cause = err
}
return nil, fmt.Errorf("RUN %s: %w", args, cause)
}
return config.Cmd, nil
}
// resolvConfMount binds the host's resolver configuration into a RUN
// step, as `run` does, so that an `apt-get update` resolves names. Only
// where the tree under writable has the file already: a missing
// mountpoint would be created in the writable root and stay in the
// finished image as an empty resolv.conf (measured), e.g. after
// FROM scratch; such a step runs without the host's resolver.
func resolvConfMount(writable string) []engine.Mount {
if fi, err := os.Stat("/etc/resolv.conf"); err != nil || !fi.Mode().IsRegular() {
return nil
}
target, err := rootpath.Resolve(writable, "/etc/resolv.conf")
if err != nil {
return nil
}
if fi, err := os.Stat(target); err != nil || !fi.Mode().IsRegular() {
return nil
}
return []engine.Mount{{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"}}
}
func envVars(env []string) []engine.EnvVar {
var result []engine.EnvVar
for _, e := range env {
if key, value, found := strings.Cut(e, "="); found {
result = append(result, engine.EnvVar{Key: key, Value: value})
}
}
return result
}
// fromReference reads FROM's image reference and refuses what belongs
// to a later stage.
func fromReference(from Instruction) (string, error) {
fields := strings.Fields(from.Args)
for _, field := range fields {
if strings.HasPrefix(field, "--") {
return "", fmt.Errorf("line %d: FROM %s is not supported: werkdock builds for the host's platform, "+
"and there is no emulation to choose another", from.Line, field)
}
}
if len(fields) > 1 {
if strings.EqualFold(fields[1], "AS") {
return "", fmt.Errorf("line %d: FROM ... AS is multi-stage, which is stage 3 of RFC 0006", from.Line)
}
return "", fmt.Errorf("line %d: FROM takes one image reference, got %q", from.Line, from.Args)
}
return fields[0], nil
}
// writeAptFragment puts the apt sandbox-user fragment into the work
// tree, once per build, and remembers to remove it at commit. A file
// the base image already had is left alone and stays the image's own.
func (b *builder) writeAptFragment(writable string) error {
if b.aptFragment {
return nil
}
target, err := rootpath.Resolve(writable, aptSandboxUserPath)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Dir(target)); err != nil {
return nil // no apt in this image, so nothing to configure
}
if _, err := os.Stat(target); err == nil {
return nil // the image brought its own; do not overwrite it
}
if err := os.WriteFile(target, []byte(aptSandboxUserBody), 0o644); err != nil {
return err
}
b.aptFragment = true
return nil
}
func (b *builder) removeAptFragment(rootfs string) error {
if !b.aptFragment {
return nil
}
target, err := rootpath.Resolve(rootfs, aptSandboxUserPath)
if err != nil {
return err
}
// a step may have removed it, e.g. a cleanup of /etc/apt; then there is nothing left to remove
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// findFakeroot looks for a fakeroot binary in the base image. Its
// absence is not an error — plenty of builds never chown — but it is
// the difference between `apt-get install nginx` working and dpkg
// failing in the postinst (measured, RFC 0006).
func findFakeroot(rootfs string) string {
for _, candidate := range []string{"/usr/bin/fakeroot", "/bin/fakeroot", "/usr/local/bin/fakeroot"} {
host, err := rootpath.Resolve(rootfs, candidate)
if err != nil {
continue
}
if fi, err := os.Stat(host); err == nil && !fi.IsDir() && fi.Mode().Perm()&0o111 != 0 {
return candidate
}
}
return ""
}