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) }