5b8656974b
The command form ran only when the lock was SET, so a database switch closed by
`command` stayed closed through the household's 15-minute window. New twin
fields `open_command` + `open_success` (same service/user/args_env and the same
argv-safe expansion and success-marker rules as `command`/`success`).
- liftNativeLock (the window, via OpenSignupWindow → goNativeLock(false)): marks
the gate record native_lock "opening" BEFORE anything opens, lifts the env,
runs open_command; only full success records "lifted". A failed open closes
the switch again at once and records after_setup {ok: false, step: open}; the
app page shows its own line (app_info.signup_native_open_failed).
- The close: reconcileSignupBlocks (every 20 s and at controller start) runs
`command` for any non-"applied" state once no window runs. A failed close
after a window is logged ERROR ("may still be OPEN past the household's
window") and retried every nativeLockOpenRetry (2 min) instead of 30.
- After a successful app update, verifyAndConclude → markNativeLockForReapply
sets native_lock "" so the loop closes the switch again.
- A template with `command` but no `open_command` keeps today's window (env
only) and logs once per app that its own switch cannot be reopened.
Tests: internal/stacks/after_setup_r717_test.go (7), web render + parity case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
287 lines
12 KiB
Go
287 lines
12 KiB
Go
package stacks
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-717 — the command form's "open" twin: the household's 15-minute window reopens an app's own database-held
|
|
// sign-up switch, and the loop closes it again. Never reaches Docker: afterLoadFn is the compose-exec seam.
|
|
|
|
const cmdYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" +
|
|
"after_setup:\n service: gapp\n command: [\"sh\", \"-c\", \"close-signup\"]\n success: \"SIGNUP-CLOSED\"\n" +
|
|
" open_command: [\"sh\", \"-c\", \"open-signup\"]\n open_success: \"SIGNUP-OPENED\"\n" +
|
|
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
|
|
|
|
// fakeSwitch is the app's own database switch behind the exec seam.
|
|
type fakeSwitch struct {
|
|
mu sync.Mutex
|
|
open bool
|
|
opens int
|
|
closes int
|
|
failOpen bool
|
|
failClose bool
|
|
onOpen func() // runs after the switch opened, before the command returns (a crash point)
|
|
}
|
|
|
|
func (f *fakeSwitch) exec(dir string, args ...string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
last := args[len(args)-1]
|
|
switch last {
|
|
case "open-signup":
|
|
f.opens++
|
|
if f.failOpen {
|
|
return "error: database is locked", fmt.Errorf("exit 1")
|
|
}
|
|
f.open = true
|
|
if f.onOpen != nil {
|
|
f.onOpen()
|
|
}
|
|
return "SIGNUP-OPENED", nil
|
|
case "close-signup":
|
|
f.closes++
|
|
if f.failClose {
|
|
return "error: database is locked", fmt.Errorf("exit 1")
|
|
}
|
|
f.open = false
|
|
return "SIGNUP-CLOSED", nil
|
|
}
|
|
return "", fmt.Errorf("unexpected exec %v", args)
|
|
}
|
|
|
|
func (f *fakeSwitch) counts() (opens, closes int, open bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.opens, f.closes, f.open
|
|
}
|
|
|
|
func wireCmdManager(m *Manager, f *fakeSwitch) *bytes.Buffer {
|
|
m.afterSetupSync = true
|
|
m.afterSetupUpFn = func(string) error { return nil }
|
|
m.afterLoadFn = f.exec
|
|
var buf bytes.Buffer
|
|
m.logger = log.New(&buf, "", 0)
|
|
return &buf
|
|
}
|
|
|
|
func cmdManager(t *testing.T, yml string) (*Manager, *fakeSwitch, *bytes.Buffer, string) {
|
|
t.Helper()
|
|
m := gateManager(t, yml)
|
|
f := &fakeSwitch{}
|
|
buf := wireCmdManager(m, f)
|
|
dir := closedGate(t, m)
|
|
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) // the first close
|
|
if _, closes, open := f.counts(); closes != 1 || open {
|
|
t.Fatalf("the gate opening did not close the app's own switch: closes=%d open=%v", closes, open)
|
|
}
|
|
return m, f, buf, dir
|
|
}
|
|
|
|
// The window runs open_command once; the window's end runs command once; the switch ends closed.
|
|
// COMPANION RED-PROOF (audits/design-build-2026-10-06/E/red-open-then-close.txt): on the unchanged code (no
|
|
// open_command) the window never opens the switch → "the window did not open" fails.
|
|
func TestAfterSetupR717_OpenThenCloseEachRunOnce(t *testing.T) {
|
|
m, f, _, dir := cmdManager(t, cmdYml)
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
if opens, closes, open := f.counts(); opens != 1 || closes != 1 || !open {
|
|
t.Fatalf("the window did not open the app's own switch: opens=%d closes=%d open=%v", opens, closes, open)
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockLifted || c.AfterSetup == nil || !c.AfterSetup.OK {
|
|
t.Fatalf("after the open: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
|
|
}
|
|
m.SetupGateTick() // inside the window: nothing closes
|
|
if _, closes, open := f.counts(); closes != 1 || !open {
|
|
t.Fatalf("the loop closed the switch INSIDE the window: closes=%d open=%v", closes, open)
|
|
}
|
|
later := time.Now().Add(signupWindow + time.Minute)
|
|
m.updateNowFn = func() time.Time { return later }
|
|
m.SetupGateTick()
|
|
m.SetupGateTick() // a second pass must not run it again
|
|
if opens, closes, open := f.counts(); opens != 1 || closes != 2 || open {
|
|
t.Fatalf("after the window: opens=%d closes=%d open=%v (want 1 open, 1 more close, closed)", opens, closes, open)
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied || !c.AfterSetup.OK {
|
|
t.Fatalf("after the window: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
|
|
}
|
|
}
|
|
|
|
// A box restart inside the window ends CLOSED — even when the controller died between the open command and its
|
|
// record (the worst moment: the switch is open, nothing says so yet).
|
|
// COMPANION RED-PROOF (red-restart-mid-open.txt): drop the "opening" mark in liftNativeLock → the disk at the crash
|
|
// says "applied", the restarted loop never closes, "a restart left sign-up OPEN" fails.
|
|
func TestAfterSetupR717_ARestartInsideTheWindowEndsClosed(t *testing.T) {
|
|
m, f, _, dir := cmdManager(t, cmdYml)
|
|
var atCrash []byte
|
|
f.onOpen = func() { // the controller dies here: copy app.yaml as it is on disk at this moment
|
|
b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
must(t, err)
|
|
atCrash = b
|
|
}
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
if atCrash == nil {
|
|
t.Fatal("the open command never ran")
|
|
}
|
|
must(t, os.WriteFile(filepath.Join(dir, "app.yaml"), atCrash, 0o600)) // the disk the restart finds
|
|
|
|
// The restart: a new Manager over the same paths, the startup tick (RunSetupGateLoop's first pass).
|
|
m2, err := NewManager(m.cfg, log.New(&bytes.Buffer{}, "", 0))
|
|
must(t, err)
|
|
must(t, m2.ScanStacks())
|
|
buf := wireCmdManager(m2, f)
|
|
m2.mu.Lock()
|
|
m2.stacks["gapp"].State = StateRunning
|
|
m2.mu.Unlock()
|
|
later := time.Now().Add(signupWindow + time.Minute)
|
|
m2.updateNowFn = func() time.Time { return later }
|
|
m2.SetupGateTick()
|
|
if _, _, open := f.counts(); open {
|
|
t.Fatalf("a restart left sign-up OPEN in the app past its window (record at the crash: native=%q)\n%s",
|
|
LoadAppConfig(dir).SetupGate.NativeLock, buf.String())
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
|
|
t.Fatalf("after the restart: native=%q", c.SetupGate.NativeLock)
|
|
}
|
|
}
|
|
|
|
// An open command that fails leaves the switch closed, records step "open", and says so in the log.
|
|
// COMPANION RED-PROOF (red-failed-open.txt): drop the re-close (applyNativeLock(true, …)) in liftNativeLock's
|
|
// failure branch → the half-opened switch stays open → "a failed open left the switch OPEN" fails.
|
|
func TestAfterSetupR717_AFailedOpenStaysClosedAndSaysSo(t *testing.T) {
|
|
m, f, buf, dir := cmdManager(t, cmdYml)
|
|
f.onOpen = nil
|
|
f.mu.Lock()
|
|
f.failOpen = true
|
|
f.open = true // the command half-ran: it flipped the switch, then failed
|
|
f.mu.Unlock()
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
if _, _, open := f.counts(); open {
|
|
t.Fatalf("a failed open left the switch OPEN\n%s", buf.String())
|
|
}
|
|
c := LoadAppConfig(dir)
|
|
if c.SetupGate.NativeLock == NativeLockLifted || c.SetupGate.NativeLock == NativeLockOpening {
|
|
t.Fatalf("a failed open recorded native=%q", c.SetupGate.NativeLock)
|
|
}
|
|
if c.AfterSetup == nil || c.AfterSetup.OK || c.AfterSetup.Step != afterSetupStepOpen || !strings.Contains(c.AfterSetup.Detail, "could not be opened") {
|
|
t.Fatalf("the failure is not recorded: %+v", c.AfterSetup)
|
|
}
|
|
if !strings.Contains(buf.String(), "[ERROR]") || !strings.Contains(buf.String(), "could NOT be opened") {
|
|
t.Fatalf("the failure is not logged:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
// A close that fails at the window's end is logged loudly, never recorded as closed, and retried by the loop.
|
|
// COMPANION RED-PROOF: none separate — the retry gap is pinned by asserting the second pass (nativeLockOpenRetry).
|
|
func TestAfterSetupR717_AFailedCloseIsRetriedNotTrusted(t *testing.T) {
|
|
m, f, buf, dir := cmdManager(t, cmdYml)
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
f.mu.Lock()
|
|
f.failClose = true
|
|
f.mu.Unlock()
|
|
t0 := time.Now().Add(signupWindow + time.Minute)
|
|
m.updateNowFn = func() time.Time { return t0 }
|
|
m.SetupGateTick()
|
|
c := LoadAppConfig(dir)
|
|
if c.SetupGate.NativeLock == NativeLockApplied || c.AfterSetup.OK {
|
|
t.Fatalf("a failed close was recorded as closed: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
|
|
}
|
|
if !strings.Contains(buf.String(), "may still be OPEN past the household's window") {
|
|
t.Fatalf("the failed close is not loud:\n%s", buf.String())
|
|
}
|
|
f.mu.Lock()
|
|
f.failClose = false
|
|
f.mu.Unlock()
|
|
t1 := t0.Add(nativeLockOpenRetry + time.Second)
|
|
m.updateNowFn = func() time.Time { return t1 }
|
|
m.SetupGateTick()
|
|
if _, _, open := f.counts(); open {
|
|
t.Fatal("the loop did not retry the close")
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied || !c.AfterSetup.OK {
|
|
t.Fatalf("after the retry: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
|
|
}
|
|
}
|
|
|
|
// After a successful app update, the loop runs the close again.
|
|
// COMPANION RED-PROOF (red-close-after-update.txt): drop markNativeLockForReapply in verifyAndConclude → the close
|
|
// does not run again → "the update did not re-run the close" fails.
|
|
func TestAfterSetupR717_TheCloseRunsAgainAfterAnUpdate(t *testing.T) {
|
|
m, f, _, dir := cmdManager(t, cmdYml)
|
|
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "ok" }
|
|
f.mu.Lock()
|
|
f.open = true // the update brought a database whose switch is open
|
|
f.mu.Unlock()
|
|
m.verifyAndConclude(context.Background(), "gapp", dir, nil, UpdateRestorePoint{}, time.Now(), &updateJournalEntry{})
|
|
m.mu.Lock()
|
|
m.stacks["gapp"].State = StateRunning
|
|
m.mu.Unlock()
|
|
m.SetupGateTick()
|
|
if _, closes, open := f.counts(); closes != 2 || open {
|
|
t.Fatalf("the update did not re-run the close: closes=%d open=%v", closes, open)
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
|
|
t.Fatalf("after the update: native=%q", c.SetupGate.NativeLock)
|
|
}
|
|
}
|
|
|
|
// A template that closes by command but has no open_command: the window opens the address only, the app's switch
|
|
// stays closed, and the log says so ONCE (two presses).
|
|
// COMPANION RED-PROOF: drop the LoadOrStore guard → the warning appears twice and this fails.
|
|
func TestAfterSetupR717_NoOpenCommandLogsOnceAndKeepsTheWindow(t *testing.T) {
|
|
yml := strings.Replace(cmdYml, " open_command: [\"sh\", \"-c\", \"open-signup\"]\n open_success: \"SIGNUP-OPENED\"\n", "", 1)
|
|
m, f, buf, dir := cmdManager(t, yml)
|
|
for i := 0; i < 2; i++ {
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
}
|
|
if opens, closes, open := f.counts(); opens != 0 || closes != 1 || open {
|
|
t.Fatalf("opens=%d closes=%d open=%v", opens, closes, open)
|
|
}
|
|
if n := strings.Count(buf.String(), "has no open_command"); n != 1 {
|
|
t.Fatalf("the missing open_command was logged %d times, want once:\n%s", n, buf.String())
|
|
}
|
|
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
|
|
t.Fatal("the window did not open the address")
|
|
}
|
|
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
|
|
t.Fatalf("native=%q — nothing of the app's own was opened", c.SetupGate.NativeLock)
|
|
}
|
|
}
|
|
|
|
// The env form keeps today's behaviour beside an open_command: env lifted AND the command run.
|
|
func TestAfterSetupR717_EnvAndOpenCommandTogether(t *testing.T) {
|
|
yml := strings.Replace(cmdYml, "after_setup:\n", "after_setup:\n env:\n SIGNUP_CLOSED: \"true\"\n", 1)
|
|
m := gateManager(t, yml)
|
|
must(t, os.WriteFile(filepath.Join(m.cfg.Paths.StacksDir, "gapp", "docker-compose.yml"), []byte(nativeCompose), 0o644))
|
|
must(t, m.ScanStacks())
|
|
f := &fakeSwitch{}
|
|
wireCmdManager(m, f)
|
|
dir := closedGate(t, m)
|
|
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold))
|
|
_, err := m.OpenSignupWindow("gapp")
|
|
must(t, err)
|
|
c := LoadAppConfig(dir)
|
|
if opens, _, open := f.counts(); c.Env["SIGNUP_CLOSED"] != "" || opens != 1 || !open || c.SetupGate.NativeLock != NativeLockLifted {
|
|
t.Fatalf("window: env=%q opens=%d open=%v native=%q", c.Env["SIGNUP_CLOSED"], opens, open, c.SetupGate.NativeLock)
|
|
}
|
|
later := time.Now().Add(signupWindow + time.Minute)
|
|
m.updateNowFn = func() time.Time { return later }
|
|
m.SetupGateTick()
|
|
c = LoadAppConfig(dir)
|
|
if _, _, open := f.counts(); c.Env["SIGNUP_CLOSED"] != "true" || open || c.SetupGate.NativeLock != NativeLockApplied {
|
|
t.Fatalf("after: env=%q open=%v native=%q", c.Env["SIGNUP_CLOSED"], open, c.SetupGate.NativeLock)
|
|
}
|
|
}
|