R-773: a removed app restored from its backup gets its sign-up lock back (record opened_by restore + block, before anything starts)
gates / gates (push) Successful in 29s
gates / gates (push) Successful in 29s
An installed app the household never closed keeps what it had (decision 49). Red-proof RP-D2. 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:
@@ -229,6 +229,31 @@ func (m *Manager) CloseSignupNow(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreSignupLock is R-773's half of PersistUnitRedeployConfig: for an app whose template locks sign-up, the lock
|
||||
// record (opened_by "restore", never a closed gate) and — best effort — its block file. nil, nil when the template has
|
||||
// no lock. A block that cannot be written now is logged and left to the loop (the record says it is wanted); hosts that
|
||||
// cannot be read refuse the restore before anything starts (fail closed, as the gate's own install does).
|
||||
// The app's own switch (`after_setup`) is the loop's: NativeLock is "" so it runs once the app is up.
|
||||
func (m *Manager) restoreSignupLock(name, composePath string, env map[string]string, meta *Metadata) (*SetupGateRecord, error) {
|
||||
block := strings.TrimSpace(meta.SignupBlock)
|
||||
if block == "" && meta.AfterSetup == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rs, err := gateRoutersFromCompose(composePath, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: the app's addresses could not be read: %w", name, err)
|
||||
}
|
||||
hosts := gateHosts(rs)
|
||||
if block != "" {
|
||||
if err := m.writeSignupBlock(name, hosts, block); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the restore's sign-up block could not be written: %v — the loop retries; sign-up is OPEN until it is", name, err)
|
||||
}
|
||||
}
|
||||
now := m.now().UTC().Format(time.RFC3339)
|
||||
m.logger.Printf("[INFO] [stacks] %s: restored after a removal — sign-up closed again as after its setup (hosts %v)", name, hosts)
|
||||
return &SetupGateRecord{State: SetupGateOpen, Since: now, Hosts: hosts, OpenedAt: now, OpenedBy: SetupGateByRestore}, nil
|
||||
}
|
||||
|
||||
// goNativeLock runs applyNativeLock in the background, one at a time per app (a press, the window and the loop
|
||||
// can meet). The seam afterSetupSync makes it synchronous for tests.
|
||||
func (m *Manager) goNativeLock(name string, lock bool, why string) {
|
||||
|
||||
@@ -764,7 +764,21 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
}
|
||||
}
|
||||
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
|
||||
carryLifeRecords(m.logger, name, LoadAppConfig(stackDir), cfg)
|
||||
priorRaw := LoadAppConfig(stackDir)
|
||||
carryLifeRecords(m.logger, name, priorRaw, cfg)
|
||||
// R-773: a REMOVED app has no app.yaml left, so nothing carries its sign-up lock (decision 47) and the restore
|
||||
// used to bring it back with sign-up open (measured on 9202: Karakeep's /signup 403 before, 200 after remove +
|
||||
// restore). The restored data comes back with its admin, so the restore applies what a fresh install gets after
|
||||
// its setup: the lock record and its block, written HERE — before anything starts. An app that was never removed
|
||||
// keeps exactly the record it had (carryLifeRecords): a restore never adds a lock to an installed app that the
|
||||
// household has not closed (decision 49). Pinned by TestR773_*.
|
||||
if priorRaw == nil && cfg.SetupGate == nil {
|
||||
rec, err := m.restoreSignupLock(name, stack.ComposePath, env, &meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err)
|
||||
}
|
||||
cfg.SetupGate = rec
|
||||
}
|
||||
if len(cfg.RestoredLogins) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] %s: the restore generated %v — the app's own login came back with its data; the page will not show the new value as the password", name, cfg.RestoredLogins)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-773 — after a REMOVE + restore, an app's sign-up block (decision 47) was gone: the removed app had no app.yaml, so
|
||||
// nothing carried the lock record, and the restore brought sign-up back open (measured 2026-10-01 on 9202, Karakeep:
|
||||
// /signup 403 before, 200 after). Through the PRODUCTION restore write (PersistUnitRedeployConfig).
|
||||
|
||||
// The removed app comes back with the lock record AND its block — written before anything starts.
|
||||
// COMPANION RED-PROOF: drop the restoreSignupLock call in PersistUnitRedeployConfig → both assertions fail.
|
||||
func TestR773_ARemovedAppComesBackWithSignupClosed(t *testing.T) {
|
||||
m := gateManager(t, signupYml)
|
||||
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
|
||||
if LoadAppConfig(dir) != nil {
|
||||
t.Fatal("precondition: the removed app has no app.yaml")
|
||||
}
|
||||
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
|
||||
got := LoadAppConfig(dir)
|
||||
if got == nil || got.SetupGate == nil || got.SetupGate.State != SetupGateOpen || got.SetupGate.OpenedBy != SetupGateByRestore {
|
||||
t.Fatalf("the restore must record the sign-up lock (an OPEN gate record, by restore), got %+v", got)
|
||||
}
|
||||
b, err := os.ReadFile(m.signupBlockPath("gapp"))
|
||||
if err != nil || !strings.Contains(string(b), "Host(`gapp.example.hu`)") || !strings.Contains(string(b), "PathPrefix(`/signup`)") {
|
||||
t.Fatalf("the block must stand before the app starts: %v\n%s", err, b)
|
||||
}
|
||||
if blocked, _ := m.SignupBlocked("gapp"); !blocked {
|
||||
t.Fatal("SignupBlocked says open after the restore")
|
||||
}
|
||||
// and the loop keeps it (the record says it is wanted)
|
||||
must(t, os.Remove(m.signupBlockPath("gapp")))
|
||||
m.SetupGateTick()
|
||||
if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil {
|
||||
t.Fatal("the loop did not put the restore's block back")
|
||||
}
|
||||
}
|
||||
|
||||
// An app that was NOT removed keeps exactly what it had: a restore never closes sign-up the household left open on an
|
||||
// app installed before decision 47 (decision 49 — the box never applies the lock by itself to an installed app).
|
||||
func TestR773_AnInstalledAppWithoutALockGetsNone(t *testing.T) {
|
||||
m := gateManager(t, signupYml)
|
||||
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
|
||||
must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}}, m.encKey, nil))
|
||||
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
|
||||
if got := LoadAppConfig(dir); got.SetupGate != nil {
|
||||
t.Fatalf("an installed app without a lock must not get one from a restore, got %+v", got.SetupGate)
|
||||
}
|
||||
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("no block may be written for it")
|
||||
}
|
||||
}
|
||||
|
||||
// A template with no sign-up lock gets no record from a restore.
|
||||
func TestR773_NoLockInTheTemplateNoRecord(t *testing.T) {
|
||||
m := gateManager(t, "display_name: Plain\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n")
|
||||
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
|
||||
if got := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")); got.SetupGate != nil {
|
||||
t.Fatalf("no lock in the template → no record, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ const (
|
||||
// Who opened it.
|
||||
SetupGateByProbe = "probe"
|
||||
SetupGateByHousehold = "household"
|
||||
// SetupGateByRestore (R-773): a removed app restored from its backup — sign-up closed again as after its setup.
|
||||
SetupGateByRestore = "restore"
|
||||
)
|
||||
|
||||
// SetupGateRecord is app.yaml's `setup_gate:`.
|
||||
|
||||
Reference in New Issue
Block a user