Files
claude-code b2266658f3
werkator build succeeded after 00:10
werkator/vulnerability-data build succeeded after 00:06
The sandbox's environment no longer stands on the command line of bwrap (0.13.2) (#17)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-20 18:35:39 +02:00

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())
}
}