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
392 lines
12 KiB
Go
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 ""
|
|
}
|