R-717: after_setup open_command — the family window reopens an app's DB-held sign-up switch
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
This commit is contained in:
@@ -46,6 +46,9 @@ type AfterInstallRecord struct {
|
||||
At string `yaml:"at" json:"at"`
|
||||
OK bool `yaml:"ok" json:"ok"`
|
||||
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"`
|
||||
// Step (R-717, after_setup only): "open" = the household's window could not open the app's own switch (it was
|
||||
// closed again); "" = a close.
|
||||
Step string `yaml:"step,omitempty" json:"step,omitempty"`
|
||||
}
|
||||
|
||||
// afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done.
|
||||
|
||||
@@ -29,6 +29,26 @@ import (
|
||||
// restarts.
|
||||
//
|
||||
// A command form (`service`, `command`, `success`, `env` names) follows after_install's argv-safe rules (R-713).
|
||||
//
|
||||
// R-717: an app whose switch lives in its own database (opengist, wishlist) is closed by `command` and reopened for
|
||||
// the household's window by its twin:
|
||||
//
|
||||
// after_setup:
|
||||
// service: app # shared by both commands, as is `user` and `args_env`
|
||||
// command: [...] # CLOSES the switch; `success` must appear in its output
|
||||
// success: "closed"
|
||||
// open_command: [...] # OPENS it for the 15-minute window; `open_success` must appear in its output
|
||||
// open_success: "opened"
|
||||
//
|
||||
// Where each runs: the window (OpenSignupWindow → liftNativeLock) runs `open_command` after the env lift; the loop
|
||||
// (reconcileSignupBlocks, every 20 s and once at controller start) runs `command` again for every app whose state is
|
||||
// not "applied" once no window runs — after the window, after a box restart, and after an app update
|
||||
// (markNativeLockForReapply in verifyAndConclude). Fail closed: the state is marked "opening" BEFORE anything opens,
|
||||
// so a crash mid-open leaves a state the loop closes; an `open_command` that fails closes the switch again at once and
|
||||
// records `after_setup: {ok: false, step: open}` (the app page says the switch could not be opened); a close that fails
|
||||
// keeps the state not-"applied", logs ERROR and is retried by the loop every nativeLockOpenRetry. A template with
|
||||
// `command` but no `open_command` keeps today's window (env lift only) and logs once that the app's own switch cannot
|
||||
// be reopened.
|
||||
// Pinned by internal/stacks/after_setup_test.go.
|
||||
|
||||
// AfterSetupSpec is `.felhom.yml`'s `after_setup:`.
|
||||
@@ -39,14 +59,23 @@ type AfterSetupSpec struct {
|
||||
Args []string `yaml:"args_env,omitempty" json:"args_env,omitempty"` // deploy values a command may use
|
||||
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
Success string `yaml:"success,omitempty" json:"success,omitempty"`
|
||||
// OpenCommand / OpenSuccess (R-717): the twin that opens the switch `command` closes, for the household's window.
|
||||
OpenCommand []string `yaml:"open_command,omitempty" json:"open_command,omitempty"`
|
||||
OpenSuccess string `yaml:"open_success,omitempty" json:"open_success,omitempty"`
|
||||
}
|
||||
|
||||
// Native-lock states in SetupGateRecord.NativeLock.
|
||||
const (
|
||||
NativeLockApplied = "applied" // the app's own switch says closed
|
||||
NativeLockLifted = "lifted" // the household's window: the switch is open again
|
||||
// NativeLockOpening (R-717) is written BEFORE the window opens anything: a crash between the opening and its
|
||||
// record leaves a state that is not "applied", so the loop closes it once the window has passed.
|
||||
NativeLockOpening = "opening"
|
||||
)
|
||||
|
||||
// afterSetupStepOpen marks an AfterInstallRecord written by a failed OPEN (the window), not a failed close.
|
||||
const afterSetupStepOpen = "open"
|
||||
|
||||
// afterSetupUp starts the app from its stored app.yaml (a seam: tests never reach Docker).
|
||||
func (m *Manager) afterSetupUp(name string) error {
|
||||
if m.afterSetupUpFn != nil {
|
||||
@@ -79,73 +108,158 @@ func (m *Manager) setNativeEnv(name, dir string, spec *AfterSetupSpec, lock bool
|
||||
}
|
||||
|
||||
// applyNativeLock sets (lock=true) or lifts the app's own switch, then starts the app once when anything changed.
|
||||
// Records the outcome. Safe to call again: an unchanged env starts nothing.
|
||||
// Records the outcome. Safe to call again: an unchanged env starts nothing. A lift goes to liftNativeLock (R-717).
|
||||
func (m *Manager) applyNativeLock(name string, lock bool, why string) error {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || st.Meta.AfterSetup == nil {
|
||||
return nil
|
||||
}
|
||||
if !lock {
|
||||
return m.liftNativeLock(name, st, why)
|
||||
}
|
||||
spec := st.Meta.AfterSetup
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
record := func(ok bool, detail string) {
|
||||
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)}
|
||||
state := NativeLockApplied
|
||||
if !lock {
|
||||
state = NativeLockLifted
|
||||
}
|
||||
m.mutateAppConfig(name, dir, "after_setup", func(cfg *AppConfig) bool {
|
||||
cfg.AfterSetup = rec
|
||||
if ok && cfg.SetupGate != nil {
|
||||
cfg.SetupGate.NativeLock = state
|
||||
}
|
||||
return true
|
||||
})
|
||||
priorState := ""
|
||||
if st.AppConfig != nil && st.AppConfig.SetupGate != nil {
|
||||
priorState = st.AppConfig.SetupGate.NativeLock
|
||||
}
|
||||
var err error
|
||||
// The switch works only if the app's INSTALLED compose reads the variable. An app installed before the template
|
||||
// wired it (decision 49's apps) carries the old compose until its next update: say so, never report a lock that
|
||||
// is not there. The address block still holds.
|
||||
if len(spec.Env) > 0 && lock {
|
||||
if len(spec.Env) > 0 {
|
||||
if miss := composeMissingVars(st.ComposePath, spec.Env); len(miss) > 0 {
|
||||
err = fmt.Errorf("the installed version of the app does not read %v yet — its own switch applies after its next update", miss)
|
||||
m.logger.Printf("[WARN] [stacks] %s: %v", name, err)
|
||||
record(false, err.Error())
|
||||
m.recordNativeLock(name, dir, false, err.Error(), "", "")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(spec.Env) > 0 {
|
||||
if m.setNativeEnv(name, dir, spec, lock) {
|
||||
if m.setNativeEnv(name, dir, spec, true) {
|
||||
err = m.afterSetupUp(name)
|
||||
}
|
||||
}
|
||||
if err == nil && lock && len(spec.Command) > 0 {
|
||||
err = m.runAfterSetupCommand(name, dir, spec)
|
||||
if err == nil && len(spec.Command) > 0 {
|
||||
err = m.runAfterSetupCommand(name, dir, spec, spec.Command, spec.Success, "command")
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could not be %s (%s): %v — the address block still holds", name, map[bool]string{true: "closed", false: "opened"}[lock], why, err)
|
||||
record(false, err.Error())
|
||||
if priorState == NativeLockLifted || priorState == NativeLockOpening {
|
||||
// R-717: the household's window was (or may have been) opened — the app's own sign-up may still be OPEN.
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could NOT be closed again (%s): %v — sign-up inside the app may still be OPEN past the household's window; the address block holds and the loop retries every %s", name, why, err, nativeLockOpenRetry)
|
||||
} else {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could not be closed (%s): %v — the address block still holds", name, why, err)
|
||||
}
|
||||
m.recordNativeLock(name, dir, false, err.Error(), "", "")
|
||||
return err
|
||||
}
|
||||
keys := make([]string, 0, len(spec.Env))
|
||||
for k := range spec.Env {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch %s (%s; env %v)", name, map[bool]string{true: "CLOSED", false: "opened for the household's window"}[lock], why, keys)
|
||||
record(true, "")
|
||||
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch CLOSED (%s; env %v, command %v)", name, why, sortedEnvKeys(spec.Env), len(spec.Command) > 0)
|
||||
m.recordNativeLock(name, dir, true, "", "", NativeLockApplied)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAfterSetupCommand runs the command form once, with after_install's argv-safe expansion and success marker.
|
||||
func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec) error {
|
||||
if spec.Service == "" || spec.Success == "" {
|
||||
return fmt.Errorf("after_setup command needs a service and a success marker")
|
||||
// liftNativeLock is the household's window (R-717): the env keys go (one start) and the template's open_command runs.
|
||||
// Fail closed: the state says "opening" before anything opens; an open that fails closes the switch again at once and
|
||||
// records the failure with step "open" (the app page says it could not be opened). Only a full success records
|
||||
// "lifted", which the loop closes again once the window has passed.
|
||||
func (m *Manager) liftNativeLock(name string, st *Stack, why string) error {
|
||||
spec := st.Meta.AfterSetup
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
if len(spec.Command) > 0 && len(spec.OpenCommand) == 0 {
|
||||
if _, seen := m.nativeOpenMissingLogged.LoadOrStore(name, true); !seen {
|
||||
m.logger.Printf("[WARN] [stacks] %s: the template closes the app's own sign-up switch with a command but has no open_command — the household's window cannot reopen it (only the address block opens)", name)
|
||||
}
|
||||
}
|
||||
if len(spec.Env) == 0 && len(spec.OpenCommand) == 0 {
|
||||
return nil // nothing of the app's own to open
|
||||
}
|
||||
m.mutateAppConfig(name, dir, "after_setup_opening", func(cfg *AppConfig) bool {
|
||||
if cfg.SetupGate == nil {
|
||||
return false
|
||||
}
|
||||
cfg.SetupGate.NativeLock = NativeLockOpening
|
||||
return true
|
||||
})
|
||||
if c := LoadAppConfig(dir); c == nil || c.SetupGate == nil || c.SetupGate.NativeLock != NativeLockOpening {
|
||||
err := fmt.Errorf("the record could not be marked before opening — the app's own switch stays closed")
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch was NOT opened (%s): %v", name, why, err)
|
||||
m.recordNativeLock(name, dir, false, err.Error(), afterSetupStepOpen, "")
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
if len(spec.Env) > 0 && m.setNativeEnv(name, dir, spec, false) {
|
||||
err = m.afterSetupUp(name)
|
||||
}
|
||||
if err == nil && len(spec.OpenCommand) > 0 {
|
||||
err = m.runAfterSetupCommand(name, dir, spec, spec.OpenCommand, spec.OpenSuccess, "open_command")
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could NOT be opened (%s): %v — closing it again; a new family member cannot sign up in the app itself", name, why, err)
|
||||
detail := "could not be opened for the household's window: " + err.Error()
|
||||
if cerr := m.applyNativeLock(name, true, "an opening that failed"); cerr != nil {
|
||||
detail += "; closing it again failed too (the loop retries): " + cerr.Error()
|
||||
}
|
||||
m.recordNativeLock(name, dir, false, detail, afterSetupStepOpen, "")
|
||||
return err
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch opened for the household's window (%s; env %v, open_command %v)", name, why, sortedEnvKeys(spec.Env), len(spec.OpenCommand) > 0)
|
||||
m.recordNativeLock(name, dir, true, "", "", NativeLockLifted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordNativeLock writes the after_setup record; state != "" also moves SetupGate.NativeLock.
|
||||
func (m *Manager) recordNativeLock(name, dir string, ok bool, detail, step, state string) {
|
||||
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300), Step: step}
|
||||
m.mutateAppConfig(name, dir, "after_setup", func(cfg *AppConfig) bool {
|
||||
cfg.AfterSetup = rec
|
||||
if state != "" && cfg.SetupGate != nil {
|
||||
cfg.SetupGate.NativeLock = state
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// markNativeLockForReapply (R-717) makes the loop run the close again after an app update: an update may bring a
|
||||
// database whose switch is not the one the box set. NativeLock "" = "run once the app is up" (as after a restore).
|
||||
func (m *Manager) markNativeLockForReapply(name, dir string) {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || st.Meta.AfterSetup == nil {
|
||||
return
|
||||
}
|
||||
marked := false
|
||||
m.mutateAppConfig(name, dir, "after_setup_reapply", func(cfg *AppConfig) bool {
|
||||
if cfg.SetupGate == nil || cfg.SetupGate.State != SetupGateOpen || cfg.SetupGate.NativeLock != NativeLockApplied {
|
||||
return false // never set, or not applied: the loop already runs it
|
||||
}
|
||||
cfg.SetupGate.NativeLock = ""
|
||||
marked = true
|
||||
return true
|
||||
})
|
||||
if marked {
|
||||
m.logger.Printf("[INFO] [stacks] %s: after the update the app's own sign-up switch is closed again by the loop", name)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedEnvKeys(mp map[string]string) []string {
|
||||
keys := make([]string, 0, len(mp))
|
||||
for k := range mp {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// runAfterSetupCommand runs one command of the command form (`command` or `open_command`) once, with after_install's
|
||||
// argv-safe expansion and its success marker.
|
||||
func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec, command []string, success, field string) error {
|
||||
if spec.Service == "" || success == "" {
|
||||
return fmt.Errorf("after_setup %s needs a service and a success marker", field)
|
||||
}
|
||||
cfg := LoadAppConfigDecrypted(dir, m.encKey)
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("app.yaml unreadable")
|
||||
}
|
||||
cmd, err := expandAfterInstall(spec.Command, spec.Args, cfg.Env)
|
||||
cmd, err := expandAfterInstall(command, spec.Args, cfg.Env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,8 +270,8 @@ func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec) e
|
||||
args = append(args, spec.Service)
|
||||
args = append(args, cmd...)
|
||||
out, err := m.runInService(dir, args...)
|
||||
if err != nil || !strings.Contains(out, spec.Success) {
|
||||
return fmt.Errorf("after_setup command did not report %q (err %v)", spec.Success, err)
|
||||
if err != nil || !strings.Contains(out, success) {
|
||||
return fmt.Errorf("after_setup %s did not report %q (err %v)", field, success, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,8 @@ type Manager struct {
|
||||
// nativeLockBusy: one after_setup run per app at a time (the loop, the window and a press can meet).
|
||||
nativeLockBusy sync.Map
|
||||
afterSetupSync bool // tests: run after_setup in the caller
|
||||
// nativeOpenMissingLogged: apps already logged as "closed by a command, no open_command" (R-717, once each).
|
||||
nativeOpenMissingLogged sync.Map
|
||||
|
||||
// --- guarded update (slice 4, update.go) ---
|
||||
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
|
||||
@@ -1237,7 +1239,7 @@ func deepCopyStack(s *Stack) Stack {
|
||||
if s.Meta.AfterSetup != nil {
|
||||
as := *s.Meta.AfterSetup
|
||||
as.Env = cloneStrMap(as.Env)
|
||||
as.Args, as.Command = cloneStrs(as.Args), cloneStrs(as.Command)
|
||||
as.Args, as.Command, as.OpenCommand = cloneStrs(as.Args), cloneStrs(as.Command), cloneStrs(as.OpenCommand)
|
||||
cp.Meta.AfterSetup = &as
|
||||
}
|
||||
if s.Meta.SetupDoneProbe != nil {
|
||||
|
||||
@@ -134,7 +134,9 @@ func (m *Manager) OpenSignupWindow(name string) (string, error) {
|
||||
if err := m.removeSignupBlockFile(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if st.Meta.AfterSetup != nil && len(st.Meta.AfterSetup.Env) > 0 { // v0.282.0: the app's own switch opens too (a restart)
|
||||
// v0.282.0: the app's own switch opens too (env: a restart). R-717: and the template's open_command; a template
|
||||
// that closes by command without one is logged once by liftNativeLock.
|
||||
if as := st.Meta.AfterSetup; as != nil && (len(as.Env) > 0 || len(as.OpenCommand) > 0 || len(as.Command) > 0) {
|
||||
m.goNativeLock(name, false, "the household's window")
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: the household opened sign-up until %s — the loop closes it again", name, until)
|
||||
@@ -188,7 +190,12 @@ func (m *Manager) reconcileSignupBlocks() {
|
||||
last := st.AppConfig.AfterSetup
|
||||
due := last == nil || last.OK
|
||||
if !due {
|
||||
if t, err := time.Parse(time.RFC3339, last.At); err != nil || m.now().Sub(t) > nativeLockRetry {
|
||||
// R-717: a switch the window opened (or may have) is retried sooner — sign-up may be open in the app.
|
||||
gap := nativeLockRetry
|
||||
if s := st.AppConfig.SetupGate.NativeLock; s == NativeLockLifted || s == NativeLockOpening {
|
||||
gap = nativeLockOpenRetry
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, last.At); err != nil || m.now().Sub(t) > gap {
|
||||
due = true
|
||||
}
|
||||
}
|
||||
@@ -202,6 +209,9 @@ func (m *Manager) reconcileSignupBlocks() {
|
||||
// nativeLockRetry: how often the loop retries an app's own switch that could not be set.
|
||||
var nativeLockRetry = 30 * time.Minute
|
||||
|
||||
// nativeLockOpenRetry (R-717): how often the loop retries closing a switch the household's window opened.
|
||||
var nativeLockOpenRetry = 2 * time.Minute
|
||||
|
||||
// SetupGateProbe asks the app's own "setup done" status once (the household's button asks it first, Part A of the
|
||||
// 2026-09-29 afternoon brief). has=false: the template declares no probe.
|
||||
func (m *Manager) SetupGateProbe(name string) (has bool, done bool, got string, err error) {
|
||||
|
||||
@@ -1031,6 +1031,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
|
||||
m.clearFailedStep(name, dir) // R-680: and the failed-step record
|
||||
m.clearJournal(name)
|
||||
m.removePreUpdateCopies(dir)
|
||||
m.markNativeLockForReapply(name, dir) // R-717: the loop closes the app's own sign-up switch again after an update
|
||||
// R-678 (v0.271.0): the app's catalog fields — ladder_steps_left, the badge's inputs, the pin — are
|
||||
// re-read NOW, before Updating goes false, so neither a person nor the automatic leg ever reads the
|
||||
// pre-update values after `done`. MEASURED 2026-09-24: ~50 s stale, six re-presses by the caller.
|
||||
|
||||
Reference in New Issue
Block a user