The minimal build-capable CLI decided in RFC 0002's outcome: stdlib-only Go module, one static binary. - engine: RunSpec behind the Engine interface (RFC 0001); the Bwrap engine ports Werkator's hardened invocation — uid-0 mapping, read-only rootfs at /, proc/dev/tmp/root before the user binds so binds below them land inside, mountpoint pre-creation in the rootfs including file mountpoints, and a guard against binds escaping the rootfs. --clearenv gives docker-style clean environments (HOME/PATH set explicitly). - store: images under $WERKDOCK_HOME (default ~/.werkdock), load unpacks via the tar CLI into a tmp dir and renames atomically. - cli: docker-shaped run flags (-v/-e/-w/--rm); refused docker flags (-p, --network, --memory, --cpus, --user, -d) fail loudly with the reason; exit codes follow docker (125 CLI errors, child code through). - doctor: port of werkator-build-prerequisites.sh — userns probe with the three signals, tar/zstd, free space and group-quota headroom via testable df/quota parsers, same PASS/FAIL output. - tests: argv golden test, mountpoint and escape tests, flag refusals, store round trip, doctor parsers — plus real-sandbox integration tests that skip where bwrap or userns are unavailable. Also records in step 21: RFC 0002 levels 2/3 deferred; next goal is sandbox builds of Werkator, Werkbaum, and Werkdock itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
5.4 KiB
Go
176 lines
5.4 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 {
|
|
Volumes []engine.Bind
|
|
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,
|
|
Binds: hostBinds(opts.Volumes),
|
|
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
|
|
}
|
|
|
|
// hostBinds 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 binds, so an explicit bind over /etc wins.
|
|
func hostBinds(volumes []engine.Bind) []engine.Bind {
|
|
var binds []engine.Bind
|
|
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
|
binds = append(binds, engine.Bind{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true})
|
|
}
|
|
return append(binds, volumes...)
|
|
}
|
|
|
|
// 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 volumes, envs stringList
|
|
opts := &runOptions{}
|
|
fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]")
|
|
fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]")
|
|
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 _, v := range volumes {
|
|
bind, err := parseVolume(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts.Volumes = append(opts.Volumes, bind)
|
|
}
|
|
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.Bind, error) {
|
|
parts := strings.Split(v, ":")
|
|
if len(parts) < 2 || len(parts) > 3 {
|
|
return engine.Bind{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
|
|
}
|
|
bind := engine.Bind{Source: parts[0], Dest: parts[1]}
|
|
if len(parts) == 3 {
|
|
if parts[2] != "ro" {
|
|
return engine.Bind{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' is supported", parts[2], v)
|
|
}
|
|
bind.ReadOnly = true
|
|
}
|
|
if !filepath.IsAbs(bind.Source) {
|
|
return engine.Bind{}, fmt.Errorf("volume source must be an absolute path: %s", bind.Source)
|
|
}
|
|
if !filepath.IsAbs(bind.Dest) {
|
|
return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest)
|
|
}
|
|
return bind, 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)
|
|
}
|