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 }