Files
werkdock/internal/store/store.go
T
mhoennigandClaude Fable 5 e4bfeacf5a 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>
2026-09-01 07:01:21 +02:00

123 lines
3.7 KiB
Go

// 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)
}
}
}