mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 03:42:59 -05:00
632 lines
18 KiB
Go
632 lines
18 KiB
Go
//go:build !windows
|
|
|
|
package devstack
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/onyx-dot-app/onyx/tools/ods/internal/gittest"
|
|
)
|
|
|
|
// fakeServiceEnv makes the test binary act as a service when set.
|
|
const fakeServiceEnv = "ODS_STACK_FAKE_SERVICE"
|
|
|
|
func TestMain(m *testing.M) {
|
|
if os.Getenv(fakeServiceEnv) != "" {
|
|
fakeServiceMain(os.Args[1:])
|
|
return
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// fakeServiceMain listens on --port, answers /health, and exits on SIGTERM.
|
|
// --ignore-term keeps running through SIGTERM; --spawn-child forks a copy
|
|
// with --ignore-term and no port, in the same process group.
|
|
func fakeServiceMain(args []string) {
|
|
port := 0
|
|
var raceMarker string
|
|
var ignoreTerm, spawnChild bool
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--port":
|
|
port, _ = strconv.Atoi(args[i+1])
|
|
i++
|
|
case "--ignore-term":
|
|
ignoreTerm = true
|
|
case "--spawn-child":
|
|
spawnChild = true
|
|
case "--race-once":
|
|
raceMarker = args[i+1]
|
|
i++
|
|
}
|
|
}
|
|
if ignoreTerm {
|
|
signal.Ignore(syscall.SIGTERM)
|
|
}
|
|
if spawnChild {
|
|
exe, _ := os.Executable()
|
|
child := exec.Command(exe, "--ignore-term")
|
|
child.Env = os.Environ()
|
|
if err := child.Start(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("child", child.Process.Pid)
|
|
}
|
|
if port > 0 {
|
|
if raceMarker != "" {
|
|
file, err := os.OpenFile(raceMarker, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err == nil {
|
|
_, _ = fmt.Fprint(file, port)
|
|
_ = file.Close()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(raceMarker + ".ready"); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
os.Exit(2)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
go func() { _ = http.Serve(ln, mux) }()
|
|
}
|
|
// Block on a signal channel so the runtime never sees a deadlock.
|
|
hup := make(chan os.Signal, 1)
|
|
signal.Notify(hup, syscall.SIGHUP)
|
|
<-hup
|
|
}
|
|
|
|
// fakeProvider is a Provider that starts no infrastructure and plans fake
|
|
// services on the leased ports.
|
|
type fakeProvider struct {
|
|
mu sync.Mutex
|
|
infra []PortRequest
|
|
services []string
|
|
// extraArgs per service slug, such as "--spawn-child".
|
|
extraArgs map[string][]string
|
|
// fixedPorts per service slug: a port the service binds that is not
|
|
// leased, as the Slack bot's metrics port.
|
|
fixedPorts map[string]int
|
|
// planHook runs at the start of Plan, inside the lifecycle lock.
|
|
planHook func()
|
|
migrated [][]string
|
|
composeUp int
|
|
prepared int
|
|
}
|
|
|
|
func (p *fakeProvider) Prepare(context.Context) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.prepared++
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeProvider) InfraPorts() ([]PortRequest, error) { return p.infra, nil }
|
|
|
|
func (p *fakeProvider) AppPorts(Options) []PortRequest {
|
|
var reqs []PortRequest
|
|
for i, name := range p.services {
|
|
reqs = append(reqs, PortRequest{Name: name, Preferred: testBasePort + i*10})
|
|
}
|
|
return reqs
|
|
}
|
|
|
|
func (p *fakeProvider) ComposeUp(context.Context, map[string]int, Options) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.composeUp++
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeProvider) ComposeDown(context.Context) error { return nil }
|
|
|
|
func (p *fakeProvider) Migrate(_ context.Context, env []string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.migrated = append(p.migrated, env)
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeProvider) Plan(_ string, ports map[string]int, env Env, _ Options) ([]Spec, error) {
|
|
if p.planHook != nil {
|
|
p.planHook()
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var specs []Spec
|
|
for _, name := range p.services {
|
|
port := ports[name]
|
|
if fixed, ok := p.fixedPorts[name]; ok {
|
|
port = fixed
|
|
}
|
|
cmd := append([]string{exe, "--port", fmt.Sprint(port)}, p.extraArgs[name]...)
|
|
spec := Spec{
|
|
Name: name,
|
|
Slug: name,
|
|
Command: cmd,
|
|
Dir: os.TempDir(),
|
|
Env: env.With([]string{fakeServiceEnv + "=1"}),
|
|
Port: port,
|
|
Health: fmt.Sprintf("http://localhost:%d/health", port),
|
|
}
|
|
if fixed, ok := p.fixedPorts[name]; ok {
|
|
spec.Port, spec.FixedPort, spec.Optional, spec.Health = 0, fixed, true, ""
|
|
}
|
|
specs = append(specs, spec)
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
func (p *fakeProvider) InfraEnv(ports map[string]int) map[string]string {
|
|
values := map[string]string{}
|
|
if port, ok := ports["POSTGRES_HOST_PORT"]; ok {
|
|
values["POSTGRES_PORT"] = fmt.Sprint(port)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// testBasePort is a free region chosen once per test binary.
|
|
var testBasePort = freeBasePort()
|
|
|
|
// newTestStack makes a git worktree with env templates and a stack whose
|
|
// registry lives in registryDir.
|
|
func newTestStack(t *testing.T, registryDir string) *Stack {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
gittest.Git(t, dir, "init", "-q")
|
|
vscode := filepath.Join(dir, ".vscode")
|
|
if err := os.MkdirAll(vscode, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, content := range map[string]string{
|
|
"env_template.txt": "GEN_AI_API_KEY=<REPLACE THIS>\nLANGSMITH_TRACING=\"true\"\nPOSTGRES_HOST=relational_db\n",
|
|
"env.web_template.txt": "AUTH_TYPE=basic\n",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(vscode, name), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s, err := Open(dir, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Registry = &Registry{Dir: registryDir}
|
|
t.Cleanup(func() {
|
|
if err := s.stopServices(2 * time.Second); err != nil {
|
|
t.Errorf("stack cleanup: %v", err)
|
|
}
|
|
})
|
|
return s
|
|
}
|
|
|
|
func testOptions() Options {
|
|
return Options{ReadyTimeout: 20 * time.Second, LockWait: 30 * time.Second, StopTimeout: 2 * time.Second, SkipPrepare: true}
|
|
}
|
|
|
|
func mustUp(t *testing.T, s *Stack, p *fakeProvider) *Status {
|
|
t.Helper()
|
|
status, err := s.Up(context.Background(), p, testOptions())
|
|
if err != nil {
|
|
t.Fatalf("up: %v (status %+v)", err, status)
|
|
}
|
|
if !status.Ready || status.Phase != PhaseReady {
|
|
t.Fatalf("stack not ready: %+v", status)
|
|
}
|
|
return status
|
|
}
|
|
|
|
func servicePID(t *testing.T, s *Stack, slug string) ServiceRecord {
|
|
t.Helper()
|
|
st, err := s.ReadState()
|
|
if err != nil || st == nil {
|
|
t.Fatalf("read state: %v", err)
|
|
}
|
|
for _, rec := range st.Services {
|
|
if rec.Slug == slug {
|
|
return rec
|
|
}
|
|
}
|
|
t.Fatalf("no record for %s", slug)
|
|
return ServiceRecord{}
|
|
}
|
|
|
|
func TestUp_twoStacksGetDistinctPortsAndState(t *testing.T) {
|
|
registry := t.TempDir()
|
|
a := newTestStack(t, registry)
|
|
b := newTestStack(t, registry)
|
|
pa := &fakeProvider{services: []string{"web", "api"}, infra: []PortRequest{{Name: "POSTGRES_HOST_PORT", Preferred: testBasePort + 500}}}
|
|
pb := &fakeProvider{services: []string{"web", "api"}, infra: []PortRequest{{Name: "POSTGRES_HOST_PORT", Preferred: testBasePort + 500}}}
|
|
|
|
sa := mustUp(t, a, pa)
|
|
sb := mustUp(t, b, pb)
|
|
|
|
if a.Project == b.Project {
|
|
t.Fatalf("projects collide: %s", a.Project)
|
|
}
|
|
if a.StateDir == b.StateDir {
|
|
t.Fatalf("state dirs collide: %s", a.StateDir)
|
|
}
|
|
for _, name := range []string{"web", "api", "POSTGRES_HOST_PORT"} {
|
|
if sa.Ports[name] == sb.Ports[name] {
|
|
t.Errorf("%s port shared between stacks: %d", name, sa.Ports[name])
|
|
}
|
|
}
|
|
if sa.URLs["web"] != fmt.Sprintf("http://localhost:%d", sa.Ports["web"]) {
|
|
t.Errorf("web url %q does not match port %d", sa.URLs["web"], sa.Ports["web"])
|
|
}
|
|
// Migrations ran with the explicit local host and this stack's port.
|
|
if len(pa.migrated) != 1 {
|
|
t.Fatalf("migrations ran %d times", len(pa.migrated))
|
|
}
|
|
if got := EnvValue(pa.migrated[0], "POSTGRES_HOST"); got != "localhost" {
|
|
t.Errorf("POSTGRES_HOST=%q, want localhost", got)
|
|
}
|
|
if got := EnvValue(pa.migrated[0], "POSTGRES_PORT"); got != fmt.Sprint(sa.Ports["POSTGRES_HOST_PORT"]) {
|
|
t.Errorf("POSTGRES_PORT=%q, want %d", got, sa.Ports["POSTGRES_HOST_PORT"])
|
|
}
|
|
|
|
// Stopping one stack leaves the other running.
|
|
if err := a.Stop(context.Background(), testOptions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st, _ := a.Status(); st.Ready || st.Phase != PhaseStopped || len(st.Services) != 0 {
|
|
t.Errorf("stack a after stop: %+v", st)
|
|
}
|
|
st, err := b.Status()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !st.Ready {
|
|
t.Errorf("stack b stopped too: %+v", st)
|
|
}
|
|
for _, svc := range st.Services {
|
|
if svc.State != StateRunning || svc.Healthy == nil || !*svc.Healthy {
|
|
t.Errorf("stack b service %s: %+v", svc.Slug, svc)
|
|
}
|
|
}
|
|
|
|
// A restarted stack gets its ports back.
|
|
again, err := a.Up(context.Background(), pa, testOptions())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, port := range sa.Ports {
|
|
if again.Ports[name] != port {
|
|
t.Errorf("%s moved from %d to %d across restart", name, port, again.Ports[name])
|
|
}
|
|
}
|
|
if len(pa.migrated) != 2 {
|
|
t.Errorf("rerunning up skipped migrations: ran %d times", len(pa.migrated))
|
|
}
|
|
}
|
|
|
|
func TestUp_concurrentSameStackConverges(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web", "api"}}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, 2)
|
|
for i := range results {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, results[i] = s.Up(context.Background(), p, testOptions())
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for i, err := range results {
|
|
if err != nil {
|
|
t.Fatalf("up %d: %v", i, err)
|
|
}
|
|
}
|
|
st, err := s.ReadState()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(st.Services) != 2 {
|
|
t.Fatalf("services after two ups: %d", len(st.Services))
|
|
}
|
|
pids := map[int]bool{}
|
|
for _, rec := range st.Services {
|
|
if !isCurrent(rec) {
|
|
t.Errorf("%s is not running", rec.Slug)
|
|
}
|
|
pids[rec.PID] = true
|
|
}
|
|
if len(pids) != 2 {
|
|
t.Errorf("expected two distinct processes, got %v", pids)
|
|
}
|
|
// The second run reused the first's processes rather than starting more
|
|
// on the same ports: every leased port has exactly one listener.
|
|
for _, rec := range st.Services {
|
|
if bindable(rec.Port) {
|
|
t.Errorf("port %d of %s is free", rec.Port, rec.Slug)
|
|
}
|
|
}
|
|
if p.composeUp != 2 || p.migrated == nil {
|
|
t.Errorf("compose ran %d times, migrations %d", p.composeUp, len(p.migrated))
|
|
}
|
|
}
|
|
|
|
func TestUp_busyLeasedPortMovesService(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web"}}
|
|
first := mustUp(t, s, p)
|
|
if err := s.Stop(context.Background(), testOptions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Something else now holds the leased port.
|
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", first.Ports["web"]))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = ln.Close() }()
|
|
|
|
second := mustUp(t, s, p)
|
|
if second.Ports["web"] == first.Ports["web"] {
|
|
t.Fatalf("web stayed on busy port %d", first.Ports["web"])
|
|
}
|
|
leases, err := s.Registry.Leases()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, l := range leases {
|
|
if l.Name == "web" && l.Port != second.Ports["web"] {
|
|
t.Errorf("registry lease %+v does not match new port %d", l, second.Ports["web"])
|
|
}
|
|
}
|
|
rec := servicePID(t, s, "web")
|
|
if rec.Port != second.Ports["web"] || !isCurrent(rec) {
|
|
t.Errorf("web record %+v", rec)
|
|
}
|
|
}
|
|
|
|
func TestUp_recoversInterruptedStart(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web", "api"}}
|
|
mustUp(t, s, p)
|
|
web := servicePID(t, s, "web")
|
|
api := servicePID(t, s, "api")
|
|
|
|
// Simulate an interrupted start: the api process died and the state was
|
|
// left mid-start.
|
|
if err := syscall.Kill(-api.PGID, syscall.SIGKILL); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for groupAlive(api.PGID) {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
st, _ := s.ReadState()
|
|
st.Phase = PhaseStarting
|
|
if err := s.WriteState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, _ := s.Status(); status.Ready {
|
|
t.Fatalf("status reports ready with a dead service: %+v", status)
|
|
}
|
|
|
|
mustUp(t, s, p)
|
|
if got := servicePID(t, s, "web"); got.PID != web.PID {
|
|
t.Errorf("healthy web was restarted: pid %d -> %d", web.PID, got.PID)
|
|
}
|
|
if got := servicePID(t, s, "api"); got.PID == api.PID || !isCurrent(got) {
|
|
t.Errorf("dead api was not restarted: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestUp_reconcilesChangedSpecOnly(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web", "api"}, extraArgs: map[string][]string{}}
|
|
mustUp(t, s, p)
|
|
web := servicePID(t, s, "web")
|
|
api := servicePID(t, s, "api")
|
|
|
|
// The api command changes; web does not.
|
|
p.extraArgs["api"] = []string{"--changed"}
|
|
mustUp(t, s, p)
|
|
if got := servicePID(t, s, "web"); got.PID != web.PID {
|
|
t.Errorf("unchanged web was restarted: pid %d -> %d", web.PID, got.PID)
|
|
}
|
|
got := servicePID(t, s, "api")
|
|
if got.PID == api.PID {
|
|
t.Errorf("changed api kept pid %d", api.PID)
|
|
}
|
|
if pidAlive(api.PID) && isCurrent(api) {
|
|
t.Errorf("old api process %d still runs", api.PID)
|
|
}
|
|
}
|
|
|
|
func TestStop_killsDescendantsThatIgnoreTerm(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web"}, extraArgs: map[string][]string{"web": {"--spawn-child"}}}
|
|
mustUp(t, s, p)
|
|
web := servicePID(t, s, "web")
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for len(groupMembers(web.PGID)) < 2 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("child never appeared in group %d", web.PGID)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
if err := s.Stop(context.Background(), testOptions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if groupAlive(web.PGID) {
|
|
t.Errorf("process group %d still has members: %v", web.PGID, groupMembers(web.PGID))
|
|
}
|
|
if !bindable(web.Port) {
|
|
t.Errorf("port %d still held after stop", web.Port)
|
|
}
|
|
}
|
|
|
|
func TestStop_leavesForeignProcessesAlone(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
exe, _ := os.Executable()
|
|
other := exec.Command(exe, "--port", "0")
|
|
other.Env = append(os.Environ(), fakeServiceEnv+"=1")
|
|
other.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
if err := other.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = other.Process.Kill(); _, _ = other.Process.Wait() }()
|
|
|
|
// A record that names a live pid with the wrong start identity.
|
|
st := &State{Phase: PhaseReady, Services: []ServiceRecord{{
|
|
Name: "web", Slug: "web", PID: other.Process.Pid, PGID: other.Process.Pid,
|
|
StartMarker: "not the real marker", StartedAt: time.Now().Add(-time.Hour),
|
|
}}}
|
|
if err := s.WriteState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Stop(context.Background(), testOptions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !pidAlive(other.Process.Pid) {
|
|
t.Fatal("stop killed a process it did not start")
|
|
}
|
|
}
|
|
|
|
func TestLock_timesOutWithOwner(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
unlock, err := s.Lock(context.Background(), "test holder", time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer unlock()
|
|
_, err = s.Lock(context.Background(), "second", 300*time.Millisecond)
|
|
if !errors.Is(err, ErrLocked) {
|
|
t.Fatalf("second lock: %v", err)
|
|
}
|
|
if got := err.Error(); !contains(got, "test holder") {
|
|
t.Errorf("error does not name the owner: %s", got)
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
|
}
|
|
|
|
func indexOf(s, sub string) int {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestUp_keepsServiceOnFixedPortItHolds(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
fixed := freeBasePort() + 5
|
|
p := &fakeProvider{services: []string{"web", "bot"}, fixedPorts: map[string]int{"bot": fixed}}
|
|
mustUp(t, s, p)
|
|
bot := servicePID(t, s, "bot")
|
|
if bot.PID == 0 || bot.Skipped != "" {
|
|
t.Fatalf("bot not started: %+v", bot)
|
|
}
|
|
|
|
// The bot itself now holds its fixed port; a second up must keep it.
|
|
mustUp(t, s, p)
|
|
again := servicePID(t, s, "bot")
|
|
if again.PID != bot.PID || again.Skipped != "" {
|
|
t.Errorf("bot restarted or skipped on rerun: %+v -> %+v", bot, again)
|
|
}
|
|
|
|
// Once something else holds the port, the bot is skipped, not killed
|
|
// wrongly: stop first so the port is free for the foreign listener.
|
|
if err := s.Stop(context.Background(), testOptions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", fixed))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = ln.Close() }()
|
|
mustUp(t, s, p)
|
|
if skipped := servicePID(t, s, "bot"); skipped.Skipped == "" {
|
|
t.Errorf("bot not skipped while port is foreign: %+v", skipped)
|
|
}
|
|
}
|
|
|
|
func TestStatus_notReadyWhileStartIsIncomplete(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web"}}
|
|
mustUp(t, s, p)
|
|
st, _ := s.ReadState()
|
|
// An interrupted start saved a healthy web but never reached the rest.
|
|
st.Phase = PhaseStarting
|
|
if err := s.WriteState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, err := s.Status()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status.Ready {
|
|
t.Fatalf("incomplete start reported ready: %+v", status)
|
|
}
|
|
mustUp(t, s, p)
|
|
if status, _ := s.Status(); !status.Ready {
|
|
t.Fatalf("finished start not ready: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestRestart_holdsOneLock(t *testing.T) {
|
|
s := newTestStack(t, t.TempDir())
|
|
p := &fakeProvider{services: []string{"web"}}
|
|
first := mustUp(t, s, p)
|
|
|
|
// Plan runs inside the locked restart, after the services were stopped:
|
|
// a competing lifecycle command must be refused at that point.
|
|
var lockErr error
|
|
hooked := false
|
|
p.planHook = func() {
|
|
hooked = true
|
|
var unlock func()
|
|
unlock, lockErr = s.Lock(context.Background(), "intruder", 100*time.Millisecond)
|
|
if lockErr == nil {
|
|
unlock()
|
|
}
|
|
}
|
|
status, err := s.Restart(context.Background(), p, testOptions())
|
|
if err != nil || !status.Ready {
|
|
t.Fatalf("restart: %v %+v", err, status)
|
|
}
|
|
if !hooked {
|
|
t.Fatal("plan hook did not run")
|
|
}
|
|
if !errors.Is(lockErr, ErrLocked) {
|
|
t.Errorf("another command could take the lock during restart: %v", lockErr)
|
|
}
|
|
if status.Ports["web"] != first.Ports["web"] {
|
|
t.Errorf("restart changed the port: %d -> %d", first.Ports["web"], status.Ports["web"])
|
|
}
|
|
}
|