package instance import ( "bytes" "errors" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" "werkdock/internal/engine" ) // hostEngine runs the command directly on the host, no sandbox — the // lifecycle is about processes and files, and a real process is what // signals need. type hostEngine struct{ stdout, stderr *bytes.Buffer } type hostProcess struct{ cmd *exec.Cmd } func (p *hostProcess) ChildPID() int { return p.cmd.Process.Pid } func (p *hostProcess) Signal(sig os.Signal) error { return p.cmd.Process.Signal(sig) } func (p *hostProcess) Wait() (int, error) { err := p.cmd.Wait() var exit *exec.ExitError if errors.As(err, &exit) { return exit.ExitCode(), nil } return 0, err } func (e *hostEngine) Start(spec engine.RunSpec) (engine.Process, error) { cmd := exec.Command(spec.Command[0], spec.Command[1:]...) if e.stdout != nil { cmd.Stdout, cmd.Stderr = e.stdout, e.stderr } if err := cmd.Start(); err != nil { return nil, err } return &hostProcess{cmd: cmd}, nil } func (e *hostEngine) Run(spec engine.RunSpec) (int, error) { p, err := e.Start(spec) if err != nil { return 0, err } return p.Wait() } func newManager(t *testing.T) Manager { t.Helper() return Manager{Root: t.TempDir()} } func create(t *testing.T, m Manager, name string, command ...string) *Instance { t.Helper() inst, err := m.Create(Config{Name: name, Image: "alpine:3.20", Spec: engine.RunSpec{RootFS: "/x", Command: command}}) if err != nil { t.Fatal(err) } return inst } func TestCreateLaysOutTheInstanceDirectory(t *testing.T) { m := newManager(t) inst, err := m.Create(Config{ Image: "docker.io/library/postgres:16-alpine", Spec: engine.RunSpec{RootFS: "/x", Command: []string{"postgres"}, Mounts: []engine.Mount{{Mode: engine.MountBind, Source: "/h", Dest: "/data"}}}, Volumes: []string{"/var/run/postgresql", "/var/lib/postgresql/data"}, }) if err != nil { t.Fatal(err) } if len(inst.Config.ID) != 64 || inst.Config.Version != ConfigVersion { t.Errorf("id %q, version %d", inst.Config.ID, inst.Config.Version) } if want := "postgres-" + inst.Config.ID[:6]; inst.Config.Name != want { t.Errorf("default name: got %q, want %q", inst.Config.Name, want) } for _, f := range []string{"config.json", "state.json", "run", "volumes/var/lib/postgresql/data", "volumes/var/run/postgresql"} { if _, err := os.Stat(filepath.Join(inst.Dir, f)); err != nil { t.Errorf("%s: %v", f, err) } } want := []engine.Mount{ {Mode: engine.MountBind, Source: filepath.Join(inst.Dir, "run"), Dest: "/run"}, {Mode: engine.MountBind, Source: filepath.Join(inst.Dir, "volumes/var/lib/postgresql/data"), Dest: "/var/lib/postgresql/data"}, {Mode: engine.MountBind, Source: filepath.Join(inst.Dir, "volumes/var/run/postgresql"), Dest: "/var/run/postgresql"}, {Mode: engine.MountBind, Source: "/h", Dest: "/data"}, } if got := inst.Config.Spec.Mounts; len(got) != len(want) { t.Fatalf("mounts: got %+v", got) } else { for i := range want { if got[i] != want[i] { t.Errorf("mount %d: got %+v want %+v", i, got[i], want[i]) } } } loaded, err := m.Get(inst.Config.ID[:8]) if err != nil { t.Fatal(err) } if loaded.State.Status != StatusCreated || loaded.Config.Name != inst.Config.Name { t.Errorf("reloaded: %+v %+v", loaded.Config, loaded.State) } } // config.json holds the environment of the sandbox, the logs what it // prints: the secrets of a build, so they are the owner's alone. func TestAnInstanceIsReadableByItsOwnerOnly(t *testing.T) { m := newManager(t) inst, err := m.Create(Config{Image: "i", Spec: engine.RunSpec{RootFS: "/x", Command: []string{"true"}, Env: []engine.EnvVar{{Key: "API_KEY", Value: "sk-or-v1-secret"}}}}) if err != nil { t.Fatal(err) } if _, err := m.Supervise(inst, &hostEngine{stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}}, SuperviseOptions{Record: true}); err != nil { t.Fatal(err) } for path, want := range map[string]os.FileMode{ inst.Dir: 0o700, filepath.Join(inst.Dir, "config.json"): 0o600, filepath.Join(inst.Dir, "state.json"): 0o600, inst.StdoutPath(): 0o600, inst.StderrPath(): 0o600, } { info, err := os.Stat(path) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != want { t.Errorf("%s: mode %o, want %o", path, got, want) } } } func TestCreateRefusesDuplicateAndInvalidNames(t *testing.T) { m := newManager(t) create(t, m, "db", "true") if _, err := m.Create(Config{Name: "db", Image: "i", Spec: engine.RunSpec{Command: []string{"true"}}}); err == nil || !strings.Contains(err.Error(), "already in use") { t.Errorf("duplicate name: got %v", err) } if _, err := m.Create(Config{Name: "-bad", Image: "i"}); err == nil || !strings.Contains(err.Error(), "invalid instance name") { t.Errorf("invalid name: got %v", err) } } func TestGetResolvesIDNameAndPrefix(t *testing.T) { m := newManager(t) a := create(t, m, "one", "true") b := create(t, m, "two", "true") for _, key := range []string{a.Config.ID, "one", a.Config.ID[:12]} { got, err := m.Get(key) if err != nil || got.Config.ID != a.Config.ID { t.Errorf("%q: got %v, %v", key, got, err) } } if _, err := m.Get("three"); !errors.Is(err, ErrNotFound) { t.Errorf("unknown: got %v", err) } // a prefix both share is ambiguous; the ids are random, so build one common := 0 for common < 64 && a.Config.ID[common] == b.Config.ID[common] { common++ } if common > 0 { if _, err := m.Get(a.Config.ID[:common]); err == nil || !strings.Contains(err.Error(), "ambiguous") { t.Errorf("ambiguous prefix: got %v", err) } } all, err := m.List() if err != nil || len(all) != 2 || all[0].Config.ID != b.Config.ID { t.Errorf("list newest first: %v %v", all, err) } } func TestSuperviseRecordsExitCodeAndOutput(t *testing.T) { m := newManager(t) inst := create(t, m, "job", "sh", "-c", "echo out; echo err >&2; exit 4") var live bytes.Buffer code, err := m.Supervise(inst, &hostEngine{stdout: &live, stderr: &live}, SuperviseOptions{Record: true}) if err != nil { t.Fatal(err) } if code != 4 { t.Errorf("exit code %d", code) } reloaded, err := m.Get("job") if err != nil { t.Fatal(err) } if reloaded.State.Status != StatusExited || reloaded.State.ExitCode != 4 || reloaded.State.PID != os.Getpid() || reloaded.State.FinishedAt.IsZero() { t.Errorf("state: %+v", reloaded.State) } var out, errs bytes.Buffer if err := m.Logs(reloaded, &out, &errs, false, nil); err != nil { t.Fatal(err) } // the host engine writes to the buffers, not to the files — the // files are what a bwrap engine fills; here they exist and are empty if _, err := os.Stat(reloaded.StdoutPath()); err != nil { t.Errorf("stdout file: %v", err) } } func TestSuperviseRecordsAStartFailure(t *testing.T) { m := newManager(t) inst := create(t, m, "job", "/nonexistent/program") if _, err := m.Supervise(inst, &hostEngine{}, SuperviseOptions{}); err == nil { t.Fatal("expected a start error") } reloaded, err := m.Get("job") if err != nil { t.Fatal(err) } if reloaded.State.Status != StatusExited || reloaded.State.ExitCode != ExitCodeLost || reloaded.State.Error == "" { t.Errorf("state: %+v", reloaded.State) } } func TestSuperviseRemovesAnAutoRemoveInstance(t *testing.T) { m := newManager(t) inst, err := m.Create(Config{Image: "i", AutoRemove: true, Spec: engine.RunSpec{Command: []string{"true"}}}) if err != nil { t.Fatal(err) } if _, err := m.Supervise(inst, &hostEngine{}, SuperviseOptions{}); err != nil { t.Fatal(err) } if _, err := os.Stat(inst.Dir); !os.IsNotExist(err) { t.Errorf("instance dir still there: %v", err) } } // stop: SIGTERM reaches a command that handles it; one that ignores it // is killed after the timeout. Both end in state exited. func TestStopTerminatesThenKills(t *testing.T) { m := newManager(t) graceful := create(t, m, "graceful", "sh", "-c", `trap 'exit 7' TERM; while :; do sleep 0.05; done`) stubborn := create(t, m, "stubborn", "sh", "-c", `trap '' TERM; while :; do sleep 0.05; done`) for _, inst := range []*Instance{graceful, stubborn} { inst := inst done := make(chan int, 1) go func() { code, _ := m.Supervise(inst, &hostEngine{}, SuperviseOptions{}) done <- code }() waitRunning(t, m, inst.Config.Name) } start := time.Now() if err := m.Stop(graceful, 5*time.Second); err != nil { t.Fatal(err) } if code, _ := m.Wait(graceful, time.Second); code != 7 || time.Since(start) > 3*time.Second { t.Errorf("graceful: code %d after %s", code, time.Since(start)) } start = time.Now() if err := m.Stop(stubborn, 300*time.Millisecond); err != nil { t.Fatal(err) } if code, _ := m.Wait(stubborn, time.Second); code != -1 && code != 137 || time.Since(start) < 300*time.Millisecond { t.Errorf("stubborn: code %d after %s", code, time.Since(start)) } if err := m.Stop(stubborn, time.Second); err != nil { t.Errorf("stopping a stopped instance: %v", err) } if err := m.Kill(stubborn, syscall.SIGTERM); err == nil || !strings.Contains(err.Error(), "not running") { t.Errorf("kill of a stopped instance: %v", err) } } func waitRunning(t *testing.T, m Manager, name string) *Instance { t.Helper() for i := 0; i < 100; i++ { inst, err := m.Get(name) if err == nil && inst.Running() { return inst } time.Sleep(20 * time.Millisecond) } t.Fatalf("%s did not reach running", name) return nil } func TestRemoveRefusesRunningUnlessForced(t *testing.T) { m := newManager(t) inst := create(t, m, "svc", "sh", "-c", "while :; do sleep 0.05; done") go m.Supervise(inst, &hostEngine{}, SuperviseOptions{}) running := waitRunning(t, m, "svc") if err := m.Remove(running, false); err == nil || !strings.Contains(err.Error(), "is running") { t.Errorf("rm of a running instance: %v", err) } if err := m.Remove(running, true); err != nil { t.Fatal(err) } if _, err := m.Get("svc"); !errors.Is(err, ErrNotFound) { t.Errorf("after rm -f: %v", err) } } // A state file that says running while the supervisor is gone (a kill // -9, a reboot) is repaired on load. func TestLoadReconcilesALostSupervisor(t *testing.T) { m := newManager(t) inst := create(t, m, "lost", "true") inst.State = State{Status: StatusRunning, PID: 2147483000, ChildPID: 2147483001, StartedAt: time.Now()} if err := inst.SaveState(); err != nil { t.Fatal(err) } reloaded, err := m.Get("lost") if err != nil { t.Fatal(err) } if reloaded.State.Status != StatusExited || reloaded.State.ExitCode != ExitCodeLost || reloaded.State.Error == "" { t.Errorf("state: %+v", reloaded.State) } } func TestLogsFollowsUntilExit(t *testing.T) { m := newManager(t) inst := create(t, m, "chatty", "true") if err := os.WriteFile(inst.StdoutPath(), []byte("one\n"), 0o644); err != nil { t.Fatal(err) } inst.State = State{Status: StatusRunning, PID: os.Getpid(), ChildPID: os.Getpid()} if err := inst.SaveState(); err != nil { t.Fatal(err) } go func() { time.Sleep(300 * time.Millisecond) f, _ := os.OpenFile(inst.StdoutPath(), os.O_APPEND|os.O_WRONLY, 0o644) f.WriteString("two\n") f.Close() inst.State.Status = StatusExited inst.SaveState() }() var out, errs bytes.Buffer if err := m.Logs(inst, &out, &errs, true, nil); err != nil { t.Fatal(err) } if out.String() != "one\ntwo\n" { t.Errorf("followed output %q", out.String()) } }