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
157 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|