Files
werkdock/internal/instance/instance.go
mhoennigandClaude Opus 5 eee4d5184c
werkator build succeeded after 00:03
A file into a container, without writing into the shared image
PUT /containers/{id}/archive was the last endpoint Testcontainers asks
for that werkdock answered with a 501. It is what withCopyFileToContainer
sends, and with it an image's own init mechanism works: a script copied
to /docker-entrypoint-initdb.d is run by the postgres entrypoint.

Docker unpacks the tar into the container's writable layer. Werkdock has
none, and its rootfs is the image's own directory in the store, shared by
every instance and bound read-only — unpacking there would change the
image for everyone. So the tar goes into the instance directory and is
bound into the sandbox, one of two ways, and the image decides which:
whole where the image has nothing at the destination, file by file where
it has something, so its own files stay visible beside the copied ones.
That is docker's merge semantics, reached by another road.

The real client corrected the design once: Testcontainers sends path=/
and carries the whole path in the tar entry names. An afternoon's
assumption that / should be refused would have refused every copy there
is. The measured call is now written down in docs/testcontainers-api.md,
where the guesses can be checked against it.

Refused rather than ignored: a copy into a running instance (mounts are
fixed at start), a directory onto one the image fills (the bind would
hide it), symlinks and device nodes, entries that climb out of the
destination, and archives above 64 entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:53:24 +02:00

371 lines
11 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, 0o755); 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
}
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
}
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'), 0o644); 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)
}