Files
werkdock/internal/cli/cli.go
mhoennigandClaude Opus 5 86b8f2ffca
werkator build succeeded after 00:04
A copied tree meets the image where the two first differ
PUT /archive decided once, at the top level of the archive: a directory
the image filled was refused, because binding it would hide what was
inside. That was the right worry in the wrong place. Docker's clients put
the whole path in the tar entries, so MariaDBContainer copying its
configuration to /etc/mysql/conf.d arrives as a top-level "etc" — refused,
and the container never started at all.

The rule now descends instead of giving up: bind where the image has
nothing, descend where it has a directory, bind the file where it has a
file. The two trees meet at whatever depth they first differ, which is
docker's merge; mariadb's config binds one file four levels down and
/etc is left alone. The refusal that said werkdock "would have to hide
them" is gone with it, because there is no longer a case where it would.

Found by MariaDBContainer, which is now a test in werkprobe rather than
something I ran once by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:29:59 +02:00

187 lines
8.4 KiB
Go

// 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"
"strings"
)
// Version of this build. A merge to main is a release, so the branch
// that changes behavior bumps it here and titles its CHANGELOG section
// with it; there is no separate release step.
var Version = "0.8.1"
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 "create":
return createCmd(args[1:])
case "start":
return startCmd(args[1:])
case "stop":
return stopCmd(args[1:])
case "kill":
return killCmd(args[1:])
case "rm":
return rmCmd(args[1:])
case "ps":
return psCmd(args[1:])
case "logs":
return logsCmd(args[1:])
case "wait":
return waitCmd(args[1:])
case "exec":
return execCmd(args[1:])
case "system":
return systemCmd(args[1:])
case "supervise":
return superviseCmd(args[1:])
case "container":
return subcommand("container", args[1:], map[string]string{"ls": "ps", "list": "ps"}, "create", "start", "stop", "kill", "rm", "ps", "logs", "wait", "exec", "inspect", "run")
case "image":
return subcommand("image", args[1:], map[string]string{"ls": "images", "list": "images", "rm": "rmi"}, "images", "rmi", "pull", "load", "import", "inspect")
case "import":
return importCmd(args[1:])
case "load":
return loadCmd(args[1:])
case "pull":
return pullCmd(args[1:])
case "rmi":
return rmiCmd(args[1:])
case "images":
return imagesCmd(args[1:])
case "inspect":
return inspectCmd(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
}
}
// subcommand maps docker's management form (`container ls`, `image
// rm`) onto the top-level verbs.
func subcommand(group string, args []string, aliases map[string]string, verbs ...string) int {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "werkdock: %s needs a subcommand: %s\n", group, strings.Join(verbs, ", "))
return exitCLIError
}
verb := args[0]
if alias, ok := aliases[verb]; ok {
verb = alias
}
for _, v := range verbs {
if v == verb {
return Main(append([]string{verb}, args[1:]...))
}
}
fmt.Fprintf(os.Stderr, "werkdock: unknown command %q for %s\n", args[0], group)
return exitCLIError
}
func usage(w io.Writer) {
fmt.Fprint(w, `werkdock — a docker-like sandbox CLI over bwrap, filesystem isolation only.
Network, /proc, /dev, and /tmp come from the host by contract; the caller is the
sandbox's only user, uid 0 unless --user or the image's USER says otherwise.
Usage:
werkdock run [flags] IMAGE [COMMAND] [ARG...] run a command in a sandbox (-d: in the background, as a service)
werkdock create [flags] IMAGE [COMMAND] [ARG...] create an instance without starting it (docker create)
werkdock start INSTANCE... start created or stopped instances in the background
werkdock ps [-a] [-q] [--format FMT] list running (-a: all) instances, docker's columns
werkdock stop [-t SECONDS] INSTANCE... SIGTERM, then SIGKILL after SECONDS (default 10)
werkdock kill [-s SIGNAL] INSTANCE... send a signal (default KILL)
werkdock logs [-f] INSTANCE print the recorded stdout and stderr (-f: follow)
werkdock wait INSTANCE... wait for exit, print the exit code
werkdock exec [flags] INSTANCE COMMAND [ARG...] run another command in an instance's sandbox
werkdock rm [-f] INSTANCE... remove instances (-f: a running one too)
werkdock inspect IMAGE|INSTANCE... print image or instance metadata as JSON, docker-shaped
werkdock import [-c CHANGE...] ARCHIVE [IMAGE] create an image from a rootfs archive (docker import)
werkdock load -i ARCHIVE load a docker save / OCI image archive, layers flattened
werkdock pull [--platform OS/ARCH] IMAGE fetch an image from a registry through the crane binary
werkdock images [--format FMT] [-q] [REPO[:TAG]] list images: one reference per line, or docker's table/template
werkdock rmi IMAGE... remove images (docker rmi)
werkdock system service [--time D] [unix://PATH] serve the docker API subset (for Testcontainers)
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; -v, --tmpfs and --mount apply in flag order)
--tmpfs DEST empty tmpfs at DEST (repeatable; /tmp, /root and /run always are one)
--mount type=bind,src=SRC,dst=DEST[,readonly] the same, in docker's explicit form (repeatable)
--mount type=tmpfs,dst=DEST
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host, if set there)
-w, --workdir DIR working directory inside the sandbox (default /; created if missing and writable)
--entrypoint PROGRAM run PROGRAM with the arguments after IMAGE (overrides the image's ENTRYPOINT, drops its CMD)
-u, --user USER[:GROUP] appear as this user inside the sandbox (name from the image's /etc/passwd, or a number);
default: the image's USER, else uid 0. One uid only: the caller is mapped to it
--userns keep-id appear as the calling user's own uid and gid (podman's spelling)
--network host accepted as a no-op: the sandbox shares the host's network by contract
(other modes and -p/--publish are refused)
-d, --detach start the instance in the background under a supervisor and print its id
--name NAME name the instance (default: image basename plus six characters of the id)
--rm remove the instance when the command exits (a foreground --rm run records no logs)
-i, --interactive pass stdin to the command (-t is refused: no pseudo-terminal)
Exec flags: -e, -w, -u, -i as for run; the rootfs, mounts, uid, environment, /run and the
anonymous volumes are the instance's, the pid namespace is not (its processes are invisible).
Ps flags:
-a, --all stopped and created instances too
-q, --quiet print ids only
--format FMT Go template over .ID .Image .Command .CreatedAt .RunningFor .Status .State .Ports .Names,
or "table [TEMPLATE]"
Import flags:
-c, --change INSTRUCTION Dockerfile instruction applied to the image (repeatable):
ENTRYPOINT, CMD (JSON array or shell form), ENV, WORKDIR, USER, VOLUME, LABEL
Images flags:
--format FMT Go template over .Repository .Tag .ID .CreatedAt .CreatedSince .Size,
or "table [TEMPLATE]" for docker's columns (REPOSITORY TAG IMAGE ID CREATED SIZE)
-q, --quiet print image IDs only
Pull flags:
--platform OS/ARCH platform to pick from a multi-arch image (default linux/<host arch>)
pull runs crane (github.com/google/go-containerregistry): found in PATH,
beside werkdock, or at WERKDOCK_CRANE
Without COMMAND, run executes the image's ENTRYPOINT and CMD; with one, it
replaces CMD. Env and WORKDIR of the image apply unless -e/-w override them.
IMAGE is a docker/OCI-style reference, [REGISTRY/]REPOSITORY[:TAG], e.g.
eclipse-temurin:21 or ghcr.io/example/build:2026.09; the tag defaults to
latest. INSTANCE is an id, a unique id prefix, or a name. The store lives in
$WERKDOCK_HOME (default ~/.werkdock); instances in its instances/ directory
with config.json, state.json, stdout, stderr, run/ and volumes/ each.
docker's management form works too: container ls|start|..., image ls|rm|pull|....
`+"`"+`werkdock system service`+"`"+` listens on $XDG_RUNTIME_DIR/werkdock.sock by default;
a client reaches it with DOCKER_HOST=unix://<that path>.
`)
}
func fail(err error) int {
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
return exitCLIError
}