Files
miandmhoennig ea30cbe79c
werkator build succeeded after 00:04
One import per image at a time, and run answers 126/127 for a command that cannot start (0.11.0) (#13)
Found on mih09 with Werkator 1.37.0 (repository werkrevisor, PR#5): a CI build and a pull-request review started together and both imported the same new rootfs.

## Imports of one image wait for each other

`Store.Import` stages in `<image>.tmp` and removes that directory first, so the next import cleans up after an interrupted one.
A second import at the same time removed the first one's files: one failed with `latest.tmp/image.json: no such file or directory`, the other's sandbox could not execute `/bin/sh`.
An import now holds an exclusive `flock` on `$WERKDOCK_HOME/locks/import-<hash>.lock`, across goroutines and processes; the kernel drops it when the holder dies.
The import that waited refuses with `already exists`, as an import after it would.

`TestConcurrentImportsOfOneImageLeaveOneCompleteImage`: four imports of a 3000-file archive at once leave one success, three refusals and one complete image. It failed 3 of 3 runs without the lock.

## `werkdock run` answers 127 or 126

`engine.CheckCommand` looks the command up like execvp inside the rootfs (symlinks resolved in the rootfs, PATH in order, a non-executable candidate skipped for a later one).
`run` refuses a command that cannot start with docker's 127 (not found) or 126 (not executable) before anything is created; until now bwrap's 1 came back.
Paths under a mount, the implicit tmpfs areas included, are not checked. `create`/`start` and the Docker API are unchanged.
The resolver moved from `store` to its own package `rootpath`, shared with `engine`.

Smoke-tested with the built binary: `run smoke missing` exits 127, a file without execute bit 126, a second import 125 with `already exists`.

Version 0.11.0 with its CHANGELOG section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #13
2026-10-05 17:29:20 +02:00

428 lines
12 KiB
Go

package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"syscall"
"time"
"werkdock/internal/engine"
"werkdock/internal/instance"
"werkdock/internal/store"
)
// The lifecycle verbs are thin frontends over the instance package
// (RFC 0005): they parse docker's flags, resolve instances by id,
// prefix, or name, and print what docker prints.
func manager() (instance.Manager, error) {
st, err := store.Default()
if err != nil {
return instance.Manager{}, err
}
return instance.Manager{Root: st.Root}, nil
}
// createCmd is `docker create`: the run flags, an instance directory,
// no start. Prints the id.
func createCmd(args []string) int {
opts, err := parseRun(args, os.LookupEnv)
if err != nil {
return fail(err)
}
if opts.Detach {
return fail(errors.New("create takes no -d: the instance is not started; werkdock start ID does that"))
}
st, err := store.Default()
if err != nil {
return fail(err)
}
inst, err := createInstance(st, opts, false)
if err != nil {
return fail(err)
}
fmt.Println(inst.Config.ID)
return 0
}
// startCmd is `docker start INSTANCE...`: each is started under a
// detached supervisor; the name is printed as docker does.
func startCmd(args []string) int {
fs := flag.NewFlagSet("start", flag.ContinueOnError)
fs.SetOutput(io.Discard)
refuse(fs, "a", "start does not attach; werkdock logs -f ID follows the output")
refuse(fs, "attach", "start does not attach; werkdock logs -f ID follows the output")
refuse(fs, "i", "start does not attach; werkdock logs -f ID follows the output")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) == 0 {
return fail(errors.New("start needs an INSTANCE (werkdock start INSTANCE...)"))
}
m, err := manager()
if err != nil {
return fail(err)
}
return eachInstance(m, fs.Args(), func(inst *instance.Instance) error {
return startDetached(m, inst)
})
}
// eachInstance applies fn to every instance named, reports failures
// per instance and prints the argument on success, docker's way; exit
// 125 if any failed.
func eachInstance(m instance.Manager, keys []string, fn func(*instance.Instance) error) int {
code := 0
for _, key := range keys {
inst, err := m.Get(key)
if err == nil {
err = fn(inst)
}
if err != nil {
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
code = exitCLIError
continue
}
fmt.Println(key)
}
return code
}
func stopCmd(args []string) int {
fs := flag.NewFlagSet("stop", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var timeout int
fs.IntVar(&timeout, "t", 10, "seconds to wait for the command to exit on SIGTERM before SIGKILL")
fs.IntVar(&timeout, "time", 10, "seconds to wait for the command to exit on SIGTERM before SIGKILL")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) == 0 {
return fail(errors.New("stop needs an INSTANCE (werkdock stop [-t SECONDS] INSTANCE...)"))
}
m, err := manager()
if err != nil {
return fail(err)
}
return eachInstance(m, fs.Args(), func(inst *instance.Instance) error {
return m.Stop(inst, time.Duration(timeout)*time.Second)
})
}
func killCmd(args []string) int {
fs := flag.NewFlagSet("kill", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var signame string
fs.StringVar(&signame, "s", "KILL", "signal to send")
fs.StringVar(&signame, "signal", "KILL", "signal to send")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) == 0 {
return fail(errors.New("kill needs an INSTANCE (werkdock kill [-s SIGNAL] INSTANCE...)"))
}
sig, err := parseSignal(signame)
if err != nil {
return fail(err)
}
m, err := manager()
if err != nil {
return fail(err)
}
return eachInstance(m, fs.Args(), func(inst *instance.Instance) error {
return m.Kill(inst, sig)
})
}
var signals = map[string]syscall.Signal{
"HUP": syscall.SIGHUP, "INT": syscall.SIGINT, "QUIT": syscall.SIGQUIT, "KILL": syscall.SIGKILL,
"USR1": syscall.SIGUSR1, "USR2": syscall.SIGUSR2, "TERM": syscall.SIGTERM, "WINCH": syscall.SIGWINCH,
}
// parseSignal accepts docker's spellings: a number, NAME, or SIGNAME.
func parseSignal(s string) (syscall.Signal, error) {
if n, err := strconv.Atoi(s); err == nil && n > 0 {
return syscall.Signal(n), nil
}
name := strings.TrimPrefix(strings.ToUpper(s), "SIG")
if sig, ok := signals[name]; ok {
return sig, nil
}
return 0, fmt.Errorf("unknown signal %q", s)
}
func rmCmd(args []string) int {
fs := flag.NewFlagSet("rm", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var force bool
fs.BoolVar(&force, "f", false, "kill a running instance before removing it")
fs.BoolVar(&force, "force", false, "kill a running instance before removing it")
refuse(fs, "v", "anonymous volumes always go with the instance; there are no named volumes to keep")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) == 0 {
return fail(errors.New("rm needs an INSTANCE (werkdock rm [-f] INSTANCE...)"))
}
m, err := manager()
if err != nil {
return fail(err)
}
return eachInstance(m, fs.Args(), func(inst *instance.Instance) error {
return m.Remove(inst, force)
})
}
func waitCmd(args []string) int {
fs := flag.NewFlagSet("wait", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) == 0 {
return fail(errors.New("wait needs an INSTANCE (werkdock wait INSTANCE...)"))
}
m, err := manager()
if err != nil {
return fail(err)
}
code := 0
for _, key := range fs.Args() {
inst, err := m.Get(key)
if err != nil {
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
code = exitCLIError
continue
}
exit, err := m.Wait(inst, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "werkdock: %v\n", err)
code = exitCLIError
continue
}
fmt.Println(exit)
}
return code
}
func logsCmd(args []string) int {
fs := flag.NewFlagSet("logs", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var follow bool
fs.BoolVar(&follow, "f", false, "keep printing until the instance exits")
fs.BoolVar(&follow, "follow", false, "keep printing until the instance exits")
refuse(fs, "t", "werkdock records no timestamps; the files hold the output as written")
refuse(fs, "timestamps", "werkdock records no timestamps; the files hold the output as written")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) != 1 {
return fail(errors.New("logs needs exactly one INSTANCE (werkdock logs [-f] INSTANCE)"))
}
m, err := manager()
if err != nil {
return fail(err)
}
inst, err := m.Get(fs.Args()[0])
if err != nil {
return fail(err)
}
if err := m.Logs(inst, os.Stdout, os.Stderr, follow, nil); err != nil {
return fail(err)
}
return 0
}
// psColumns is `docker ps` default table.
var psColumns = []column{
{"CONTAINER ID", "{{.ID}}"},
{"IMAGE", "{{.Image}}"},
{"COMMAND", "{{.Command}}"},
{"CREATED", "{{.RunningFor}}"},
{"STATUS", "{{.Status}}"},
{"PORTS", "{{.Ports}}"},
{"NAMES", "{{.Names}}"},
}
// psRow is one instance as `ps --format` sees it, docker's field names.
type psRow struct{ inst *instance.Instance }
func (r psRow) ID() string { return r.inst.ShortID() }
func (r psRow) Image() string { return r.inst.Config.Image }
func (r psRow) Names() string { return r.inst.Config.Name }
func (r psRow) CreatedAt() string {
return r.inst.Config.Created.Format("2006-01-02 15:04:05 -0700 MST")
}
func (r psRow) RunningFor() string {
return humanDuration(time.Since(r.inst.Config.Created)) + " ago"
}
// Ports is always empty: the sandbox shares the host's network, so a
// service is reachable at the port it binds, without a mapping.
func (r psRow) Ports() string { return "" }
// Command is the command quoted and cut to 20 characters, as docker's
// default table shows it; --no-trunc is not offered, --format is.
func (r psRow) Command() string {
cmd := strings.Join(r.inst.Config.Spec.Command, " ")
if len(cmd) > 20 {
cmd = cmd[:19] + "…"
}
return strconv.Quote(cmd)
}
// State is the lifecycle word; Status the sentence docker prints.
func (r psRow) State() string { return string(r.inst.State.Status) }
func (r psRow) Status() string {
s := r.inst.State
switch s.Status {
case instance.StatusRunning:
return "Up " + humanDuration(time.Since(s.StartedAt))
case instance.StatusExited:
return fmt.Sprintf("Exited (%d) %s ago", s.ExitCode, humanDuration(time.Since(s.FinishedAt)))
}
return "Created"
}
func psCmd(args []string) int {
fs := flag.NewFlagSet("ps", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var all, quiet bool
var format string
fs.BoolVar(&all, "a", false, "show stopped and created instances too")
fs.BoolVar(&all, "all", false, "show stopped and created instances too")
fs.BoolVar(&quiet, "q", false, "print ids only")
fs.BoolVar(&quiet, "quiet", false, "print ids only")
fs.StringVar(&format, "format", "", "Go template or \"table [TEMPLATE]\" over .ID .Image .Command .CreatedAt .RunningFor .Status .State .Ports .Names")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) != 0 {
return fail(fmt.Errorf("ps takes no arguments (got %q)", fs.Args()))
}
m, err := manager()
if err != nil {
return fail(err)
}
instances, err := m.List()
if err != nil {
return fail(err)
}
var rows []interface{}
for _, inst := range instances {
if !all && !inst.Running() {
continue
}
rows = append(rows, psRow{inst: inst})
}
switch {
case quiet:
for _, row := range rows {
fmt.Println(row.(psRow).ID())
}
default:
if format == "" {
format = "table"
}
if err := printFormatted(os.Stdout, format, psColumns, rows); err != nil {
return fail(err)
}
}
return 0
}
// execCmd runs a second command in an instance's sandbox: the same
// rootfs, mounts, uid, environment, and — because /run and the
// anonymous volumes are directories of the instance — the same
// sockets and data. Not the same pid namespace: the instance's
// processes are not visible.
func execCmd(args []string) int {
fs := flag.NewFlagSet("exec", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var envs stringList
var workdir, user string
var interactive bool
fs.Var(&envs, "e", "environment variable KEY=VALUE")
fs.Var(&envs, "env", "environment variable KEY=VALUE")
fs.StringVar(&workdir, "w", "", "working directory")
fs.StringVar(&workdir, "workdir", "", "working directory")
fs.StringVar(&user, "u", "", "user[:group] to appear as")
fs.StringVar(&user, "user", "", "user[:group] to appear as")
fs.BoolVar(&interactive, "i", false, "pass stdin to the command")
fs.BoolVar(&interactive, "interactive", false, "pass stdin to the command")
refuse(fs, "t", "werkdock allocates no pseudo-terminal; run without -t")
refuse(fs, "tty", "werkdock allocates no pseudo-terminal; run without -t")
refuse(fs, "d", "a detached exec has no instance to record it; use run -d for a second service")
refuse(fs, "detach", "a detached exec has no instance to record it; use run -d for a second service")
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) < 2 {
return fail(errors.New("exec needs an INSTANCE and a COMMAND (werkdock exec [-e K=V] [-w DIR] [-u USER] INSTANCE COMMAND [ARG...])"))
}
m, err := manager()
if err != nil {
return fail(err)
}
inst, err := m.Get(fs.Args()[0])
if err != nil {
return fail(err)
}
if !inst.Running() {
return fail(fmt.Errorf("instance %s is not running", inst.Config.Name))
}
spec := inst.Config.Spec
spec.Command = fs.Args()[1:]
if workdir != "" {
spec.Workdir = workdir
}
for _, e := range envs {
if v, ok := parseEnv(e, os.LookupEnv); ok {
spec.Env = append(spec.Env, v)
}
}
if user != "" {
u, err := engine.LookupUser(spec.RootFS, user)
if err != nil {
return fail(fmt.Errorf("--user: %w", err))
}
spec.UID, spec.GID, spec.Home = u.UID, u.GID, u.Home
}
eng := &engine.Bwrap{}
if interactive {
eng.Stdin = os.Stdin
}
code, err := eng.Run(spec)
if err != nil {
return fail(err)
}
return code
}
// superviseCmd is the background half of run -d and start: not a
// user verb, started by werkdock itself as `werkdock supervise ID`
// with the output going to the instance's files.
func superviseCmd(args []string) int {
if len(args) != 1 {
return fail(errors.New("supervise takes exactly one instance id"))
}
m, err := manager()
if err != nil {
return fail(err)
}
inst, err := m.Get(args[0])
if err != nil {
return fail(err)
}
code, err := m.Supervise(inst, &engine.Bwrap{}, instance.SuperviseOptions{Record: true})
if err != nil {
return fail(err)
}
return code
}