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
145 lines
5.6 KiB
Go
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
|
|
}
|
|
}
|