Files
werkdock/internal/cli/cli.go
mhoennigandClaude Fable 5.1 1ebc5bf09a
werkator build succeeded after 00:09
Mosquitto moves after all: a port that lives in a config file
0.9.0 listed mosquitto as the image werkdock cannot move, and that was
true of everything mosquitto offers: -p is ignored once a config file
declares a listener (the manual says so, and it was measured), no
environment variable names the port, and the config file expands none.
What mosquitto does read is the config file — and a config file is the
one thing werkdock can replace that a flag or a variable cannot reach.

A quirk can now name a PortFile: which file the image ships carries the
port and how the line reads. Werkdock copies that file into the instance
directory with the line rewritten to the chosen port and binds the copy
read-only over the image's original, which is never touched. Both of
mosquitto's listeners live in the same file, so the dashboard's HTTP API
on 9883 moves along with MQTT on 1883.

Measured with 2.1.2 through the API socket: two instances at once, each
on its own pair of ports, each accepting an anonymous client and
answering on its dashboard, 1883 left free.

The rewritten copy applies only while the command still names the file
and the client has not bound a file of their own over that path; either
way the copy would not be what the server reads, and the port is then
reported unmoved. An image whose file no longer has the expected line is
refused at create with the line named, not started on a port werkdock
only claimed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 09:36:32 +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.10.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
}