17863fe8bc
gates / gates (push) Successful in 47s
MinAgent 0.155.0 (unchanged). Tests red-proved by mutation (A: 10, B: 7, C: 1, C2: 2). Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
98 lines
4.7 KiB
Go
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
|
|
}
|