Files
6d6060bb07 RFC 0005 milestone 2: instance lifecycle — run -d, ps, stop, logs, rm, exec (#4)
An instance is a directory under instances/<id>/ (config.json, state.json,
stdout, stderr, run/, volumes/) written by package instance, the service
behind the verbs. run -d and start fork this binary as a supervisor
(werkdock supervise ID) that records the pids and the exit code; stop,
kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap
over the same instance tree. The command is pid 1 of its sandbox
(bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and
the anonymous volumes are instance directories, kept across stop/start
and shared with exec. Version 0.5.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #4
2026-09-10 20:55:59 +02:00

157 lines
5.9 KiB
Go

package cli
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"werkdock/internal/instance"
"werkdock/internal/store"
)
func TestParseSignal(t *testing.T) {
for _, s := range []string{"KILL", "SIGKILL", "kill", "9"} {
if sig, err := parseSignal(s); err != nil || int(sig) != 9 {
t.Errorf("%q: got %v, %v", s, sig, err)
}
}
if _, err := parseSignal("BOOM"); err == nil {
t.Error("BOOM accepted")
}
}
func TestPsRowFormatsLikeDocker(t *testing.T) {
inst := &instance.Instance{
Config: instance.Config{ID: strings.Repeat("ab", 32), Name: "pg", Image: "postgres:16-alpine", Created: time.Now().Add(-90 * time.Second)},
State: instance.State{Status: instance.StatusRunning, StartedAt: time.Now().Add(-30 * time.Second)},
}
inst.Config.Spec.Command = []string{"docker-entrypoint.sh", "postgres"}
row := psRow{inst: inst}
if row.ID() != "abababababab" || row.Names() != "pg" || row.RunningFor() != "About a minute ago" || row.Status() != "Up 30 seconds" {
t.Errorf("row: %q %q %q %q", row.ID(), row.Names(), row.RunningFor(), row.Status())
}
if row.Command() != `"docker-entrypoint.s…"` {
t.Errorf("command: %q", row.Command())
}
inst.State = instance.State{Status: instance.StatusExited, ExitCode: 3, FinishedAt: time.Now()}
if got := row.Status(); got != "Exited (3) 0 seconds ago" {
t.Errorf("exited status: %q", got)
}
var out bytes.Buffer
if err := printFormatted(&out, "table", psColumns, []interface{}{row}); err != nil {
t.Fatal(err)
}
if lines := strings.Split(strings.TrimSpace(out.String()), "\n"); len(lines) != 2 || !strings.HasPrefix(lines[0], "CONTAINER ID IMAGE ") || !strings.HasPrefix(lines[1], "abababababab postgres:16-alpine") {
t.Errorf("table:\n%s", out.String())
}
}
// The whole lifecycle through Main, with a real bwrap over / as the
// image: run -d, ps, logs, exec, stop, start, rm.
func TestLifecycleVerbsInsideRealSandbox(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
home := t.TempDir()
t.Setenv("WERKDOCK_HOME", home)
// an image whose rootfs is empty but for the mountpoints; the
// host's toolchain directories are bound in read-only per run
dir := filepath.Join(home, "images", "host", "latest")
for _, d := range []string{"rootfs/etc", "rootfs/mnt"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
var hostBinds []string
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64", "/usr"} {
if _, err := os.Stat(d); err == nil {
hostBinds = append(hostBinds, "-v", d+":"+d+":ro")
}
}
if err := os.WriteFile(filepath.Join(dir, "image.json"), []byte(`{"name":"host:latest","config":{"Volumes":{"/mnt":{}}}}`), 0o644); err != nil {
t.Fatal(err)
}
st := store.Store{Root: home}
if _, err := st.RootFS("host"); err != nil {
t.Fatal(err)
}
// the supervisor is this binary: build it once
bin := filepath.Join(t.TempDir(), "werkdock")
if out, err := exec.Command("go", "build", "-o", bin, "werkdock").CombinedOutput(); err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
run := func(args ...string) (string, int) {
cmd := exec.Command(bin, args...)
cmd.Env = append(os.Environ(), "WERKDOCK_HOME="+home)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
code := 0
if exit, ok := err.(*exec.ExitError); ok {
code = exit.ExitCode()
} else if err != nil {
t.Fatalf("%v: %v", args, err)
}
return out.String(), code
}
out, code := run(append(append([]string{"run", "-d", "--name", "svc"}, hostBinds...), "host", "sh", "-c", `trap 'echo bye; exit 0' TERM; echo hello > /mnt/marker; echo up; while :; do sleep 0.1; done`)...)
if code != 0 || len(strings.TrimSpace(out)) != 64 {
t.Fatalf("run -d: %d %q", code, out)
}
deadline := time.Now().Add(5 * time.Second)
for {
if out, _ = run("logs", "svc"); strings.Contains(out, "up") {
break
}
if time.Now().After(deadline) {
t.Fatalf("service never printed: %q", out)
}
time.Sleep(100 * time.Millisecond)
}
if out, _ = run("ps", "--format", "{{.Names}} {{.State}}"); strings.TrimSpace(out) != "svc running" {
t.Errorf("ps: %q", out)
}
// exec shares the anonymous volume, not the pid namespace
if out, code = run("exec", "svc", "cat", "/mnt/marker"); code != 0 || strings.TrimSpace(out) != "hello" {
t.Errorf("exec: %d %q", code, out)
}
if out, code = run("stop", "-t", "5", "svc"); code != 0 || strings.TrimSpace(out) != "svc" {
t.Errorf("stop: %d %q", code, out)
}
if out, _ = run("ps", "-a", "--format", "{{.Names}} {{.State}} {{.Status}}"); !strings.HasPrefix(out, "svc exited Exited (0)") {
t.Errorf("ps -a after stop: %q", out)
}
if out, _ = run("logs", "svc"); !strings.Contains(out, "bye") {
t.Errorf("logs after graceful stop: %q", out)
}
if _, code = run("start", "svc"); code != 0 {
t.Errorf("start: %d", code)
}
if out, code = run("exec", "svc", "cat", "/mnt/marker"); code != 0 || strings.TrimSpace(out) != "hello" {
t.Errorf("volume kept across stop/start: %d %q", code, out)
}
if out, code = run("rm", "svc"); code == 0 || !strings.Contains(out, "is running") {
t.Errorf("rm of a running instance: %d %q", code, out)
}
if out, code = run("rm", "-f", "svc"); code != 0 {
t.Errorf("rm -f: %d %q", code, out)
}
if out, _ = run("ps", "-a", "-q"); strings.TrimSpace(out) != "" {
t.Errorf("ps -a after rm: %q", out)
}
// a foreground --rm run leaves nothing behind either
if out, code = run(append(append([]string{"run", "--rm"}, hostBinds...), "host", "echo", "once")...); code != 0 || strings.TrimSpace(out) != "once" {
t.Errorf("run --rm: %d %q", code, out)
}
if entries, _ := os.ReadDir(filepath.Join(home, "instances")); len(entries) != 0 {
t.Errorf("instances left: %v", entries)
}
}