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 "" }