427 lines
13 KiB
Go
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)
|
|
}
|