Files
mhoennigandClaude Fable 5 a2da6454f7 werkdock: ordered mounts with --tmpfs, images verb, :rw accepted
Session C groundwork: Werkator's git-metadata mask needs a tmpfs
BETWEEN binds (ro-bind .git, tmpfs .git/werkator, bind workspace), so
-v and --tmpfs now collect into one ordered mount list and RunSpec
carries Mounts instead of Binds. --tmpfs DEST is the docker flag of the
same name. `werkdock images` lists loaded image names one per line, so
a consumer can check existence through the CLI. -v accepts the explicit
:rw docker default instead of refusing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:39:31 +02:00

204 lines
6.2 KiB
Go

package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"werkdock/internal/engine"
"werkdock/internal/store"
)
// runOptions is the parsed form of `werkdock run` flags, separated from
// execution so the parsing is testable and a later daemon can reuse it.
type runOptions struct {
Mounts []engine.Mount
Env []engine.EnvVar
Workdir string
Remove bool
Image string
Command []string
}
func runCmd(args []string) int {
opts, err := parseRun(args, os.Getenv)
if err != nil {
return fail(err)
}
st, err := store.Default()
if err != nil {
return fail(err)
}
rootfs, err := st.RootFS(opts.Image)
if err != nil {
return fail(err)
}
spec := engine.RunSpec{
RootFS: rootfs,
Mounts: hostMounts(opts.Mounts),
Env: opts.Env,
Workdir: opts.Workdir,
Command: opts.Command,
}
eng := &engine.Bwrap{}
code, err := eng.Run(spec)
if err != nil {
return fail(err)
}
return code
}
// hostMounts prepends the host mounts the contract prescribes: DNS comes
// from the host, so /etc/resolv.conf is bound read-only when it exists —
// before the user mounts, so an explicit mount over /etc wins.
func hostMounts(mounts []engine.Mount) []engine.Mount {
var all []engine.Mount
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
}
return append(all, mounts...)
}
// parseRun parses the docker-shaped run flags. Docker flags whose
// promise werkdock cannot keep are registered and refused with a
// reason — never silently ignored (RFC 0002).
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var envs stringList
opts := &runOptions{}
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
// order, so a tmpfs between two binds (the git-metadata mask) must stay
// between them.
volumes := &mountFlag{mounts: &opts.Mounts}
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
fs.Var(&envs, "e", "environment variable KEY=VALUE")
fs.Var(&envs, "env", "environment variable KEY=VALUE")
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
fs.StringVar(&opts.Workdir, "workdir", "", "working directory inside the sandbox")
fs.BoolVar(&opts.Remove, "rm", false, "remove the instance afterwards")
refuse(fs, "p", "werkdock has no network isolation; the sandbox binds host ports directly")
refuse(fs, "publish", "werkdock has no network isolation; the sandbox binds host ports directly")
refuse(fs, "network", "the network is the host's by contract; there is nothing to configure")
refuse(fs, "memory", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
refuse(fs, "cpus", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
refuse(fs, "user", "the sandbox always runs uid 0 mapped to the calling user")
refuse(fs, "d", "detached instances are not implemented yet")
refuse(fs, "detach", "detached instances are not implemented yet")
if err := fs.Parse(args); err != nil {
return nil, err
}
if !opts.Remove {
return nil, errors.New("persistent instances are not implemented yet; run with --rm")
}
rest := fs.Args()
if len(rest) == 0 {
return nil, errors.New("no image specified")
}
if len(rest) == 1 {
return nil, errors.New("no command specified (werkdock images carry no default command yet)")
}
opts.Image = rest[0]
opts.Command = rest[1:]
for _, e := range envs {
opts.Env = append(opts.Env, parseEnv(e, getenv))
}
if opts.Workdir != "" && !filepath.IsAbs(opts.Workdir) {
return nil, fmt.Errorf("workdir must be an absolute path: %s", opts.Workdir)
}
return opts, nil
}
func parseVolume(v string) (engine.Mount, error) {
parts := strings.Split(v, ":")
if len(parts) < 2 || len(parts) > 3 {
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
}
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
if len(parts) == 3 {
switch parts[2] {
case "ro":
mount.Mode = engine.MountRoBind
case "rw":
// docker accepts :rw as the explicit default; so do we
default:
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
}
}
if !filepath.IsAbs(mount.Source) {
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
}
if !filepath.IsAbs(mount.Dest) {
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
}
return mount, nil
}
// mountFlag appends -v/--volume and --tmpfs values to one shared,
// ordered mount list.
type mountFlag struct {
mounts *[]engine.Mount
tmpfs bool
}
func (f *mountFlag) String() string { return "" }
func (f *mountFlag) Set(v string) error {
if f.tmpfs {
if !filepath.IsAbs(v) {
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
}
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
return nil
}
mount, err := parseVolume(v)
if err != nil {
return err
}
*f.mounts = append(*f.mounts, mount)
return nil
}
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
if key, value, found := strings.Cut(e, "="); found {
return engine.EnvVar{Key: key, Value: value}
}
return engine.EnvVar{Key: e, Value: getenv(e)}
}
// stringList collects a repeatable flag's values in order.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
// refusedFlag rejects a known docker flag with the reason werkdock
// cannot honor it.
type refusedFlag struct {
name string
reason string
}
func (f *refusedFlag) String() string { return "" }
func (f *refusedFlag) Set(string) error {
return fmt.Errorf("flag -%s is not supported: %s", f.name, f.reason)
}
func (f *refusedFlag) IsBoolFlag() bool { return true }
func refuse(fs *flag.FlagSet, name, reason string) {
fs.Var(&refusedFlag{name: name, reason: reason}, name, reason)
}