350 lines
11 KiB
Go
350 lines
11 KiB
Go
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())
|
|
}
|
|
}
|