Werkdock skeleton: doctor, load, run over the bwrap engine (Go)

The minimal build-capable CLI decided in RFC 0002's outcome: stdlib-only
Go module, one static binary.

- engine: RunSpec behind the Engine interface (RFC 0001); the Bwrap
  engine ports Werkator's hardened invocation — uid-0 mapping, read-only
  rootfs at /, proc/dev/tmp/root before the user binds so binds below
  them land inside, mountpoint pre-creation in the rootfs including file
  mountpoints, and a guard against binds escaping the rootfs. --clearenv
  gives docker-style clean environments (HOME/PATH set explicitly).
- store: images under $WERKDOCK_HOME (default ~/.werkdock), load
  unpacks via the tar CLI into a tmp dir and renames atomically.
- cli: docker-shaped run flags (-v/-e/-w/--rm); refused docker flags
  (-p, --network, --memory, --cpus, --user, -d) fail loudly with the
  reason; exit codes follow docker (125 CLI errors, child code through).
- doctor: port of werkator-build-prerequisites.sh — userns probe with
  the three signals, tar/zstd, free space and group-quota headroom via
  testable df/quota parsers, same PASS/FAIL output.
- tests: argv golden test, mountpoint and escape tests, flag refusals,
  store round trip, doctor parsers — plus real-sandbox integration
  tests that skip where bwrap or userns are unavailable.

Also records in step 21: RFC 0002 levels 2/3 deferred; next goal is
sandbox builds of Werkator, Werkbaum, and Werkdock itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-01 07:01:21 +02:00
co-authored by Claude Fable 5
parent de79210a7b
commit e4bfeacf5a
17 changed files with 1628 additions and 2 deletions
+2
View File
@@ -0,0 +1,2 @@
/werkdock
/dist/
+16
View File
@@ -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. - `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. - 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. Status: bootstrap.
The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md). 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. Werkdock grows in this subdirectory of the Werkator repository and moves to its own repository once it stands on its own.
+4 -2
View File
@@ -2,7 +2,7 @@
**Status:** **Status:**
- proposed: 2026-09-01 - proposed: 2026-09-01
- accepted: - - accepted: 2026-09-01 (level 1 as the shape of the CLI; levels 2 and 3 deferred indefinitely)
- rejected: - - 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. **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 ## 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.
+3
View File
@@ -0,0 +1,3 @@
module werkdock
go 1.22
+68
View File
@@ -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
}
+57
View File
@@ -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
}
+40
View File
@@ -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
}
+175
View File
@@ -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)
}
+124
View File
@@ -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)
}
}
+292
View File
@@ -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))
}
}
+161
View File
@@ -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)
}
}
+199
View File
@@ -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
}
+187
View File
@@ -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)
}
}
+37
View File
@@ -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)
}
+122
View File
@@ -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)
}
}
}
+130
View File
@@ -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)
}
}
}
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"werkdock/internal/cli"
)
func main() {
os.Exit(cli.Main(os.Args[1:]))
}