mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 19:32:55 -05:00
## Description
Make ODS reuse shared development infrastructure by default, with
`--isolated` for separate datasets.
- Use the same service specifications and launchers for `stack`, `web
dev`, and `backend` commands. Remove the duplicate `stack
--frontend-only` option.
- Enforce project ownership across checkouts and resolve database
commands to the actual managed database.
- Stop services before pending migrations, preserve them when already at
head, and reject manual migrations beneath managed services.
- Harden startup cancellation, shutdown grace, descendant cleanup,
terminal input, and frontend API validation. Preserve Windows foreground
commands.
- Consolidate environment helpers, protect symlinked environment files,
propagate restore errors, and update help and documentation.
This PR contains only ODS and its development-stack skill. It does not
include the plugins implementation.
## How Has This Been Tested?
- Targeted command and regression tests using fake tools and disposable
Git repositories.
- Race tests for runtime targeting, ownership, migration ordering,
process cleanup, and terminal input.
- `go vet ./...`, pre-commit checks, native builds, and Windows
cross-builds.
- The full Go suite was run. Its remaining failures are the existing
macOS devcontainer SSH-mount fixture expectations in
`TestDevcontainerCommands` and `TestRunDevRebuild`.
No real database migrations, restores, or development-stack restarts
ran. Native Windows execution remains untested.
## Additional Options
- [ ] [Optional] Please cherry-pick this PR to the latest release
version.
- [x] [Optional] Override Linear Check
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
ODS now shares development infrastructure across worktrees by default,
with `--isolated` for separate datasets. `stack`, `web dev`, and
`backend` use the same service specs and launchers, and the duplicate
`stack --frontend-only` option is removed.
- Direct launches run the same dependency-prepare step as stack startup,
so `backend` and `web dev` work with fresh or stale Python environments.
- A stopped preview discards its former project state before retargeting
to another API owner.
- Enforces project ownership across checkouts and resolves database
commands to the managed database. New tests reject conflicting targets
before any mutation.
- Stops services before pending migrations, preserves them when already
at head, and rejects manual migrations beneath managed services.
Migration probe failures fail closed.
- Hardens startup cancellation, shutdown grace, and descendant cleanup;
ODS keeps the terminal foreground group so Ctrl-C still honors shutdown
timeouts, and tmux pane identifiers no longer change service identity.
- Database passwords reach tools through the child environment only,
never in process arguments; Windows foreground commands are preserved.
- Consolidates environment helpers, protects symlinked env files,
propagates restore errors, and refreshes help and documentation. Test
commits exercise the new migration-target and preview-launch paths.
<sup>Written for commit 14500cb051.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/onyx-dot-app/onyx/pull/16246?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/onyx-dot-app/onyx/16246?returnTo=https%3A%2F%2Fgithub.com%2Fonyx-dot-app%2Fonyx%2Fpull%2F16246&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added the global `--isolated` option for separate development stacks;
shared infrastructure is the default, with clearer project ownership and
status information.
* Added `ods web dev` previews that can use a healthy managed API on
macOS and Linux, plus foreground startup on Windows.
* Expanded command guidance for development workflows, database
targeting, and dump formats.
* **Bug Fixes**
* Pending migrations now stop managed application services first;
healthy services remain running when no migrations are needed.
* Database operations verify project ownership and target selection
before making changes. Restore errors now fail the command, SQL restores
stop on errors, and temporary files are cleaned up after failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
638 lines
18 KiB
Go
638 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) MigrationsPending(context.Context, []string) (bool, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return len(p.migrated) == 0, 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, "", Isolated)
|
|
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) != 1 {
|
|
t.Errorf("rerunning up migrated a database already at head: 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"])
|
|
}
|
|
}
|