werkator build succeeded after 00:04
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
428 lines
12 KiB
Go
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
|
|
}
|