Files
felhom-controller/controller/internal/stacks/restore_gate.go
T
2026-10-15 14:34:33 +02:00

98 lines
4.7 KiB
Go

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
}