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