package stacks import ( "fmt" "os" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gopkg.in/yaml.v3" ) // R-1057 (v0.314.0, `09` §3 decision 253): a restore puts the setup gate back AS IT WAS WHEN THE BACKUP WAS TAKEN. // // Before: a REMOVED gated app came back with no gate at all — „the data comes back with its admin" (carryLifeRecords: // no prior record = no gate). That assumption fails for a backup made before the household's first setup: measured on // 9202 with Prowlarr (felhom.eu audits/prowlarr-2026-10-15/box/run3/restore.txt) — a stranger's /ping through traefik // answered 200 after the restore, 401 (the gate) before the remove. The first stranger would have made the admin. // // The rule now, for a template with `setup_gate: true`: // - closed at the backup → closed after the restore (the gate file is written BEFORE anything starts, as at install — // decision 46 spike F2), whether the app was removed or not; the probe or „Kész" opens it as at install; // - open at the backup → no gate (the rule's intent, unchanged: the data comes back with its admin); an app that was // re-gated since (a reinstall) is opened again, because the data it gets back has its admin; // - no record (a backup made before v0.314.0) of a REMOVED app → closed; the gate's loop opens it at once if the // app's own probe says the setup is done. Never open by default. An app that was not removed keeps its own record. // Pinned by restore_gate_test.go. // GateAtBackup is what a backup says about the app's setup gate. type GateAtBackup struct { Known bool // the backup carries a record Closed bool // the gate was closed (the first setup not done) OpenedBy string // who opened it, when open } // GateCapture is the record a backup writes for an app: nil for a template with no gate. An app whose template gates // but which has no record was installed before gates existed (v0.280.0) and was never gated — it is recorded OPEN, so // a restore of it is not gated either (as it was). func GateCapture(meta *Metadata, rec *SetupGateRecord) *appbackup.GateState { if meta == nil || !meta.SetupGate { return nil } if rec == nil { return &appbackup.GateState{State: SetupGateOpen, OpenedBy: "never-gated"} } return &appbackup.GateState{State: rec.State, OpenedBy: rec.OpenedBy} } // ReadGateAtBackup reads `setup_gate:` from a unit's app.yaml (or a live one — the same keys). An absent file, an // unreadable file or no key = not known. A state other than open counts as CLOSED (fail closed). func ReadGateAtBackup(appYamlPath string) GateAtBackup { b, err := os.ReadFile(appYamlPath) if err != nil { return GateAtBackup{} } var doc struct { SetupGate *appbackup.GateState `yaml:"setup_gate"` } if yaml.Unmarshal(b, &doc) != nil || doc.SetupGate == nil { return GateAtBackup{} } return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy} } // applyGateAtBackup decides the restored app's gate (R-1057). removed = the app had no app.yaml before this restore. // It returns reopen = true when an app re-gated since the backup must be opened (the caller then applies the sign-up // lock exactly as for a removed app). A gate that must stand is written to disk before this returns; a gate that cannot // be written refuses the restore before anything starts (fail closed, as the install). func (m *Manager) applyGateAtBackup(name, composePath string, env map[string]string, meta *Metadata, cfg *AppConfig, removed bool, gate GateAtBackup) (reopen bool, err error) { if !meta.SetupGate { return false, nil } switch { case gate.Known && gate.Closed, !gate.Known && removed: if cfg.SetupGate.Closed() { _, err := m.writeSetupGate(name, composePath, env) return false, err } rec, err := m.prepareSetupGate(name, composePath, env) if err != nil { return false, fmt.Errorf("the setup gate could not be written (the app was not started): %w", err) } cfg.SetupGate = rec why := "it was closed when the backup was taken" if !gate.Known { why = "the backup carries no gate record (made before v0.314.0); the app's probe or „Kész” opens it" } m.logger.Printf("[INFO] [stacks] %s: restored with its setup gate CLOSED — %s (R-1057)", name, why) return false, nil case gate.Known && !gate.Closed && cfg.SetupGate.Closed(): cfg.SetupGate = nil if err := m.removeSetupGateFile(name); err != nil { m.logger.Printf("[WARN] [stacks] %s: the old setup-gate file could not be removed (the loop retries): %v", name, err) } m.logger.Printf("[INFO] [stacks] %s: the backup was taken after the first setup — the gate is opened for the restored data (R-1057)", name) return true, nil } return false, nil }