Files
claude-code b2266658f3
werkator build succeeded after 00:10
werkator/vulnerability-data build succeeded after 00:06
The sandbox's environment no longer stands on the command line of bwrap (0.13.2) (#17)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-20 18:35:39 +02:00

427 lines
13 KiB
Go

// Package instance is the lifecycle service behind run -d, ps, stop,
// logs, rm, and exec: instance state lives on disk, one directory per
// instance, so the CLI verbs and a later API server (RFC 0005
// milestone 3) read and write the same thing and neither owns it.
//
// Layout: $WERKDOCK_HOME/instances/<id>/ with config.json (what to
// run), state.json (whether and how it ran), stdout and stderr (the
// command's output, for logs), run/ (bound at /run so sockets and pid
// files survive a stop and are visible to exec), and volumes/<path>
// for the image's anonymous VOLUMEs. A `.tmp` suffix marks a directory
// being created or removed.
package instance
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"time"
"werkdock/internal/engine"
)
// ConfigVersion is the format of config.json; the contract between the
// CLI and the API server (RFC 0005).
const ConfigVersion = 1
// Config is what an instance runs, written once at create.
type Config struct {
Version int `json:"version"`
ID string `json:"id"`
Name string `json:"name"`
// Image is the reference the instance was created from, in short form.
Image string `json:"image"`
Created time.Time `json:"created"`
Spec engine.RunSpec `json:"spec"`
// Volumes are the image's anonymous VOLUME paths, bound from
// volumes/<path> below the instance directory (Spec.Mounts carries
// the binds; this lists them for inspect).
Volumes []string `json:"volumes,omitempty"`
// AutoRemove is --rm: the instance directory goes when the command exits.
AutoRemove bool `json:"autoRemove,omitempty"`
// Labels are docker's container labels; the API server's clients
// (Testcontainers) set them at create and filter on them later.
Labels map[string]string `json:"labels,omitempty"`
// Ports is what the service listens on. Werkdock has no network
// namespace, so ContainerPort and HostPort are the same number
// unless the port was moved by the environment variable the image
// honours; either way this is what an API client is told
// (RFC 0005, Ports).
Ports []PortMapping `json:"ports,omitempty"`
}
// PortMapping is one published port.
type PortMapping struct {
ContainerPort int `json:"containerPort"`
HostPort int `json:"hostPort"`
Protocol string `json:"protocol"`
}
// Status is the lifecycle state, docker's words.
type Status string
const (
StatusCreated Status = "created"
StatusRunning Status = "running"
StatusExited Status = "exited"
)
// State is state.json, rewritten by the supervisor at every transition.
type State struct {
Status Status `json:"status"`
// PID is the supervisor process; ChildPID the sandboxed command as
// the host sees it, the target of stop and kill.
PID int `json:"pid,omitempty"`
ChildPID int `json:"childPid,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
ExitCode int `json:"exitCode"`
// Error is set when the command could not be started, or when the
// supervisor vanished without recording an exit.
Error string `json:"error,omitempty"`
}
// ExitCodeLost is recorded when the supervisor died without writing an
// exit code (a reboot, a kill -9): docker's 255 for the same case.
const ExitCodeLost = 255
// Instance is one instance directory, loaded.
type Instance struct {
Dir string
Config Config
State State
}
// Manager is the instance store below the werkdock home.
type Manager struct {
// Root is $WERKDOCK_HOME; instances live in Root/instances.
Root string
}
func (m Manager) instancesDir() string { return filepath.Join(m.Root, "instances") }
// ShortID is the 12-character form ps prints.
func (i *Instance) ShortID() string { return i.Config.ID[:12] }
// Running reports whether the command is running (as far as the state
// file knows; Load reconciles it with the supervisor's liveness).
func (i *Instance) Running() bool { return i.State.Status == StatusRunning }
func (i *Instance) StdoutPath() string { return filepath.Join(i.Dir, "stdout") }
func (i *Instance) StderrPath() string { return filepath.Join(i.Dir, "stderr") }
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)
// Create writes a new instance directory for cfg and returns it in
// state created. ID and Created are assigned here; Name defaults to
// the image's repository basename plus the ID's first six characters.
// The anonymous volumes and /run are bound from the instance
// directory, in front of cfg.Spec.Mounts (an explicit mount on the
// same path already excluded them, see the CLI).
func (m Manager) Create(cfg Config) (*Instance, error) {
if cfg.Name != "" && !namePattern.MatchString(cfg.Name) {
return nil, fmt.Errorf("invalid instance name %q: [a-zA-Z0-9][a-zA-Z0-9_.-]* is allowed", cfg.Name)
}
if cfg.Name != "" {
if existing, err := m.Get(cfg.Name); err == nil {
return nil, fmt.Errorf("the name %q is already in use by instance %s (werkdock rm %s to reuse it)", cfg.Name, existing.ShortID(), cfg.Name)
}
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
cfg.Version = ConfigVersion
cfg.ID = hex.EncodeToString(raw)
cfg.Created = time.Now()
if cfg.Name == "" {
cfg.Name = defaultName(cfg.Image, cfg.ID)
}
dir := filepath.Join(m.instancesDir(), cfg.ID)
tmp := dir + ".tmp"
if err := os.MkdirAll(tmp, 0o700); err != nil {
return nil, err
}
var binds []engine.Mount
runDir := filepath.Join(tmp, "run")
if err := os.Mkdir(runDir, 0o755); err != nil {
return nil, err
}
if err := seedRun(cfg.Spec.RootFS, runDir); err != nil {
return nil, err
}
binds = append(binds, engine.Mount{Mode: engine.MountBind, Source: filepath.Join(dir, "run"), Dest: "/run"})
sort.Strings(cfg.Volumes)
for _, v := range cfg.Volumes {
rel := filepath.Join("volumes", filepath.FromSlash(strings.TrimPrefix(filepath.Clean(v), "/")))
if err := os.MkdirAll(filepath.Join(tmp, rel), 0o755); err != nil {
return nil, err
}
binds = append(binds, engine.Mount{Mode: engine.MountBind, Source: filepath.Join(dir, rel), Dest: filepath.Clean(v)})
}
cfg.Spec.Mounts = append(binds, cfg.Spec.Mounts...)
inst := &Instance{Dir: tmp, Config: cfg, State: State{Status: StatusCreated}}
if err := writeJSON(filepath.Join(tmp, "config.json"), cfg); err != nil {
return nil, err
}
if err := inst.SaveState(); err != nil {
return nil, err
}
if err := os.Rename(tmp, dir); err != nil {
return nil, err
}
inst.Dir = dir
return inst, nil
}
// seedRun gives the instance's /run what the image puts there.
//
// /run is the instance's own directory, not the image's, so that
// sockets and pid files survive a stop and are visible to exec. That
// makes it empty at the start, and an image whose server expects to
// find its directory already made — mariadb wants /run/mysqld and says
// "Bind on unix socket: No such file or directory" without it — would
// fail for a reason that has nothing to do with the image. Docker's
// /run is empty too, but the image's own layer shows through it; here
// nothing shows through, so what the image has is copied in.
//
// Directories and small regular files (/run/systemd/container holds a
// word), nothing else: a socket or device node in an image's /run would
// be stale anyway.
func seedRun(rootfs, runDir string) error {
if rootfs == "" {
return nil
}
source := filepath.Join(rootfs, "run")
if info, err := os.Stat(source); err != nil || !info.IsDir() {
return nil
}
return filepath.WalkDir(source, func(p string, d os.DirEntry, err error) error {
if err != nil || p == source {
return err
}
rel, err := filepath.Rel(source, p)
if err != nil {
return err
}
target := filepath.Join(runDir, rel)
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
return os.MkdirAll(target, info.Mode().Perm())
case info.Mode().IsRegular() && info.Size() <= seedFileLimit:
data, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, data, info.Mode().Perm())
}
return nil
})
}
// seedFileLimit is what counts as "a word in a file" rather than data
// somebody meant to keep.
const seedFileLimit = 64 * 1024
func defaultName(image, id string) string {
repo := image
if i := strings.LastIndex(repo, ":"); i > strings.LastIndex(repo, "/") {
repo = repo[:i]
}
if i := strings.LastIndex(repo, "/"); i >= 0 {
repo = repo[i+1:]
}
repo = strings.TrimLeft(repo, "_.-")
if repo == "" {
repo = "instance"
}
return repo + "-" + id[:6]
}
// SaveConfig rewrites config.json atomically. Only one thing changes
// an instance's configuration after create: a file copied into it
// before it starts (docker's PUT /archive), which adds a mount.
func (i *Instance) SaveConfig() error {
return writeJSON(filepath.Join(i.Dir, "config.json"), i.Config)
}
// SaveState rewrites state.json atomically.
func (i *Instance) SaveState() error {
return writeJSON(filepath.Join(i.Dir, "state.json"), i.State)
}
// Reload re-reads state.json.
func (i *Instance) Reload() error {
return readJSON(filepath.Join(i.Dir, "state.json"), &i.State)
}
func writeJSON(path string, v interface{}) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func readJSON(path string, v interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// ErrNotFound is returned by Get for an unknown id, prefix, or name.
var ErrNotFound = errors.New("no such instance")
// load reads one instance directory and reconciles its state: a
// "running" instance whose supervisor is gone is recorded as exited
// with ExitCodeLost, and an exited --rm instance is removed on sight
// (the supervisor normally does both; this covers its death).
func (m Manager) load(dir string) (*Instance, error) {
inst := &Instance{Dir: dir}
if err := readJSON(filepath.Join(dir, "config.json"), &inst.Config); err != nil {
return nil, err
}
if err := inst.Reload(); err != nil {
return nil, err
}
if inst.Running() && !processAlive(inst.State.PID) {
inst.State.Status = StatusExited
inst.State.ExitCode = ExitCodeLost
inst.State.FinishedAt = time.Now()
inst.State.Error = "the supervisor process exited without recording the exit code"
if err := inst.SaveState(); err != nil {
return nil, err
}
}
if inst.Config.AutoRemove && inst.State.Status == StatusExited {
if err := m.remove(inst); err != nil {
return nil, err
}
return nil, fmt.Errorf("%w: %s", ErrNotFound, inst.ShortID())
}
return inst, nil
}
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// List returns every instance, newest first.
func (m Manager) List() ([]*Instance, error) {
entries, err := os.ReadDir(m.instancesDir())
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var all []*Instance
for _, e := range entries {
if !e.IsDir() || strings.HasSuffix(e.Name(), ".tmp") {
continue
}
inst, err := m.load(filepath.Join(m.instancesDir(), e.Name()))
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return nil, fmt.Errorf("instance %s: %w", e.Name(), err)
}
all = append(all, inst)
}
sort.Slice(all, func(a, b int) bool { return all[a].Config.Created.After(all[b].Config.Created) })
return all, nil
}
// HasLabels reports whether the instance carries every label in want
// (docker's label filter: all of them must match).
func (i *Instance) HasLabels(want map[string]string) bool {
for k, v := range want {
got, ok := i.Config.Labels[k]
if !ok || (v != "" && got != v) {
return false
}
}
return true
}
// Get resolves key the docker way: a full id, a name, or a unique id
// prefix.
func (m Manager) Get(key string) (*Instance, error) {
if key == "" {
return nil, fmt.Errorf("%w: empty name", ErrNotFound)
}
all, err := m.List()
if err != nil {
return nil, err
}
for _, inst := range all {
if inst.Config.ID == key || inst.Config.Name == key {
return inst, nil
}
}
var matches []*Instance
for _, inst := range all {
if strings.HasPrefix(inst.Config.ID, key) {
matches = append(matches, inst)
}
}
switch len(matches) {
case 0:
return nil, fmt.Errorf("%w: %s", ErrNotFound, key)
case 1:
return matches[0], nil
}
return nil, fmt.Errorf("instance id prefix %q is ambiguous (%d matches)", key, len(matches))
}
// Remove deletes the instance directory. A running instance is refused
// unless force, which kills it first (docker rm -f).
func (m Manager) Remove(inst *Instance, force bool) error {
if inst.Running() {
if !force {
return fmt.Errorf("instance %s is running: stop it first, or rm -f", inst.Config.Name)
}
if err := m.Kill(inst, syscall.SIGKILL); err != nil {
return err
}
if _, err := m.Wait(inst, 10*time.Second); err != nil {
return err
}
}
return m.remove(inst)
}
func (m Manager) remove(inst *Instance) error {
tmp := inst.Dir + ".rm.tmp"
if err := os.Rename(inst.Dir, tmp); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return os.RemoveAll(tmp)
}