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:
co-authored by
Claude Fable 5
parent
de79210a7b
commit
e4bfeacf5a
@@ -0,0 +1,2 @@
|
|||||||
|
/werkdock
|
||||||
|
/dist/
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user