An instance is a directory under instances/<id>/ (config.json, state.json, stdout, stderr, run/, volumes/) written by package instance, the service behind the verbs. run -d and start fork this binary as a supervisor (werkdock supervise ID) that records the pids and the exit code; stop, kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap over the same instance tree. The command is pid 1 of its sandbox (bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and the anonymous volumes are instance directories, kept across stop/start and shared with exec. Version 0.5.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #4
500 lines
17 KiB
Go
500 lines
17 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"werkdock/internal/engine"
|
|
"werkdock/internal/instance"
|
|
"werkdock/internal/store"
|
|
)
|
|
|
|
// runOptions is the parsed form of `werkdock run` flags, separated from
|
|
// execution so the parsing is testable and a later daemon can reuse it.
|
|
type runOptions struct {
|
|
Mounts []engine.Mount
|
|
Env []engine.EnvVar
|
|
Workdir string
|
|
Remove bool
|
|
Image string
|
|
// Entrypoint is non-nil when --entrypoint was given; an empty value
|
|
// clears the image's entrypoint (docker semantics).
|
|
Entrypoint *string
|
|
// Args are the positional arguments after the image: the command, or
|
|
// the entrypoint's arguments when one is given.
|
|
Args []string
|
|
Command []string
|
|
// User is --user as given (name or id, optional :group); KeepID is
|
|
// --userns=keep-id, podman's "run as myself". Empty and false leave
|
|
// the image's User or, failing that, uid 0 (RFC 0005).
|
|
User string
|
|
KeepID bool
|
|
// Detach is -d: start the instance under a background supervisor
|
|
// and print its id. Name is --name. Interactive is -i: the
|
|
// caller's stdin goes to the command (no pty; -t is refused).
|
|
Detach bool
|
|
Name string
|
|
Interactive bool
|
|
}
|
|
|
|
func runCmd(args []string) int {
|
|
opts, err := parseRun(args, os.LookupEnv)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
st, err := store.Default()
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
inst, err := createInstance(st, opts)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
m := instance.Manager{Root: st.Root}
|
|
if opts.Detach {
|
|
if err := startDetached(m, inst); err != nil {
|
|
return fail(err)
|
|
}
|
|
fmt.Println(inst.Config.ID)
|
|
return 0
|
|
}
|
|
var stdin io.Reader
|
|
if opts.Interactive {
|
|
stdin = os.Stdin
|
|
}
|
|
code, err := m.Supervise(inst, &engine.Bwrap{}, instance.SuperviseOptions{
|
|
Stdout: os.Stdout, Stderr: os.Stderr, Stdin: stdin, Record: !opts.AutoRemove(),
|
|
})
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return code
|
|
}
|
|
|
|
// AutoRemove is --rm.
|
|
func (o *runOptions) AutoRemove() bool { return o.Remove }
|
|
|
|
// createInstance resolves the image, builds the RunSpec, and writes
|
|
// the instance directory — what `create` does, and the first half of
|
|
// `run`. The bwrap invocation is checked here (mountpoints, workdir)
|
|
// so a bad command line fails now and not in a detached supervisor.
|
|
func createInstance(st store.Store, opts *runOptions) (*instance.Instance, error) {
|
|
ref, err := store.ParseReference(opts.Image)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootfs, err := st.RootFS(opts.Image)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, err := st.Meta(opts.Image)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
spec, err := buildSpec(opts, rootfs, meta.Config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := (&engine.Bwrap{}).Argv(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := engine.EnsureMountpoints(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := engine.CheckWorkdir(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
m := instance.Manager{Root: st.Root}
|
|
return m.Create(instance.Config{
|
|
Name: opts.Name,
|
|
Image: ref.Short(),
|
|
Spec: spec,
|
|
Volumes: anonymousVolumes(meta.Config.Volumes, opts.Mounts),
|
|
AutoRemove: opts.AutoRemove(),
|
|
})
|
|
}
|
|
|
|
// startDetached launches the supervisor as `werkdock supervise ID`,
|
|
// using this very binary.
|
|
func startDetached(m instance.Manager, inst *instance.Instance) error {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.StartDetached(inst, self)
|
|
}
|
|
|
|
// buildSpec merges the flags with the image configuration the docker
|
|
// way: the image's Env first, then -e (later wins); -w over WorkingDir;
|
|
// --entrypoint over Entrypoint; positional arguments over Cmd, and an
|
|
// explicit --entrypoint drops the image's Cmd.
|
|
func buildSpec(opts *runOptions, rootfs string, config store.ImageConfig) (engine.RunSpec, error) {
|
|
spec := engine.RunSpec{
|
|
RootFS: rootfs,
|
|
Mounts: hostMounts(opts.Mounts),
|
|
Workdir: opts.Workdir,
|
|
}
|
|
user, err := resolveUser(rootfs, opts, config)
|
|
if err != nil {
|
|
return engine.RunSpec{}, err
|
|
}
|
|
spec.UID, spec.GID, spec.Home = user.UID, user.GID, user.Home
|
|
for _, e := range config.Env {
|
|
key, value, _ := strings.Cut(e, "=")
|
|
spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: value})
|
|
}
|
|
spec.Env = append(spec.Env, opts.Env...)
|
|
if spec.Workdir == "" {
|
|
spec.Workdir = config.WorkingDir
|
|
}
|
|
entrypoint := config.Entrypoint
|
|
cmd := config.Cmd
|
|
if opts.Entrypoint != nil {
|
|
entrypoint = nil
|
|
if *opts.Entrypoint != "" {
|
|
entrypoint = []string{*opts.Entrypoint}
|
|
}
|
|
cmd = nil
|
|
}
|
|
if len(opts.Args) > 0 {
|
|
cmd = opts.Args
|
|
}
|
|
spec.Command = append(append([]string{}, entrypoint...), cmd...)
|
|
if len(spec.Command) == 0 {
|
|
return engine.RunSpec{}, errors.New("no command specified and the image has no ENTRYPOINT or CMD")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// resolveUser picks the identity the caller appears as: --user, else
|
|
// --userns=keep-id (the caller's own uid and gid), else the image's
|
|
// User, else root — podman's order. A name resolves in the image's
|
|
// /etc/passwd; a plain number needs no entry.
|
|
func resolveUser(rootfs string, opts *runOptions, config store.ImageConfig) (engine.User, error) {
|
|
switch {
|
|
case opts.User != "":
|
|
u, err := engine.LookupUser(rootfs, opts.User)
|
|
if err != nil {
|
|
return engine.User{}, fmt.Errorf("--user: %w", err)
|
|
}
|
|
return u, nil
|
|
case opts.KeepID:
|
|
u, err := engine.LookupUser(rootfs, strconv.Itoa(os.Getuid()))
|
|
if err != nil {
|
|
return engine.User{}, err
|
|
}
|
|
u.GID = os.Getgid()
|
|
return u, nil
|
|
case config.User != "":
|
|
u, err := engine.LookupUser(rootfs, config.User)
|
|
if err != nil {
|
|
return engine.User{}, fmt.Errorf("image USER: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
return engine.User{}, nil
|
|
}
|
|
|
|
// anonymousVolumes lists the image's VOLUME paths that no explicit
|
|
// mount covers, sorted; the instance binds a directory of its own at
|
|
// each (docker's anonymous volumes: born with the instance, kept
|
|
// across stop and start, gone with rm).
|
|
func anonymousVolumes(volumes map[string]struct{}, explicit []engine.Mount) []string {
|
|
var paths []string
|
|
for p := range volumes {
|
|
p = filepath.Clean(p)
|
|
if !mountCovers(explicit, p) {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
return paths
|
|
}
|
|
|
|
func mountCovers(mounts []engine.Mount, path string) bool {
|
|
for _, m := range mounts {
|
|
dest := filepath.Clean(m.Dest)
|
|
if path == dest || strings.HasPrefix(path, strings.TrimSuffix(dest, "/")+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// usernsFlag accepts podman's --userns=keep-id and refuses the modes
|
|
// that need more than one uid mapping.
|
|
type usernsFlag struct{ keepID *bool }
|
|
|
|
func (f usernsFlag) String() string { return "" }
|
|
func (f usernsFlag) Set(v string) error {
|
|
if v != "keep-id" {
|
|
return fmt.Errorf("--userns=%s: only keep-id is available; an unprivileged user namespace maps the caller to one uid (use --user for a specific one)", v)
|
|
}
|
|
*f.keepID = true
|
|
return nil
|
|
}
|
|
|
|
// hostMounts prepends the host mounts the contract prescribes: DNS comes
|
|
// from the host, so /etc/resolv.conf is bound read-only when it exists —
|
|
// before the user mounts, so an explicit mount over /etc wins.
|
|
func hostMounts(mounts []engine.Mount) []engine.Mount {
|
|
var all []engine.Mount
|
|
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
|
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
|
|
}
|
|
return append(all, mounts...)
|
|
}
|
|
|
|
// parseRun parses the docker-shaped run flags. Docker flags whose
|
|
// promise werkdock cannot keep are registered and refused with a
|
|
// reason — never silently ignored (RFC 0002).
|
|
func parseRun(args []string, lookupEnv func(string) (string, bool)) (*runOptions, error) {
|
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var envs stringList
|
|
opts := &runOptions{}
|
|
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
|
|
// order, so a tmpfs between two binds (the git-metadata mask) must stay
|
|
// between them.
|
|
volumes := &mountFlag{mounts: &opts.Mounts, parse: parseVolume}
|
|
tmpfs := &mountFlag{mounts: &opts.Mounts, parse: parseTmpfs}
|
|
mount := &mountFlag{mounts: &opts.Mounts, parse: parseMount}
|
|
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
|
|
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
|
|
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
|
|
fs.Var(mount, "mount", "mount in docker's explicit form, type=bind,src=SRC,dst=DEST[,ro] or type=tmpfs,dst=DEST")
|
|
fs.Var(&envs, "e", "environment variable KEY=VALUE")
|
|
fs.Var(&envs, "env", "environment variable KEY=VALUE")
|
|
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
|
|
fs.StringVar(&opts.Workdir, "workdir", "", "working directory inside the sandbox")
|
|
fs.BoolVar(&opts.Remove, "rm", false, "remove the instance afterwards")
|
|
fs.Var(&optionalString{value: &opts.Entrypoint}, "entrypoint", "override the entrypoint of the image")
|
|
refuse(fs, "p", "port mapping needs a network of its own; the sandbox shares the host's network and binds host ports directly (docker's --network host)")
|
|
refuse(fs, "publish", "port mapping needs a network of its own; the sandbox shares the host's network and binds host ports directly (docker's --network host)")
|
|
fs.Var(networkFlag{}, "network", "network mode; only \"host\" is available")
|
|
fs.Var(networkFlag{}, "net", "network mode; only \"host\" is available")
|
|
refuse(fs, "memory", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
|
|
refuse(fs, "cpus", "werkdock does not manage resources; use the host's limits (e.g. systemd)")
|
|
fs.StringVar(&opts.User, "u", "", "user[:group] the caller appears as inside the sandbox")
|
|
fs.StringVar(&opts.User, "user", "", "user[:group] the caller appears as inside the sandbox")
|
|
fs.Var(usernsFlag{keepID: &opts.KeepID}, "userns", "user namespace mode; only \"keep-id\" is available")
|
|
fs.BoolVar(&opts.Detach, "d", false, "run the instance in the background and print its id")
|
|
fs.BoolVar(&opts.Detach, "detach", false, "run the instance in the background and print its id")
|
|
fs.StringVar(&opts.Name, "name", "", "instance name (default: the image's basename plus six characters of the id)")
|
|
fs.BoolVar(&opts.Interactive, "i", false, "pass stdin to the command")
|
|
fs.BoolVar(&opts.Interactive, "interactive", false, "pass stdin to the command")
|
|
refuse(fs, "t", "werkdock allocates no pseudo-terminal; run without -t (output is passed through as is)")
|
|
refuse(fs, "tty", "werkdock allocates no pseudo-terminal; run without -t (output is passed through as is)")
|
|
refuse(fs, "restart", "werkdock has no daemon to restart instances; use a systemd user unit")
|
|
if err := fs.Parse(args); err != nil {
|
|
return nil, err
|
|
}
|
|
rest := fs.Args()
|
|
if len(rest) == 0 {
|
|
return nil, errors.New("no image specified")
|
|
}
|
|
opts.Image = rest[0]
|
|
opts.Args = rest[1:]
|
|
for _, e := range envs {
|
|
if v, ok := parseEnv(e, lookupEnv); ok {
|
|
opts.Env = append(opts.Env, v)
|
|
}
|
|
}
|
|
if opts.Workdir != "" && !filepath.IsAbs(opts.Workdir) {
|
|
return nil, fmt.Errorf("workdir must be an absolute path: %s", opts.Workdir)
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func parseVolume(v string) (engine.Mount, error) {
|
|
parts := strings.Split(v, ":")
|
|
if len(parts) < 2 || len(parts) > 3 {
|
|
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
|
|
}
|
|
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
|
|
if len(parts) == 3 {
|
|
switch parts[2] {
|
|
case "ro":
|
|
mount.Mode = engine.MountRoBind
|
|
case "rw":
|
|
// docker accepts :rw as the explicit default; so do we
|
|
default:
|
|
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
|
|
}
|
|
}
|
|
return mount, checkBind(mount)
|
|
}
|
|
|
|
func checkBind(mount engine.Mount) error {
|
|
if !filepath.IsAbs(mount.Source) {
|
|
return fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
|
|
}
|
|
if !filepath.IsAbs(mount.Dest) {
|
|
return fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseTmpfs(v string) (engine.Mount, error) {
|
|
if !filepath.IsAbs(v) {
|
|
return engine.Mount{}, fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
|
|
}
|
|
return engine.Mount{Mode: engine.MountTmpfs, Dest: v}, nil
|
|
}
|
|
|
|
// parseMount reads docker's explicit --mount syntax, a comma-separated
|
|
// key=value list, onto the same mount model as -v and --tmpfs. The
|
|
// bind and tmpfs types are supported with docker's key aliases; volume
|
|
// mounts need docker's volume layer, and the option keys that promise
|
|
// kernel-level tuning (bind-propagation, tmpfs-size, ...) are refused
|
|
// rather than ignored.
|
|
func parseMount(v string) (engine.Mount, error) {
|
|
var typ, src, dst string
|
|
readonly := false
|
|
for _, field := range strings.Split(v, ",") {
|
|
key, value, hasValue := strings.Cut(field, "=")
|
|
switch key {
|
|
case "type":
|
|
typ = value
|
|
case "source", "src":
|
|
src = value
|
|
case "destination", "dst", "target":
|
|
dst = value
|
|
case "readonly", "ro":
|
|
switch {
|
|
case !hasValue, value == "true", value == "1":
|
|
readonly = true
|
|
case value == "false", value == "0":
|
|
readonly = false
|
|
default:
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: %s must be true or false", v, key)
|
|
}
|
|
case "":
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: empty field", v)
|
|
default:
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: option %q is not supported (supported: type, source/src, destination/dst/target, readonly/ro)", v, key)
|
|
}
|
|
}
|
|
if typ == "" {
|
|
typ = "bind" // docker's default when type is omitted
|
|
}
|
|
if dst == "" {
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: destination (dst) is required", v)
|
|
}
|
|
switch typ {
|
|
case "bind":
|
|
if src == "" {
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: a bind mount needs a source (src)", v)
|
|
}
|
|
mount := engine.Mount{Mode: engine.MountBind, Source: src, Dest: dst}
|
|
if readonly {
|
|
mount.Mode = engine.MountRoBind
|
|
}
|
|
return mount, checkBind(mount)
|
|
case "tmpfs":
|
|
if src != "" {
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: a tmpfs has no source", v)
|
|
}
|
|
if readonly {
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: a read-only tmpfs is not supported", v)
|
|
}
|
|
return parseTmpfs(dst)
|
|
case "volume":
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: named volumes need a volume layer werkdock does not have; bind a host directory instead", v)
|
|
default:
|
|
return engine.Mount{}, fmt.Errorf("invalid --mount %q: type %q is not supported (supported: bind, tmpfs)", v, typ)
|
|
}
|
|
}
|
|
|
|
// mountFlag appends -v/--volume, --tmpfs, and --mount values to one
|
|
// shared, ordered mount list.
|
|
type mountFlag struct {
|
|
mounts *[]engine.Mount
|
|
parse func(string) (engine.Mount, error)
|
|
}
|
|
|
|
func (f *mountFlag) String() string { return "" }
|
|
|
|
func (f *mountFlag) Set(v string) error {
|
|
mount, err := f.parse(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*f.mounts = append(*f.mounts, mount)
|
|
return nil
|
|
}
|
|
|
|
// parseEnv follows docker: KEY=VALUE sets, a bare KEY copies the
|
|
// variable from the host — and is dropped, not set empty, when the host
|
|
// does not have it.
|
|
func parseEnv(e string, lookupEnv func(string) (string, bool)) (engine.EnvVar, bool) {
|
|
if key, value, found := strings.Cut(e, "="); found {
|
|
return engine.EnvVar{Key: key, Value: value}, true
|
|
}
|
|
value, ok := lookupEnv(e)
|
|
return engine.EnvVar{Key: e, Value: value}, ok
|
|
}
|
|
|
|
// optionalString is a string flag that remembers whether it was given
|
|
// at all, so `--entrypoint ""` can be told apart from no --entrypoint.
|
|
type optionalString struct {
|
|
value **string
|
|
}
|
|
|
|
func (f *optionalString) String() string { return "" }
|
|
|
|
func (f *optionalString) Set(v string) error {
|
|
*f.value = &v
|
|
return nil
|
|
}
|
|
|
|
// stringList collects a repeatable flag's values in order.
|
|
type stringList []string
|
|
|
|
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
|
|
|
func (s *stringList) Set(v string) error {
|
|
*s = append(*s, v)
|
|
return nil
|
|
}
|
|
|
|
// networkFlag accepts docker's --network host, which names exactly what
|
|
// the sandbox has (the host's network, by contract) and is therefore a
|
|
// no-op; every other mode would need a network namespace of its own.
|
|
type networkFlag struct{}
|
|
|
|
func (networkFlag) String() string { return "host" }
|
|
|
|
func (networkFlag) Set(mode string) error {
|
|
if mode == "host" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("network mode %q is not available in unprivileged shared-webspace environments; only \"host\" is supported", mode)
|
|
}
|
|
|
|
// refusedFlag rejects a known docker flag with the reason werkdock
|
|
// cannot honor it.
|
|
type refusedFlag struct {
|
|
name string
|
|
reason string
|
|
}
|
|
|
|
func (f *refusedFlag) String() string { return "" }
|
|
|
|
func (f *refusedFlag) Set(string) error {
|
|
return fmt.Errorf("flag -%s is not supported: %s", f.name, f.reason)
|
|
}
|
|
|
|
func (f *refusedFlag) IsBoolFlag() bool { return true }
|
|
|
|
func refuse(fs *flag.FlagSet, name, reason string) {
|
|
fs.Var(&refusedFlag{name: name, reason: reason}, name, reason)
|
|
}
|