werkdock: ordered mounts with --tmpfs, images verb, :rw accepted

Session C groundwork: Werkator's git-metadata mask needs a tmpfs
BETWEEN binds (ro-bind .git, tmpfs .git/werkator, bind workspace), so
-v and --tmpfs now collect into one ordered mount list and RunSpec
carries Mounts instead of Binds. --tmpfs DEST is the docker flag of the
same name. `werkdock images` lists loaded image names one per line, so
a consumer can check existence through the CLI. -v accepts the explicit
:rw docker default instead of refusing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-01 15:39:31 +02:00
co-authored by Claude Fable 5
parent 263245d49e
commit a2da6454f7
9 changed files with 236 additions and 66 deletions
+5 -1
View File
@@ -27,6 +27,8 @@ func Main(args []string) int {
return runCmd(args[1:])
case "load":
return loadCmd(args[1:])
case "images":
return imagesCmd(args[1:])
case "doctor":
return doctorCmd(args[1:])
case "version", "--version":
@@ -49,11 +51,13 @@ Network, uid, /proc, /dev, and /tmp come from the host by contract.
Usage:
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
werkdock images list loaded images, one name per line
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
werkdock version print the version
Run flags:
-v, --volume SRC:DEST[:ro] bind mount (repeatable, applied in order)
-v, --volume SRC:DEST[:ro] bind mount (repeatable; -v and --tmpfs apply in flag order)
--tmpfs DEST empty tmpfs at DEST (repeatable)
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
-w, --workdir DIR working directory inside the sandbox (default /)
--rm remove the instance afterwards (currently required)
+35
View File
@@ -0,0 +1,35 @@
package cli
import (
"flag"
"fmt"
"io"
"werkdock/internal/store"
)
// imagesCmd prints the loaded image names, one per line — machine-usable
// (Werkator checks image existence through it) and close enough to
// `docker images --format '{{.Repository}}'`.
func imagesCmd(args []string) int {
fs := flag.NewFlagSet("images", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return fail(err)
}
if len(fs.Args()) != 0 {
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
}
st, err := store.Default()
if err != nil {
return fail(err)
}
names, err := st.List()
if err != nil {
return fail(err)
}
for _, name := range names {
fmt.Println(name)
}
return 0
}
+57 -29
View File
@@ -16,7 +16,7 @@ import (
// 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 {
Volumes []engine.Bind
Mounts []engine.Mount
Env []engine.EnvVar
Workdir string
Remove bool
@@ -39,7 +39,7 @@ func runCmd(args []string) int {
}
spec := engine.RunSpec{
RootFS: rootfs,
Binds: hostBinds(opts.Volumes),
Mounts: hostMounts(opts.Mounts),
Env: opts.Env,
Workdir: opts.Workdir,
Command: opts.Command,
@@ -52,15 +52,15 @@ func runCmd(args []string) int {
return code
}
// hostBinds prepends the host mounts the contract prescribes: DNS comes
// 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 binds, so an explicit bind over /etc wins.
func hostBinds(volumes []engine.Bind) []engine.Bind {
var binds []engine.Bind
// 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() {
binds = append(binds, engine.Bind{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true})
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
}
return append(binds, volumes...)
return append(all, mounts...)
}
// parseRun parses the docker-shaped run flags. Docker flags whose
@@ -69,10 +69,16 @@ func hostBinds(volumes []engine.Bind) []engine.Bind {
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var volumes, envs stringList
var envs stringList
opts := &runOptions{}
fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]")
fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]")
// -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}
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
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(&envs, "e", "environment variable KEY=VALUE")
fs.Var(&envs, "env", "environment variable KEY=VALUE")
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
@@ -101,13 +107,6 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
}
opts.Image = rest[0]
opts.Command = rest[1:]
for _, v := range volumes {
bind, err := parseVolume(v)
if err != nil {
return nil, err
}
opts.Volumes = append(opts.Volumes, bind)
}
for _, e := range envs {
opts.Env = append(opts.Env, parseEnv(e, getenv))
}
@@ -117,25 +116,54 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
return opts, nil
}
func parseVolume(v string) (engine.Bind, error) {
func parseVolume(v string) (engine.Mount, error) {
parts := strings.Split(v, ":")
if len(parts) < 2 || len(parts) > 3 {
return engine.Bind{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
}
bind := engine.Bind{Source: parts[0], Dest: parts[1]}
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
if len(parts) == 3 {
if parts[2] != "ro" {
return engine.Bind{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' is supported", parts[2], v)
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)
}
bind.ReadOnly = true
}
if !filepath.IsAbs(bind.Source) {
return engine.Bind{}, fmt.Errorf("volume source must be an absolute path: %s", bind.Source)
if !filepath.IsAbs(mount.Source) {
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
}
if !filepath.IsAbs(bind.Dest) {
return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest)
if !filepath.IsAbs(mount.Dest) {
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
}
return bind, nil
return mount, nil
}
// mountFlag appends -v/--volume and --tmpfs values to one shared,
// ordered mount list.
type mountFlag struct {
mounts *[]engine.Mount
tmpfs bool
}
func (f *mountFlag) String() string { return "" }
func (f *mountFlag) Set(v string) error {
if f.tmpfs {
if !filepath.IsAbs(v) {
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
}
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
return nil
}
mount, err := parseVolume(v)
if err != nil {
return err
}
*f.mounts = append(*f.mounts, mount)
return nil
}
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
+40 -6
View File
@@ -28,12 +28,12 @@ func TestParseRunSupportedFlags(t *testing.T) {
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
t.Errorf("command: got %q", opts.Command)
}
wantVolumes := []engine.Bind{
{Source: "/repo", Dest: "/repo"},
{Source: "/cache", Dest: "/root/.gradle", ReadOnly: true},
wantMounts := []engine.Mount{
{Mode: engine.MountBind, Source: "/repo", Dest: "/repo"},
{Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"},
}
if !reflect.DeepEqual(opts.Volumes, wantVolumes) {
t.Errorf("volumes: got %+v", opts.Volumes)
if !reflect.DeepEqual(opts.Mounts, wantMounts) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
t.Errorf("env: got %+v", opts.Env)
@@ -96,7 +96,8 @@ func TestParseRunValidation(t *testing.T) {
{"no image", []string{"--rm"}, "no image specified"},
{"no command", []string{"--rm", "img"}, "no command specified"},
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
{"volume with bad option", []string{"--rm", "-v", "/a:/b:rw", "img", "true"}, "only 'ro' is supported"},
{"volume with bad option", []string{"--rm", "-v", "/a:/b:cached", "img", "true"}, "only 'ro' and 'rw' are supported"},
{"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"},
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
@@ -111,6 +112,39 @@ func TestParseRunValidation(t *testing.T) {
}
}
func TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) {
// The git-metadata mask depends on it: ro-bind .git, tmpfs over
// .git/werkator, then the workspace bind — in exactly this order.
opts, err := parseRun([]string{
"--rm",
"-v", "/r/.git:/r/.git:ro",
"--tmpfs", "/r/.git/werkator",
"-v", "/r/ws:/r/ws",
"img", "true",
}, noEnv)
if err != nil {
t.Fatal(err)
}
want := []engine.Mount{
{Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"},
{Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"},
{Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"},
}
if !reflect.DeepEqual(opts.Mounts, want) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) {
opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) {
t.Errorf("mounts: got %+v", opts.Mounts)
}
}
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
// Docker semantics: everything after the image belongs to the
// command, even if it looks like a flag.