diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e273528 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/werkdock +/dist/ diff --git a/README.md b/README.md index 809ad50..bd66fb2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,22 @@ Semantics — docker-compatible as far as the filesystem-only contract allows (s - `werkdock doctor` checks the host: user-namespace capability, disk and quota headroom. - A daemon speaking the Docker Engine API subset (for Testcontainers) is designed for but deferred. +## Build and Test + +```bash +go test ./... # all tests; sandbox integration tests skip without bwrap/userns +go vet ./... && gofmt -l . # quality gates (gofmt must print nothing) +CGO_ENABLED=0 go build . # one static linux binary, ~3 MB +``` + +First steps on a host: + +```bash +werkdock doctor # can this host run sandboxes? +werkdock load -i rootfs.tar.zst # import a rootfs archive as an image +werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build' +``` + Status: bootstrap. The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md). Werkdock grows in this subdirectory of the Werkator repository and moves to its own repository once it stands on its own. diff --git a/docs/rfcs/0002-docker-compatible-surface.md b/docs/rfcs/0002-docker-compatible-surface.md index 89c7c39..f02bd5d 100644 --- a/docs/rfcs/0002-docker-compatible-surface.md +++ b/docs/rfcs/0002-docker-compatible-surface.md @@ -2,7 +2,7 @@ **Status:** - proposed: 2026-09-01 -- accepted: - +- accepted: 2026-09-01 (level 1 as the shape of the CLI; levels 2 and 3 deferred indefinitely) - rejected: - **Proposal:** Werkdock's user-facing surface follows Docker wherever the filesystem-only contract allows: level 1 is a docker-compatible CLI (verbs, flags, exit codes), level 2 is pulling OCI images from registries, level 3 is a daemon offering the Docker Engine REST API subset that Testcontainers needs. @@ -85,4 +85,6 @@ Ryuk stays disabled in documentation until proven. ## Decision Outcome -Pending. +Decided 2026-09-01: level 1 shapes the CLI — verbs and flags follow docker, unsupported flags fail loudly. +Levels 2 and 3 (OCI pull, daemon API, Testcontainers) are deferred indefinitely; nothing in the code may make them harder, nothing is built for them now. +The immediate goal is narrower than level 1's full verb list: `doctor`, `load`, and `run` — enough for the sandbox builds of Werkator, Werkbaum, and Werkdock itself; the remaining verbs follow with need. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..560098c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module werkdock + +go 1.22 diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..903e898 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,68 @@ +// Package cli parses werkdock's docker-shaped command line (RFC 0002) +// and dispatches to the internal packages. Exit codes follow docker: +// 125 for werkdock's own errors, otherwise the sandboxed command's code +// is passed through. +package cli + +import ( + "fmt" + "io" + "os" +) + +// Version is replaced at release time; the dev default marks unreleased +// builds. +var Version = "0.1.0-dev" + +const exitCLIError = 125 + +// Main runs the CLI and returns the process exit code. +func Main(args []string) int { + if len(args) == 0 { + usage(os.Stderr) + return exitCLIError + } + switch args[0] { + case "run": + return runCmd(args[1:]) + case "load": + return loadCmd(args[1:]) + case "doctor": + return doctorCmd(args[1:]) + case "version", "--version": + fmt.Printf("werkdock %s\n", Version) + return 0 + case "help", "--help", "-h": + usage(os.Stdout) + return 0 + default: + fmt.Fprintf(os.Stderr, "werkdock: unknown command %q\n\n", args[0]) + usage(os.Stderr) + return exitCLIError + } +} + +func usage(w io.Writer) { + fmt.Fprint(w, `werkdock — a docker-like sandbox CLI over bwrap, filesystem isolation only. +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 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) + -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) + +The store lives in $WERKDOCK_HOME (default ~/.werkdock). +`) +} + +func fail(err error) int { + fmt.Fprintf(os.Stderr, "werkdock: %v\n", err) + return exitCLIError +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..e8286cc --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,57 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "werkdock/internal/doctor" + "werkdock/internal/store" +) + +func doctorCmd(args []string) int { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + fs.SetOutput(io.Discard) + if err := fs.Parse(args); err != nil { + return fail(err) + } + targetDir := "" + switch len(fs.Args()) { + case 0: + st, err := store.Default() + if err != nil { + return fail(err) + } + targetDir = st.Root + // The store may not exist yet; measure its closest existing + // ancestor, which sits on the same filesystem. + for { + if _, err := os.Stat(targetDir); err == nil { + break + } + parent := filepath.Dir(targetDir) + if parent == targetDir { + break + } + targetDir = parent + } + case 1: + targetDir = fs.Args()[0] + default: + return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1])) + } + report := doctor.Run(targetDir, os.Getuid(), runCombined) + report.Render(os.Stdout) + if report.OK() { + return 0 + } + return 1 +} + +func runCombined(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err +} diff --git a/internal/cli/load.go b/internal/cli/load.go new file mode 100644 index 0000000..b6be2a0 --- /dev/null +++ b/internal/cli/load.go @@ -0,0 +1,40 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "werkdock/internal/store" +) + +func loadCmd(args []string) int { + fs := flag.NewFlagSet("load", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var input, name string + fs.StringVar(&input, "i", "", "rootfs archive to import") + fs.StringVar(&input, "input", "", "rootfs archive to import") + fs.StringVar(&name, "name", "", "image name (default: derived from the archive file name)") + if err := fs.Parse(args); err != nil { + return fail(err) + } + if input == "" { + return fail(errors.New("load needs -i ARCHIVE")) + } + if len(fs.Args()) != 0 { + return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0])) + } + if name == "" { + name = store.ImageNameFromArchive(input) + } + st, err := store.Default() + if err != nil { + return fail(err) + } + if err := st.Load(input, name); err != nil { + return fail(err) + } + fmt.Printf("Loaded image: %s\n", name) + return 0 +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..8168db6 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,175 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "werkdock/internal/engine" + "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 { + Volumes []engine.Bind + Env []engine.EnvVar + Workdir string + Remove bool + Image string + Command []string +} + +func runCmd(args []string) int { + opts, err := parseRun(args, os.Getenv) + if err != nil { + return fail(err) + } + st, err := store.Default() + if err != nil { + return fail(err) + } + rootfs, err := st.RootFS(opts.Image) + if err != nil { + return fail(err) + } + spec := engine.RunSpec{ + RootFS: rootfs, + Binds: hostBinds(opts.Volumes), + Env: opts.Env, + Workdir: opts.Workdir, + Command: opts.Command, + } + eng := &engine.Bwrap{} + code, err := eng.Run(spec) + if err != nil { + return fail(err) + } + return code +} + +// hostBinds 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 + 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}) + } + return append(binds, volumes...) +} + +// 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, getenv func(string) string) (*runOptions, error) { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var volumes, envs stringList + opts := &runOptions{} + fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]") + fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]") + 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") + refuse(fs, "p", "werkdock has no network isolation; the sandbox binds host ports directly") + refuse(fs, "publish", "werkdock has no network isolation; the sandbox binds host ports directly") + refuse(fs, "network", "the network is the host's by contract; there is nothing to configure") + 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)") + refuse(fs, "user", "the sandbox always runs uid 0 mapped to the calling user") + refuse(fs, "d", "detached instances are not implemented yet") + refuse(fs, "detach", "detached instances are not implemented yet") + if err := fs.Parse(args); err != nil { + return nil, err + } + if !opts.Remove { + return nil, errors.New("persistent instances are not implemented yet; run with --rm") + } + rest := fs.Args() + if len(rest) == 0 { + return nil, errors.New("no image specified") + } + if len(rest) == 1 { + return nil, errors.New("no command specified (werkdock images carry no default command yet)") + } + 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)) + } + 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.Bind, 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) + } + bind := engine.Bind{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) + } + 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(bind.Dest) { + return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest) + } + return bind, nil +} + +func parseEnv(e string, getenv func(string) string) engine.EnvVar { + if key, value, found := strings.Cut(e, "="); found { + return engine.EnvVar{Key: key, Value: value} + } + return engine.EnvVar{Key: e, Value: getenv(e)} +} + +// 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 +} + +// 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) +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..d4364cd --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "reflect" + "strings" + "testing" + + "werkdock/internal/engine" +) + +func noEnv(string) string { return "" } + +func TestParseRunSupportedFlags(t *testing.T) { + opts, err := parseRun([]string{ + "--rm", + "-v", "/repo:/repo", + "--volume", "/cache:/root/.gradle:ro", + "-e", "CI=true", + "-w", "/repo", + "buildenv", "sh", "-c", "./gradlew build", + }, noEnv) + if err != nil { + t.Fatal(err) + } + if opts.Image != "buildenv" { + t.Errorf("image: got %q", opts.Image) + } + 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}, + } + if !reflect.DeepEqual(opts.Volumes, wantVolumes) { + t.Errorf("volumes: got %+v", opts.Volumes) + } + if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) { + t.Errorf("env: got %+v", opts.Env) + } + if opts.Workdir != "/repo" { + t.Errorf("workdir: got %q", opts.Workdir) + } +} + +func TestParseRunCopiesBareEnvKeysFromTheHost(t *testing.T) { + getenv := func(key string) string { + if key == "LANG" { + return "C.UTF-8" + } + return "" + } + opts, err := parseRun([]string{"--rm", "-e", "LANG", "img", "true"}, getenv) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "LANG", Value: "C.UTF-8"}}) { + t.Errorf("env: got %+v", opts.Env) + } +} + +func TestParseRunRefusesDockerFlagsLoudly(t *testing.T) { + tests := []struct { + args []string + wantReason string + }{ + {[]string{"--rm", "-p", "8080:80", "img", "true"}, "no network isolation"}, + {[]string{"--rm", "--network", "host", "img", "true"}, "network is the host's"}, + {[]string{"--rm", "--memory", "1g", "img", "true"}, "does not manage resources"}, + {[]string{"--rm", "--user", "1000", "img", "true"}, "uid 0 mapped to the calling user"}, + {[]string{"--rm", "-d", "img", "true"}, "not implemented yet"}, + } + for _, tt := range tests { + t.Run(strings.Join(tt.args, " "), func(t *testing.T) { + _, err := parseRun(tt.args, noEnv) + if err == nil || !strings.Contains(err.Error(), tt.wantReason) { + t.Errorf("got %v, want refusal containing %q", err, tt.wantReason) + } + }) + } +} + +func TestParseRunRequiresRmForNow(t *testing.T) { + _, err := parseRun([]string{"img", "true"}, noEnv) + if err == nil || !strings.Contains(err.Error(), "--rm") { + t.Errorf("got %v, want the --rm requirement", err) + } +} + +func TestParseRunValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + {"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"}, + {"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"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseRun(tt.args, noEnv) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) { + // Docker semantics: everything after the image belongs to the + // command, even if it looks like a flag. + opts, err := parseRun([]string{"--rm", "img", "ls", "-la", "/tmp"}, noEnv) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(opts.Command, []string{"ls", "-la", "/tmp"}) { + t.Errorf("command: got %q", opts.Command) + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go new file mode 100644 index 0000000..910b5b7 --- /dev/null +++ b/internal/doctor/doctor.go @@ -0,0 +1,292 @@ +// Package doctor checks whether this host can run werkdock sandboxes: +// unprivileged user namespaces with a uid-0 mapping and enforced +// read-only root binds, the required CLI tools, and disk/quota headroom +// for the build footprint. It is a port of Werkator's +// werkator-build-prerequisites.sh, with the same PASS/FAIL output. +package doctor + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// MinFreeKiB is the disk footprint a sandbox build needs headroom for: +// unpacked rootfs (zstd expands roughly 3-4x), toolchain caches, build +// output. ~5 GiB, in KiB. +const MinFreeKiB = 5 * 1024 * 1024 + +// Runner executes a command and returns its combined output; injected +// so the evaluation logic is testable against captured fixtures. +type Runner func(name string, args ...string) (string, error) + +// Report is the outcome of all checks. +type Report struct { + Checks []Check + Warnings []string +} + +// Check is one PASS/FAIL line. +type Check struct { + OK bool + Msg string +} + +func (r *Report) pass(format string, a ...any) { + r.Checks = append(r.Checks, Check{OK: true, Msg: fmt.Sprintf(format, a...)}) +} + +func (r *Report) fail(format string, a ...any) { + r.Checks = append(r.Checks, Check{OK: false, Msg: fmt.Sprintf(format, a...)}) +} + +func (r *Report) warn(format string, a ...any) { + r.Warnings = append(r.Warnings, fmt.Sprintf(format, a...)) +} + +// OK reports whether no check failed. +func (r *Report) OK() bool { + for _, c := range r.Checks { + if !c.OK { + return false + } + } + return true +} + +// Run executes all checks against targetDir (where images and build +// workspaces will live). +func Run(targetDir string, selfUID int, run Runner) *Report { + r := &Report{} + sandboxChecks(r, selfUID, run) + toolChecks(r) + diskChecks(r, targetDir, run) + return r +} + +// sandboxProbe is the command run inside the sandbox; its three output +// lines are the signals evaluated below. +const sandboxProbe = "id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)" + +func sandboxChecks(r *Report, selfUID int, run Runner) { + if _, err := exec.LookPath("bwrap"); err != nil { + r.fail("bwrap is not installed on this host") + return + } + version, err := run("bwrap", "--version") + if err != nil { + r.fail("bwrap --version failed: %v", err) + return + } + r.pass("bwrap version: %s", strings.TrimSpace(version)) + out, err := run("bwrap", + "--unshare-user", "--unshare-pid", "--die-with-parent", + "--uid", "0", "--gid", "0", + "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", + "sh", "-c", sandboxProbe) + if err != nil { + r.fail("bwrap invocation failed (no user namespace support?): %s", strings.TrimSpace(out)) + return + } + EvaluateSandbox(r, out, selfUID) +} + +// EvaluateSandbox checks the three signals of the sandbox probe output: +// uid 0 inside, a uid_map back to the unprivileged user, and an +// enforced read-only root bind. +func EvaluateSandbox(r *Report, output string, selfUID int) { + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + line := func(i int) string { + if i < len(lines) { + return strings.TrimSpace(lines[i]) + } + return "" + } + if line(0) == "0" { + r.pass("build runs as root inside the namespace (uid 0)") + } else { + r.fail("expected uid 0 inside the namespace, got: %s", line(0)) + } + mapRe := regexp.MustCompile(`^\s*0\s+` + strconv.Itoa(selfUID) + `\s+1`) + if mapRe.MatchString(line(1)) { + r.pass("uid_map maps root back to the unprivileged user (uid %d)", selfUID) + } else { + r.fail("expected uid_map '0 %d 1', got: %s", selfUID, line(1)) + } + if strings.Contains(strings.ToLower(output), "read-only file system") { + r.pass("read-only root bind is enforced") + } else { + r.fail("the read-only root bind did not reject a write to /usr") + } +} + +func toolChecks(r *Report) { + if _, err := exec.LookPath("tar"); err != nil { + r.fail("tar is not installed — required to unpack images") + } else { + r.pass("tar is available") + } + if _, err := exec.LookPath("zstd"); err != nil { + r.warn("zstd is not installed — .tar.zst images cannot be unpacked") + } +} + +func diskChecks(r *Report, targetDir string, run Runner) { + minGiB := MinFreeKiB / 1024 / 1024 + homeFS := "" + if home, err := os.UserHomeDir(); err == nil { + if out, err := run("df", "-Pk", home); err == nil { + homeFS, _, _ = ParseDF(out) + } + } + out, err := run("df", "-Pk", targetDir) + if err != nil { + r.warn("could not measure free space on %s — only the quota check applies", targetDir) + return + } + device, availKiB, mount := ParseDF(out) + if device == "" { + r.warn("could not measure free space on %s — only the quota check applies", targetDir) + } else { + if homeFS != "" && device != homeFS { + r.warn("target dir is on %s (mounted at %s), not the home filesystem (%s) — builds will run on slower storage", device, mount, homeFS) + } + if availKiB < MinFreeKiB { + r.fail("less than %d GiB free space on the build working filesystem (%s)", minGiB, mount) + } else { + r.pass("at least %d GiB free space on the build working filesystem (%s, device %s)", minGiB, mount, device) + } + } + quotaOut, err := run("quota", "-g") + if err != nil || strings.TrimSpace(quotaOut) == "" { + r.warn("no readable group quota tooling on this host — only free space was checked") + return + } + lines := ParseQuota(quotaOut) + if len(lines) == 0 { + r.warn("quota tooling present but no group quota lines could be parsed — only free space was checked") + return + } + ok := true + detail := "" + for _, q := range lines { + // Only the quota of the target filesystem counts — other + // volumes may legitimately be full without affecting builds. + if device != "" && filepath.Base(q.FS) != filepath.Base(device) && q.FS != device { + continue + } + headroom := q.Limit - q.Blocks + if headroom < MinFreeKiB { + ok = false + detail += fmt.Sprintf(" %s: %.1f GiB free of quota;", filepath.Base(q.FS), float64(headroom)/1024/1024) + } + } + if ok { + r.pass("group quota headroom covers the %d GiB build footprint", minGiB) + } else { + r.fail("group quota headroom below the %d GiB build footprint; raise the quota before building.%s", minGiB, detail) + } +} + +// ParseDF extracts device, available KiB, and mount point from +// `df -Pk DIR` output. +func ParseDF(output string) (device string, availKiB int64, mount string) { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 2 { + return "", 0, "" + } + fields := strings.Fields(lines[1]) + if len(fields) < 6 { + return "", 0, "" + } + avail, err := strconv.ParseInt(fields[3], 10, 64) + if err != nil { + return "", 0, "" + } + return fields[0], avail, fields[5] +} + +// QuotaLine is one filesystem's group quota: used blocks and the hard +// limit, both in KiB. +type QuotaLine struct { + FS string + Blocks int64 + Limit int64 +} + +// ParseQuota parses `quota -g` output, including the wrapped form where +// a long device name stands alone on its own line and the numbers +// follow on the next. A '*' suffix on the blocks value (over soft +// quota) is ignored. +func ParseQuota(output string) []QuotaLine { + var result []QuotaLine + pendingFS := "" + for _, raw := range strings.Split(output, "\n") { + fields := strings.Fields(raw) + if len(fields) == 0 { + continue + } + if len(fields) == 1 && strings.HasPrefix(fields[0], "/") { + pendingFS = fields[0] + continue + } + if strings.HasPrefix(fields[0], "/") && len(fields) >= 4 { + if blocks, limit, ok := quotaNumbers(fields[1], fields[3]); ok { + result = append(result, QuotaLine{FS: fields[0], Blocks: blocks, Limit: limit}) + pendingFS = "" + } + continue + } + if pendingFS != "" && len(fields) >= 3 { + if blocks, limit, ok := quotaNumbers(fields[0], fields[2]); ok { + result = append(result, QuotaLine{FS: pendingFS, Blocks: blocks, Limit: limit}) + pendingFS = "" + } + } + } + return result +} + +func quotaNumbers(blocksField, limitField string) (int64, int64, bool) { + blocks, err := strconv.ParseInt(strings.TrimSuffix(blocksField, "*"), 10, 64) + if err != nil { + return 0, 0, false + } + limit, err := strconv.ParseInt(limitField, 10, 64) + if err != nil { + return 0, 0, false + } + return blocks, limit, true +} + +// Render writes the report in the PASS/FAIL format of the original +// prerequisites script, ending with a RESULT line. +func (r *Report) Render(w io.Writer) { + for _, c := range r.Checks { + status := "PASS" + if !c.OK { + status = "FAIL" + } + fmt.Fprintf(w, "%s: %s\n", status, c.Msg) + } + for _, warning := range r.Warnings { + fmt.Fprintf(w, "WARNING: %s\n", warning) + } + passed := 0 + for _, c := range r.Checks { + if c.OK { + passed++ + } + } + fmt.Fprintln(w) + if r.OK() { + fmt.Fprintf(w, "RESULT: PASS (%d/%d) — werkdock sandboxes are usable on this host.\n", passed, len(r.Checks)) + } else { + fmt.Fprintf(w, "RESULT: FAIL (%d/%d) — werkdock sandboxes are not usable on this host.\n", passed, len(r.Checks)) + } +} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go new file mode 100644 index 0000000..0524467 --- /dev/null +++ b/internal/doctor/doctor_test.go @@ -0,0 +1,161 @@ +package doctor + +import ( + "reflect" + "strings" + "testing" +) + +func TestEvaluateSandboxAllSignalsPass(t *testing.T) { + r := &Report{} + output := "0\n 0 120957 1\ntouch: cannot touch '/usr/ro-test': Read-only file system\n" + EvaluateSandbox(r, output, 120957) + if !r.OK() { + t.Errorf("expected all signals to pass, got %+v", r.Checks) + } + if len(r.Checks) != 3 { + t.Errorf("expected 3 checks, got %d", len(r.Checks)) + } +} + +func TestEvaluateSandboxFailures(t *testing.T) { + tests := []struct { + name string + output string + selfUID int + wantFail string + }{ + { + "not root inside", + "1000\n 0 120957 1\nRead-only file system\n", + 120957, + "expected uid 0", + }, + { + "uid_map maps someone else", + "0\n 0 999999 1\nRead-only file system\n", + 120957, + "expected uid_map", + }, + { + "writable root bind", + "0\n 0 120957 1\n", + 120957, + "did not reject a write", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Report{} + EvaluateSandbox(r, tt.output, tt.selfUID) + found := false + for _, c := range r.Checks { + if !c.OK && strings.Contains(c.Msg, tt.wantFail) { + found = true + } + } + if !found { + t.Errorf("expected a failing check containing %q, got %+v", tt.wantFail, r.Checks) + } + }) + } +} + +func TestParseDF(t *testing.T) { + output := "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/mapper/vg0-home 959786032 447013936 463941300 50% /home\n" + device, avail, mount := ParseDF(output) + if device != "/dev/mapper/vg0-home" || avail != 463941300 || mount != "/home" { + t.Errorf("got %q %d %q", device, avail, mount) + } + if d, a, m := ParseDF("garbage"); d != "" || a != 0 || m != "" { + t.Errorf("expected empty result for garbage, got %q %d %q", d, a, m) + } +} + +func TestParseQuotaPlainAndWrappedLines(t *testing.T) { + output := `Disk quotas for group g123456 (gid 123456): + Filesystem blocks quota limit grace files quota limit grace +/dev/vdb1 123456 900000 1000000 1234 0 0 +/dev/mapper/very-long-device-name-that-wraps + 654321* 4500000 5000000 4321 0 0 +` + want := []QuotaLine{ + {FS: "/dev/vdb1", Blocks: 123456, Limit: 1000000}, + {FS: "/dev/mapper/very-long-device-name-that-wraps", Blocks: 654321, Limit: 5000000}, + } + if got := ParseQuota(output); !reflect.DeepEqual(got, want) { + t.Errorf("got %+v\nwant %+v", got, want) + } +} + +func TestParseQuotaIgnoresUnparsableOutput(t *testing.T) { + if got := ParseQuota("no quotas here\n"); len(got) != 0 { + t.Errorf("expected no lines, got %+v", got) + } +} + +// fakeRunner serves canned outputs keyed by command name. +func fakeRunner(outputs map[string]string) Runner { + return func(name string, args ...string) (string, error) { + return outputs[name], nil + } +} + +func TestDiskChecksFailOnQuotaHeadroomOfTheTargetFilesystem(t *testing.T) { + r := &Report{} + // 1 GiB quota headroom on the home device, plenty on another one. + outputs := map[string]string{ + "df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/vdb1 100000000 10000000 90000000 10% /home\n", + "quota": "Disk quotas for group g1 (gid 1):\n" + + " Filesystem blocks quota limit grace\n" + + "/dev/vdb1 4000000 5000000 5048576 - - -\n" + + "/dev/other 0 0 99999999 - - -\n", + } + diskChecks(r, "/home/user", fakeRunner(outputs)) + if r.OK() { + t.Fatalf("expected the quota check to fail, got %+v", r.Checks) + } + failing := "" + for _, c := range r.Checks { + if !c.OK { + failing = c.Msg + } + } + if !strings.Contains(failing, "quota headroom below") || !strings.Contains(failing, "vdb1") { + t.Errorf("unexpected failure message: %s", failing) + } +} + +func TestDiskChecksPassWithSpaceAndQuota(t *testing.T) { + r := &Report{} + outputs := map[string]string{ + "df": "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + + "/dev/vdb1 100000000 10000000 90000000 10% /home\n", + "quota": "Disk quotas for group g1 (gid 1):\n" + + " Filesystem blocks quota limit grace\n" + + "/dev/vdb1 1000000 90000000 99000000 - - -\n", + } + diskChecks(r, "/home/user", fakeRunner(outputs)) + if !r.OK() { + t.Errorf("expected disk checks to pass, got %+v", r.Checks) + } + if len(r.Checks) != 2 { + t.Errorf("expected free-space and quota checks, got %+v", r.Checks) + } +} + +func TestRenderEndsWithTheResultLine(t *testing.T) { + r := &Report{} + r.pass("all good") + r.warn("just saying") + var out strings.Builder + r.Render(&out) + rendered := out.String() + if !strings.Contains(rendered, "PASS: all good\n") || + !strings.Contains(rendered, "WARNING: just saying\n") || + !strings.Contains(rendered, "RESULT: PASS (1/1)") { + t.Errorf("unexpected rendering:\n%s", rendered) + } +} diff --git a/internal/engine/bwrap.go b/internal/engine/bwrap.go new file mode 100644 index 0000000..2f278f5 --- /dev/null +++ b/internal/engine/bwrap.go @@ -0,0 +1,199 @@ +package engine + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation +// only; network, uid mapping target, /proc, /dev, and /tmp come from +// the host by contract. +// +// The invocation is a port of Werkator's BwrapBuildRunner, including +// the parts hardened on a real Hostsharing webspace: bind mountpoints +// are pre-created inside the rootfs (a plain host directory), because +// bwrap cannot mkdir them against the read-only root bind. +type Bwrap struct { + // Path of the bwrap binary; empty means "bwrap" via PATH. + Path string + // Stdio of the sandboxed command; nil fields default to the + // werkdock process's own. + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader +} + +// DefaultPATH is the PATH inside the sandbox; the environment is +// cleared (docker semantics), so a sane default must be set explicitly. +const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +// Argv assembles the full bwrap command line for spec. +// +// Mount order: the rootfs first; then /proc, /dev, and the tmpfs +// mounts for /tmp and /root, BEFORE the user binds, so a bind whose +// destination lies below them lands inside instead of being shadowed; +// then the user binds in the given order. +func (b *Bwrap) Argv(spec RunSpec) ([]string, error) { + if spec.RootFS == "" { + return nil, errors.New("rootfs must be set") + } + if !filepath.IsAbs(spec.RootFS) { + return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS) + } + if len(spec.Command) == 0 { + return nil, errors.New("no command specified") + } + bin := b.Path + if bin == "" { + bin = "bwrap" + } + args := []string{ + bin, + "--unshare-user", + "--unshare-pid", + "--die-with-parent", + "--uid", "0", + "--gid", "0", + "--ro-bind", spec.RootFS, "/", + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + "--tmpfs", "/root", + } + for _, bd := range spec.Binds { + if !filepath.IsAbs(bd.Dest) { + return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest) + } + flag := "--bind" + if bd.ReadOnly { + flag = "--ro-bind" + } + args = append(args, flag, bd.Source, bd.Dest) + } + args = append(args, + "--clearenv", + "--setenv", "HOME", "/root", + "--setenv", "PATH", DefaultPATH, + ) + for _, e := range spec.Env { + args = append(args, "--setenv", e.Key, e.Value) + } + workdir := spec.Workdir + if workdir == "" { + workdir = "/" + } + args = append(args, "--chdir", workdir, "--") + args = append(args, spec.Command...) + return args, nil +} + +// EnsureMountpoints pre-creates the mountpoints of spec inside the +// rootfs directory. bwrap creates mountpoints against the sandbox view, +// which is the read-only rootfs bind — every destination missing from +// the rootfs fails with "Read-only file system". The rootfs directory +// itself is a plain host directory, so the mountpoints are created +// there; bwrap then finds them and has nothing left to mkdir. +// +// Anything that already exists in the rootfs is left alone (e.g. +// /etc/resolv.conf is a file many rootfs archives ship). A bind whose +// source is a regular file gets a file mountpoint, not a directory. +func EnsureMountpoints(spec RunSpec) error { + for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} { + if err := ensureDir(spec.RootFS, dest); err != nil { + return err + } + } + for _, bd := range spec.Binds { + target, err := rootfsPath(spec.RootFS, bd.Dest) + if err != nil { + return err + } + if _, err := os.Lstat(target); err == nil { + continue + } + src, err := os.Stat(bd.Source) + if err != nil { + return fmt.Errorf("bind source %s: %w", bd.Source, err) + } + if src.Mode().IsRegular() { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644) + if err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + continue + } + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + } + return nil +} + +func ensureDir(rootfs, dest string) error { + target, err := rootfsPath(rootfs, dest) + if err != nil { + return err + } + if _, statErr := os.Lstat(target); statErr == nil { + return nil + } + return os.MkdirAll(target, 0o755) +} + +// rootfsPath resolves dest inside rootfs and refuses destinations that +// escape it — werkdock assembles mounts from user input, so this must +// hold even for hostile paths. +func rootfsPath(rootfs, dest string) (string, error) { + root := filepath.Clean(rootfs) + target := filepath.Join(root, dest) + prefix := root + if !strings.HasSuffix(prefix, string(filepath.Separator)) { + prefix += string(filepath.Separator) + } + if target != root && !strings.HasPrefix(target, prefix) { + return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest) + } + return target, nil +} + +// Run executes spec and returns the command's exit code; bwrap +// propagates the child's code, so the caller can pass it through. +func (b *Bwrap) Run(spec RunSpec) (int, error) { + argv, err := b.Argv(spec) + if err != nil { + return 0, err + } + if err := EnsureMountpoints(spec); err != nil { + return 0, err + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = b.Stdout + if cmd.Stdout == nil { + cmd.Stdout = os.Stdout + } + cmd.Stderr = b.Stderr + if cmd.Stderr == nil { + cmd.Stderr = os.Stderr + } + cmd.Stdin = b.Stdin + err = cmd.Run() + if err == nil { + return 0, nil + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), nil + } + return 0, err +} diff --git a/internal/engine/bwrap_test.go b/internal/engine/bwrap_test.go new file mode 100644 index 0000000..0a18d91 --- /dev/null +++ b/internal/engine/bwrap_test.go @@ -0,0 +1,187 @@ +package engine + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestArgvAssemblesTheHardenedInvocation(t *testing.T) { + b := &Bwrap{} + spec := RunSpec{ + RootFS: "/store/images/buildenv/rootfs", + Binds: []Bind{ + {Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true}, + {Source: "/repo", Dest: "/repo"}, + {Source: "/cache", Dest: "/root/.gradle"}, + }, + Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}}, + Workdir: "/repo", + Command: []string{"/bin/sh", "-c", "./gradlew build"}, + } + argv, err := b.Argv(spec) + if err != nil { + t.Fatal(err) + } + want := []string{ + "bwrap", + "--unshare-user", "--unshare-pid", "--die-with-parent", + "--uid", "0", "--gid", "0", + "--ro-bind", "/store/images/buildenv/rootfs", "/", + "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root", + "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", + "--bind", "/repo", "/repo", + "--bind", "/cache", "/root/.gradle", + "--clearenv", + "--setenv", "HOME", "/root", + "--setenv", "PATH", DefaultPATH, + "--setenv", "CI", "true", + "--setenv", "TERM", "dumb", + "--chdir", "/repo", "--", + "/bin/sh", "-c", "./gradlew build", + } + if !reflect.DeepEqual(argv, want) { + t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want) + } +} + +func TestArgvValidation(t *testing.T) { + tests := []struct { + name string + spec RunSpec + wantErr string + }{ + {"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"}, + {"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"}, + {"missing command", RunSpec{RootFS: "/r"}, "no command specified"}, + { + "relative bind dest", + RunSpec{RootFS: "/r", Binds: []Bind{{Source: "/s", Dest: "work"}}, Command: []string{"true"}}, + "absolute", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := (&Bwrap{}).Argv(tt.spec) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got error %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestArgvDefaultsWorkdirToRoot(t *testing.T) { + argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}}) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(argv, " ") + if !strings.Contains(joined, "--chdir / --") { + t.Errorf("expected default workdir /, got: %s", joined) + } +} + +func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) { + rootfs := t.TempDir() + // The rootfs ships /etc/resolv.conf as a file with content — it + // must be left alone. + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil { + t.Fatal(err) + } + shipped := filepath.Join(rootfs, "etc", "resolv.conf") + if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil { + t.Fatal(err) + } + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "hosts") + if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + spec := RunSpec{ + RootFS: rootfs, + Binds: []Bind{ + {Source: "/etc", Dest: "/etc/resolv.conf", ReadOnly: true}, // exists: skipped (source type irrelevant) + {Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint + {Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint + }, + Command: []string{"true"}, + } + if err := EnsureMountpoints(spec); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace"} { + fi, err := os.Stat(filepath.Join(rootfs, dir)) + if err != nil || !fi.IsDir() { + t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err) + } + } + fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock")) + if err != nil || !fi.Mode().IsRegular() { + t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err) + } + content, err := os.ReadFile(shipped) + if err != nil || string(content) != "nameserver 127.0.0.53\n" { + t.Errorf("shipped rootfs file was modified: %q, %v", content, err) + } +} + +func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) { + spec := RunSpec{ + RootFS: t.TempDir(), + Binds: []Bind{{Source: "/tmp", Dest: "/../outside"}}, + Command: []string{"true"}, + } + err := EnsureMountpoints(spec) + if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") { + t.Errorf("got %v, want an escape refusal", err) + } +} + +// TestRunInsideRealSandbox is the gated integration test: it runs only +// where bwrap and unprivileged user namespaces actually work. The host +// / serves as the read-only rootfs, so nothing is unpacked and (all +// mountpoints existing) nothing is written. +func TestRunInsideRealSandbox(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not installed") + } + if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil { + t.Skipf("unprivileged user namespaces not usable here: %v", err) + } + var stdout, stderr bytes.Buffer + b := &Bwrap{Stdout: &stdout, Stderr: &stderr} + code, err := b.Run(RunSpec{ + RootFS: "/", + Command: []string{"id", "-u"}, + }) + if err != nil { + t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String()) + } + if code != 0 { + t.Fatalf("exit code %d, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "0" { + t.Errorf("expected uid 0 inside the sandbox, got %q", got) + } +} + +func TestRunPassesTheExitCodeThrough(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not installed") + } + if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil { + t.Skipf("unprivileged user namespaces not usable here: %v", err) + } + b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}}) + if err != nil { + t.Fatal(err) + } + if code != 42 { + t.Errorf("expected exit code 42, got %d", code) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..5763509 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,37 @@ +// Package engine executes sandboxed commands. The CLI verbs are thin +// frontends over this package, so a later daemon can expose the same +// logic without duplicating it (RFC 0002). +package engine + +// Bind is one bind mount, applied in order; later mounts shadow earlier +// ones at their own path, exactly as bwrap layers them. +type Bind struct { + Source string + Dest string + ReadOnly bool +} + +// EnvVar is one environment variable; order is preserved. +type EnvVar struct { + Key string + Value string +} + +// RunSpec describes one sandboxed command, independent of the engine +// that executes it. +type RunSpec struct { + // RootFS is the absolute path to the unpacked image rootfs, + // bound read-only at /. + RootFS string + Binds []Bind + Env []EnvVar + Workdir string + Command []string +} + +// Engine runs a RunSpec and reports the command's exit code. +// bwrap is the first engine; native namespaces may become a second +// (RFC 0001). +type Engine interface { + Run(spec RunSpec) (int, error) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..871afa2 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,122 @@ +// Package store is the on-disk image store. An image is a rootfs +// archive unpacked under the store root; instance state will live here +// too once persistent instances exist, in a format both the CLI and a +// later daemon can read (RFC 0002). +package store + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Store is rooted at $WERKDOCK_HOME, defaulting to ~/.werkdock. +type Store struct { + Root string +} + +// ImageMeta is written as image.json beside each image's rootfs. +type ImageMeta struct { + Name string `json:"name"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` +} + +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// Default resolves the store root from the environment. +func Default() (Store, error) { + if root := os.Getenv("WERKDOCK_HOME"); root != "" { + return Store{Root: root}, nil + } + home, err := os.UserHomeDir() + if err != nil { + return Store{}, fmt.Errorf("cannot resolve the store root: %w", err) + } + return Store{Root: filepath.Join(home, ".werkdock")}, nil +} + +func (s Store) imageDir(name string) string { + return filepath.Join(s.Root, "images", name) +} + +// RootFS resolves an image name to its unpacked rootfs directory. +func (s Store) RootFS(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", fmt.Errorf("invalid image name: %q", name) + } + rootfs := filepath.Join(s.imageDir(name), "rootfs") + if fi, err := os.Stat(rootfs); err != nil || !fi.IsDir() { + return "", fmt.Errorf("no such image: %s (load it with: werkdock load -i ARCHIVE --name %s)", name, name) + } + return rootfs, nil +} + +// Load imports a rootfs archive as an image. The archive is unpacked +// with the tar CLI (compression auto-detected; .tar.zst needs the zstd +// binary, which doctor checks) into a temporary directory and renamed +// into place, so a failed load leaves no half image behind. +func (s Store) Load(archive, name string) error { + if !nameRe.MatchString(name) { + return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name) + } + archiveAbs, err := filepath.Abs(archive) + if err != nil { + return err + } + if _, err := os.Stat(archiveAbs); err != nil { + return fmt.Errorf("archive: %w", err) + } + dir := s.imageDir(name) + if _, err := os.Stat(dir); err == nil { + return fmt.Errorf("image %q already exists (remove %s to replace it)", name, dir) + } + tmp := dir + ".tmp" + if err := os.RemoveAll(tmp); err != nil { + return err + } + rootfs := filepath.Join(tmp, "rootfs") + if err := os.MkdirAll(rootfs, 0o755); err != nil { + return err + } + cmd := exec.Command("tar", "--no-same-owner", "-xf", archiveAbs, "-C", rootfs) + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.RemoveAll(tmp) + return fmt.Errorf("unpacking %s failed: %w\n%s", archiveAbs, err, strings.TrimSpace(string(out))) + } + meta, err := json.MarshalIndent(ImageMeta{Name: name, Source: archiveAbs, CreatedAt: time.Now().UTC()}, "", " ") + if err != nil { + _ = os.RemoveAll(tmp) + return err + } + if err := os.WriteFile(filepath.Join(tmp, "image.json"), append(meta, '\n'), 0o644); err != nil { + _ = os.RemoveAll(tmp) + return err + } + if err := os.Rename(tmp, dir); err != nil { + _ = os.RemoveAll(tmp) + return err + } + return nil +} + +// ImageNameFromArchive derives a default image name from an archive +// file name by stripping the compression and tar extensions: +// "werkator-buildenv-trixie.tar.zst" becomes "werkator-buildenv-trixie". +func ImageNameFromArchive(archive string) string { + name := filepath.Base(archive) + for { + ext := filepath.Ext(name) + switch strings.ToLower(ext) { + case ".tar", ".gz", ".tgz", ".zst", ".xz", ".bz2": + name = strings.TrimSuffix(name, ext) + default: + return strings.ToLower(name) + } + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..4ff871e --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,130 @@ +package store + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so +// the tests need no zstd; Load unpacks it with the system tar. +func writeTestArchive(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { + t.Fatal(err) + } + content := []byte("hello from the rootfs\n") + if err := tw.WriteHeader(&tar.Header{Name: "etc/hello", Mode: 0o644, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + for _, c := range []interface{ Close() error }{tw, gz, f} { + if err := c.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestLoadUnpacksArchiveIntoTheStore(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz") + writeTestArchive(t, archive) + if err := st.Load(archive, "mini"); err != nil { + t.Fatal(err) + } + rootfs, err := st.RootFS("mini") + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello")) + if err != nil || string(content) != "hello from the rootfs\n" { + t.Errorf("unpacked file: %q, %v", content, err) + } + metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "image.json")) + if err != nil { + t.Fatal(err) + } + var meta ImageMeta + if err := json.Unmarshal(metaRaw, &meta); err != nil { + t.Fatal(err) + } + if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() { + t.Errorf("image.json incomplete: %+v", meta) + } +} + +func TestLoadRefusesAnExistingImageName(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + archive := filepath.Join(t.TempDir(), "mini.tar.gz") + writeTestArchive(t, archive) + if err := st.Load(archive, "mini"); err != nil { + t.Fatal(err) + } + err := st.Load(archive, "mini") + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("got %v, want an already-exists refusal", err) + } +} + +func TestLoadLeavesNoHalfImageOnFailure(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + st := Store{Root: t.TempDir()} + broken := filepath.Join(t.TempDir(), "broken.tar.gz") + if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil { + t.Fatal(err) + } + if err := st.Load(broken, "broken"); err == nil { + t.Fatal("expected the load to fail") + } + if _, err := os.Stat(filepath.Join(st.Root, "images", "broken")); !os.IsNotExist(err) { + t.Errorf("expected no image directory, got %v", err) + } + if _, err := os.Stat(filepath.Join(st.Root, "images", "broken.tmp")); !os.IsNotExist(err) { + t.Errorf("expected no leftover tmp directory, got %v", err) + } +} + +func TestRootFSValidation(t *testing.T) { + st := Store{Root: t.TempDir()} + if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") { + t.Errorf("got %v, want a no-such-image error", err) + } + if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image name") { + t.Errorf("got %v, want an invalid-name error", err) + } +} + +func TestImageNameFromArchive(t *testing.T) { + tests := []struct{ in, want string }{ + {"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"}, + {"/path/to/Base.TAR.GZ", "base"}, + {"rootfs.tgz", "rootfs"}, + {"plain", "plain"}, + } + for _, tt := range tests { + if got := ImageNameFromArchive(tt.in); got != tt.want { + t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9e0d884 --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "werkdock/internal/cli" +) + +func main() { + os.Exit(cli.Main(os.Args[1:])) +}