diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index ee14e93..3c795f4 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2582,6 +2582,7 @@ "app_info.close_signup_text": "Anyone who finds this app's address can sign up. Close sign-up now — you can still add your family afterwards.", "app_info.close_signup_btn": "Close sign-up now", "app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.", + "app_info.signup_native_open_failed": "The app's own sign-up switch could not be opened for the 15 minutes, so it stays closed. A new family member cannot sign up in the app itself. Try again later.", "app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.", "app_info.restarts_once": "For this the app restarts once and is briefly unavailable meanwhile.", "err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 600bc22..b62f223 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2570,6 +2570,7 @@ "app_info.close_signup_text": "Ennél az alkalmazásnál bárki regisztrálhat, aki megtalálja a címét. Zárd le a regisztrációt most — a családtagjaidat utána is fel tudod venni.", "app_info.close_signup_btn": "Regisztráció lezárása most", "app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.", + "app_info.signup_native_open_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült megnyitni a 15 percre, ezért zárva maradt. Új családtag így az alkalmazásban nem tud regisztrálni. Próbáld újra később.", "app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.", "app_info.restarts_once": "Ehhez az alkalmazás egyszer újraindul, és közben rövid ideig nem érhető el.", "err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.", diff --git a/controller/internal/stacks/after_install.go b/controller/internal/stacks/after_install.go index 553a1f9..5c7cac6 100644 --- a/controller/internal/stacks/after_install.go +++ b/controller/internal/stacks/after_install.go @@ -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. diff --git a/controller/internal/stacks/after_setup.go b/controller/internal/stacks/after_setup.go index 6912194..5921ba0 100644 --- a/controller/internal/stacks/after_setup.go +++ b/controller/internal/stacks/after_setup.go @@ -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 } diff --git a/controller/internal/stacks/after_setup_r717_test.go b/controller/internal/stacks/after_setup_r717_test.go new file mode 100644 index 0000000..02eabad --- /dev/null +++ b/controller/internal/stacks/after_setup_r717_test.go @@ -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) + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index b223c6d..11e3f8d 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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 { diff --git a/controller/internal/stacks/signup_block.go b/controller/internal/stacks/signup_block.go index 5818d28..65c6fd7 100644 --- a/controller/internal/stacks/signup_block.go +++ b/controller/internal/stacks/signup_block.go @@ -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) { diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index b8a5b12..9b784f8 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -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. diff --git a/controller/internal/web/close_signup_test.go b/controller/internal/web/close_signup_test.go index e051594..ed12bba 100644 --- a/controller/internal/web/close_signup_test.go +++ b/controller/internal/web/close_signup_test.go @@ -21,6 +21,11 @@ func TestCloseSignupPage_TheCardAndItsPress(t *testing.T) { if !strings.Contains(native, "újraindul") || !strings.Contains(native, `id="signup-native-failed"`) { t.Fatal("the window's restart line or the failed-switch line is missing") } + // R-717: a failed OPEN says so in its own words — never "could not be closed". + openFailed := renderAppInfoWith(t, map[string]interface{}{"SignupClosed": true, "SignupNativeOpenFailed": true}) + if !strings.Contains(openFailed, `id="signup-native-open-failed"`) || !strings.Contains(openFailed, "megnyitni") || strings.Contains(openFailed, `id="signup-native-failed"`) { + t.Fatal("the failed-open line is missing, or the failed-close line shows instead") + } } // COMPANION RED-PROOF: skip the CloseSignupNow error mapping → the gated app answers 500, not 409. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7f064c1..c545ccd 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -783,7 +783,11 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["CloseSignupOffered"] = s.stackMgr.CloseSignupOffered(found.Name) } data["SignupNative"] = found.Meta.AfterSetup != nil && len(found.Meta.AfterSetup.Env) > 0 - data["SignupNativeFailed"] = found.AppConfig != nil && found.AppConfig.AfterSetup != nil && !found.AppConfig.AfterSetup.OK + nativeFailed := found.AppConfig != nil && found.AppConfig.AfterSetup != nil && !found.AppConfig.AfterSetup.OK + // R-717: a failed OPEN (the household's window; the switch was closed again) says so in its own words. + nativeOpenFailed := nativeFailed && found.AppConfig.AfterSetup.Step == "open" + data["SignupNativeFailed"] = nativeFailed && !nativeOpenFailed + data["SignupNativeOpenFailed"] = nativeOpenFailed // v0.281.0 (decision 47): the sign-up card — closed, or open for the household's 15 minutes. if s.stackMgr != nil { blocked, until := s.stackMgr.SignupBlocked(found.Name) diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 63652c3..43bd4ae 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -408,6 +408,10 @@ func i18nCases() []i18nCase { d := signupCase("x", true, "").data() d["SignupNative"], d["SignupNativeFailed"] = true, true return d + }}, i18nCase{"app_info_signup_native_open_failed", "app_info", func() map[string]interface{} { + d := signupCase("x", true, "").data() + d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch + return d }}) // R-718: the close card and the gate card say the app restarts once where the app has its own switch. base = append(base, i18nCase{"app_info_close_signup_native", "app_info", func() map[string]interface{} { diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index b213837..71dcf03 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -130,6 +130,9 @@ {{- if .SignupNativeFailed}}

{{T "app_info.signup_native_failed"}}

{{- end}} + {{- if .SignupNativeOpenFailed}} +

{{T "app_info.signup_native_open_failed"}}

+ {{- end}} {{- if .SignupClosed}} {{- if .SignupNative}}

{{T "app_info.signup_window_restart"}}

diff --git a/controller/internal/web/testdata/i18n_parity/app_info_signup_native_open_failed.html b/controller/internal/web/testdata/i18n_parity/app_info_signup_native_open_failed.html new file mode 100644 index 0000000..74981e5 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_signup_native_open_failed.html @@ -0,0 +1,607 @@ + + + + + + + + Opengist — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+
+

Regisztráció

+

Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.

+

Családtagot így adhatsz hozzá: Admin panel → Users.

+

Az alkalmazás saját regisztrációs kapcsolóját nem sikerült megnyitni a 15 percre, ezért zárva maradt. Új családtag így az alkalmazásban nem tud regisztrálni. Próbáld újra később.

+ + +
+ + + + + + + + + + +
+ + + + +