Werkdock skeleton: doctor, load, run over the bwrap engine (Go)
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
de79210a7b
commit
e4bfeacf5a
@@ -0,0 +1,68 @@
|
||||
// Package cli parses werkdock's docker-shaped command line (RFC 0002)
|
||||
// and dispatches to the internal packages. Exit codes follow docker:
|
||||
// 125 for werkdock's own errors, otherwise the sandboxed command's code
|
||||
// is passed through.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Version is replaced at release time; the dev default marks unreleased
|
||||
// builds.
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
const exitCLIError = 125
|
||||
|
||||
// Main runs the CLI and returns the process exit code.
|
||||
func Main(args []string) int {
|
||||
if len(args) == 0 {
|
||||
usage(os.Stderr)
|
||||
return exitCLIError
|
||||
}
|
||||
switch args[0] {
|
||||
case "run":
|
||||
return runCmd(args[1:])
|
||||
case "load":
|
||||
return loadCmd(args[1:])
|
||||
case "doctor":
|
||||
return doctorCmd(args[1:])
|
||||
case "version", "--version":
|
||||
fmt.Printf("werkdock %s\n", Version)
|
||||
return 0
|
||||
case "help", "--help", "-h":
|
||||
usage(os.Stdout)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "werkdock: unknown command %q\n\n", args[0])
|
||||
usage(os.Stderr)
|
||||
return exitCLIError
|
||||
}
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprint(w, `werkdock — a docker-like sandbox CLI over bwrap, filesystem isolation only.
|
||||
Network, uid, /proc, /dev, and /tmp come from the host by contract.
|
||||
|
||||
Usage:
|
||||
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
|
||||
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
|
||||
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
|
||||
werkdock version print the version
|
||||
|
||||
Run flags:
|
||||
-v, --volume SRC:DEST[:ro] bind mount (repeatable, applied in order)
|
||||
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
|
||||
-w, --workdir DIR working directory inside the sandbox (default /)
|
||||
--rm remove the instance afterwards (currently required)
|
||||
|
||||
The store lives in $WERKDOCK_HOME (default ~/.werkdock).
|
||||
`)
|
||||
}
|
||||
|
||||
func fail(err error) int {
|
||||
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
|
||||
return exitCLIError
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"werkdock/internal/doctor"
|
||||
"werkdock/internal/store"
|
||||
)
|
||||
|
||||
func doctorCmd(args []string) int {
|
||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
targetDir := ""
|
||||
switch len(fs.Args()) {
|
||||
case 0:
|
||||
st, err := store.Default()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
targetDir = st.Root
|
||||
// The store may not exist yet; measure its closest existing
|
||||
// ancestor, which sits on the same filesystem.
|
||||
for {
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(targetDir)
|
||||
if parent == targetDir {
|
||||
break
|
||||
}
|
||||
targetDir = parent
|
||||
}
|
||||
case 1:
|
||||
targetDir = fs.Args()[0]
|
||||
default:
|
||||
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1]))
|
||||
}
|
||||
report := doctor.Run(targetDir, os.Getuid(), runCombined)
|
||||
report.Render(os.Stdout)
|
||||
if report.OK() {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func runCombined(name string, args ...string) (string, error) {
|
||||
out, err := exec.Command(name, args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"werkdock/internal/store"
|
||||
)
|
||||
|
||||
func loadCmd(args []string) int {
|
||||
fs := flag.NewFlagSet("load", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var input, name string
|
||||
fs.StringVar(&input, "i", "", "rootfs archive to import")
|
||||
fs.StringVar(&input, "input", "", "rootfs archive to import")
|
||||
fs.StringVar(&name, "name", "", "image name (default: derived from the archive file name)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if input == "" {
|
||||
return fail(errors.New("load needs -i ARCHIVE"))
|
||||
}
|
||||
if len(fs.Args()) != 0 {
|
||||
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
|
||||
}
|
||||
if name == "" {
|
||||
name = store.ImageNameFromArchive(input)
|
||||
}
|
||||
st, err := store.Default()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := st.Load(input, name); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fmt.Printf("Loaded image: %s\n", name)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"werkdock/internal/engine"
|
||||
)
|
||||
|
||||
func noEnv(string) string { return "" }
|
||||
|
||||
func TestParseRunSupportedFlags(t *testing.T) {
|
||||
opts, err := parseRun([]string{
|
||||
"--rm",
|
||||
"-v", "/repo:/repo",
|
||||
"--volume", "/cache:/root/.gradle:ro",
|
||||
"-e", "CI=true",
|
||||
"-w", "/repo",
|
||||
"buildenv", "sh", "-c", "./gradlew build",
|
||||
}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Image != "buildenv" {
|
||||
t.Errorf("image: got %q", opts.Image)
|
||||
}
|
||||
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
|
||||
t.Errorf("command: got %q", opts.Command)
|
||||
}
|
||||
wantVolumes := []engine.Bind{
|
||||
{Source: "/repo", Dest: "/repo"},
|
||||
{Source: "/cache", Dest: "/root/.gradle", ReadOnly: true},
|
||||
}
|
||||
if !reflect.DeepEqual(opts.Volumes, wantVolumes) {
|
||||
t.Errorf("volumes: got %+v", opts.Volumes)
|
||||
}
|
||||
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
|
||||
t.Errorf("env: got %+v", opts.Env)
|
||||
}
|
||||
if opts.Workdir != "/repo" {
|
||||
t.Errorf("workdir: got %q", opts.Workdir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) {
|
||||
getenv := func(key string) string {
|
||||
if key == "LANG" {
|
||||
return "C.UTF-8"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
opts, err := parseRun([]string{"--rm", "-e", "LANG", "img", "true"}, getenv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}}) {
|
||||
t.Errorf("env: got %+v", opts.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) {
|
||||
tests := []struct {
|
||||
args []string
|
||||
wantReason string
|
||||
}{
|
||||
{[]string{"--rm", "-p", "8080:80", "img", "true"}, "no network isolation"},
|
||||
{[]string{"--rm", "--network", "host", "img", "true"}, "network is the host's"},
|
||||
{[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"},
|
||||
{[]string{"--rm", "--user", "1000", "img", "true"}, "uid 0 mapped to the calling user"},
|
||||
{[]string{"--rm", "-d", "img", "true"}, "not implemented yet"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
|
||||
_, err := parseRun(tt.args, noEnv)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantReason) {
|
||||
t.Errorf("got %v, want refusal containing %q", err, tt.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunRequiresRmForNow(t *testing.T) {
|
||||
_, err := parseRun([]string{"img", "true"}, noEnv)
|
||||
if err == nil || !strings.Contains(err.Error(), "--rm") {
|
||||
t.Errorf("got %v, want the --rm requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{"no image", []string{"--rm"}, "no image specified"},
|
||||
{"no command", []string{"--rm", "img"}, "no command specified"},
|
||||
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
|
||||
{"volume with bad option", []string{"--rm", "-v", "/a:/b:rw", "img", "true"}, "only 'ro' is supported"},
|
||||
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
|
||||
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
|
||||
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseRun(tt.args, noEnv)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("got %v, want it to contain %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
|
||||
// Docker semantics: everything after the image belongs to the
|
||||
// command, even if it looks like a flag.
|
||||
opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(opts.Command, []string{"ls", "-la", "/tmp"}) {
|
||||
t.Errorf("command: got %q", opts.Command)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user