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,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