Files
mhoennigandClaude Opus 5 ce20df9f76
werkator build succeeded after 00:03
The quirk table, measured instead of assumed (#8)
Three of its four entries were wrong. redis, mariadb and mysql were all
listed as moving their port through an environment variable — REDIS_PORT,
MARIADB_TCP_PORT, MYSQL_TCP_PORT. None of the three images reads one.
Werkdock picked a free port, told nobody, and reported it: a client got a
number nothing was listening on, which is the single failure the whole
quirk mechanism exists to prevent.

It was found the only way it could be — by starting the images and
connecting to the port werkdock named. redis-server takes --port; the two
database entrypoints read the port out of the server's own configuration,
so the flag has to reach the command line there too. A quirk can now move
a port by a flag, and does so only while the container still runs the
image's own program: appending an argument to a command the client chose
would be a guess, and guessing is what this commit is about.

Two more things surfaced on the way. Redis could not start at all — its
entrypoint chowns its data directory as root, which an unprivileged user
namespace cannot do, so it needs the same treatment postgres has. And an
image's /run did not show through: /run is the instance's own directory
so that sockets survive a stop, which left mariadb without /run/mysqld
and dying on "Bind on unix socket: No such file or directory". The
instance's /run is now seeded from the image's.

postgres was right all along and is untouched; it really does read PGPORT.

Squashed from branch quirk-ports and pushed to main directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:10:50 +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.0"
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
}