v0.314.0: a restore puts the setup gate back as it was (R-1057); a test build moves with the floor (R-1053); Prowlarr links itself (R-1058); the box claims Plex by address (R-1060)
gates / gates (push) Successful in 47s
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
This commit is contained in:
@@ -781,6 +781,12 @@ func (m *Manager) upFromAppConfig(name string) error {
|
||||
// RedeployFromEnv itself is this function plus the unchanged up-and-report tail, so its public
|
||||
// behaviour is identical to before the split.
|
||||
func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string) error {
|
||||
return m.PersistUnitRedeployConfigGate(name, env, GateAtBackup{})
|
||||
}
|
||||
|
||||
// PersistUnitRedeployConfigGate is PersistUnitRedeployConfig with the setup gate the backup recorded (R-1057,
|
||||
// restore_gate.go): the restore puts the gate back as it was when the backup was taken.
|
||||
func (m *Manager) PersistUnitRedeployConfigGate(name string, env map[string]string, gate GateAtBackup) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
@@ -804,6 +810,10 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
|
||||
priorRaw := LoadAppConfig(stackDir)
|
||||
carryLifeRecords(m.logger, name, priorRaw, cfg)
|
||||
reopen, err := m.applyGateAtBackup(name, stack.ComposePath, env, &meta, cfg, priorRaw == nil, gate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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
|
||||
@@ -818,7 +828,7 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
}
|
||||
cfg.FamilyGate = rec
|
||||
}
|
||||
if priorRaw == nil && cfg.SetupGate == nil {
|
||||
if (priorRaw == nil || reopen) && 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)
|
||||
|
||||
@@ -1690,13 +1690,23 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
|
||||
// R-1024: a media app that came up (installed, started, restarted) gets the drives wired in (idempotent; the web
|
||||
// side debounces). Never for a stack without the marker.
|
||||
if len(args) > 0 && args[0] == "up" && m.onMediaUp != nil {
|
||||
if meta := LoadMetadata(dir); meta.MediaLibrary != nil {
|
||||
if wiresOnUp(filepath.Base(dir), LoadMetadata(dir)) {
|
||||
m.onMediaUp(filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// linkOnlyApps (v0.314.0, R-1058) are apps the wiring LINKS but that use no drive, so they carry no `media_library`
|
||||
// marker (the marker binds every drive into the app). Their own start runs the wiring too: Prowlarr installed AFTER
|
||||
// Sonarr/Radarr used to wait for „Bekötés most" (measured on demo-hp 9201, felhom.eu audits/prowlarr-2026-10-15/box9201/).
|
||||
var linkOnlyApps = map[string]bool{"prowlarr": true}
|
||||
|
||||
// wiresOnUp says whether an app's successful `compose up` runs the media wiring. Pinned by TestR1058_*.
|
||||
func wiresOnUp(name string, meta Metadata) bool {
|
||||
return meta.MediaLibrary != nil || linkOnlyApps[name]
|
||||
}
|
||||
|
||||
// SetOnMediaUp registers the callback run after a media app's successful `compose up` (main.go: the wiring).
|
||||
func (m *Manager) SetOnMediaUp(f func(name string)) { m.onMediaUp = f }
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1058 (v0.314.0) — Prowlarr's own start runs the media wiring, so installed AFTER Sonarr/Radarr it is linked with
|
||||
// no button. Through the PRODUCTION compose call (composeExecCustomEnv) with the docker stand-in on PATH (R-650).
|
||||
// COMPANION RED-PROOF: empty linkOnlyApps → „prowlarr" is missing from the calls and this fails.
|
||||
func TestR1058_ProwlarrUpRunsTheWiring(t *testing.T) {
|
||||
m := gateManager(t, "display_name: x\n")
|
||||
var calls []string
|
||||
m.SetOnMediaUp(func(name string) { calls = append(calls, name) })
|
||||
mk := func(name, yml string) string {
|
||||
d := filepath.Join(m.cfg.Paths.StacksDir, name)
|
||||
must(t, os.MkdirAll(d, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte("services: {}\n"), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(d, ".felhom.yml"), []byte(yml), 0o644))
|
||||
return d
|
||||
}
|
||||
prowlarr := mk("prowlarr", "display_name: Prowlarr\nsetup_gate: true\n")
|
||||
sonarr := mk("sonarr", "display_name: Sonarr\nmedia_library:\n kinds: [tv]\n")
|
||||
plain := mk("vaultwarden", "display_name: Vaultwarden\n")
|
||||
for _, d := range []string{prowlarr, sonarr, plain} {
|
||||
if _, err := m.composeExecCustomEnv(d, nil, "up", "-d"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := m.composeExecCustomEnv(prowlarr, nil, "ps"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"prowlarr", "sonarr"}
|
||||
if len(calls) != len(want) || calls[0] != want[0] || calls[1] != want[1] {
|
||||
t.Fatalf("wiring runs = %v, want %v (Prowlarr's up, Sonarr's up; never another app, never a non-up command)", calls, want)
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,17 @@ import (
|
||||
// /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.
|
||||
// v0.314.0 (R-1057): this is the case of a backup taken AFTER the first setup (its gate record says open). A backup with
|
||||
// no record now comes back with a CLOSED gate (restore_gate_test.go), so the open case is named explicitly here.
|
||||
// COMPANION RED-PROOF: drop the restoreSignupLock call in PersistUnitRedeployConfigGate → 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"}))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"},
|
||||
GateAtBackup{Known: true, Closed: false, OpenedBy: SetupGateByHousehold}))
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1057 (v0.314.0) — a restore puts the setup gate back as it was when the backup was taken. Through the PRODUCTION
|
||||
// restore write (PersistUnitRedeployConfigGate) and the gate's own loop (SetupGateTick).
|
||||
|
||||
var gateEnv = map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
|
||||
|
||||
func gappDir(m *Manager) string { return filepath.Join(m.cfg.Paths.StacksDir, "gapp") }
|
||||
|
||||
func gateFileExists(m *Manager) bool {
|
||||
_, err := os.Stat(m.setupGatePath("gapp"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// markRunning sets the restored app running in memory, as the restore's start would (the loop probes only a running app).
|
||||
func markRunning(m *Manager) {
|
||||
m.mu.Lock()
|
||||
m.stacks["gapp"].State = StateRunning
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Closed at the backup → closed after a REMOVE + restore: the record, and the gate file before anything starts.
|
||||
// COMPANION RED-PROOF: return early from applyGateAtBackup (mutation „no gate on restore") → this fails with no record.
|
||||
func TestR1057_ClosedAtTheBackupComesBackClosed(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
if LoadAppConfig(gappDir(m)) != nil {
|
||||
t.Fatal("precondition: the removed app has no app.yaml")
|
||||
}
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true}))
|
||||
got := LoadAppConfig(gappDir(m))
|
||||
if got == nil || !got.SetupGate.Closed() {
|
||||
t.Fatalf("closed at the backup must come back closed, got %+v", got)
|
||||
}
|
||||
if !gateFileExists(m) {
|
||||
t.Fatal("the gate's traefik file is not there — a stranger reaches the app")
|
||||
}
|
||||
}
|
||||
|
||||
// Open at the backup → no closed gate (the data came back with its admin; the rule's intent, unchanged).
|
||||
// COMPANION RED-PROOF: treat every known record as closed in applyGateAtBackup → this fails.
|
||||
func TestR1057_OpenAtTheBackupComesBackOpen(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: false, OpenedBy: SetupGateByHousehold}))
|
||||
got := LoadAppConfig(gappDir(m))
|
||||
if got.SetupGate.Closed() || gateFileExists(m) {
|
||||
t.Fatalf("open at the backup must come back open, got %+v (file %v)", got.SetupGate, gateFileExists(m))
|
||||
}
|
||||
}
|
||||
|
||||
// No record (a backup made before v0.314.0) + a probe that says „not done" → closed, and the loop keeps it closed.
|
||||
// COMPANION RED-PROOF: drop the `!gate.Known && removed` case → the app comes back open and this fails.
|
||||
func TestR1057_NoRecordProbeNotDoneStaysClosed(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
t.Cleanup(SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(`{"data":{"initialized":false}}`), nil }))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
|
||||
markRunning(m)
|
||||
m.SetupGateTick()
|
||||
if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) {
|
||||
t.Fatalf("no record + not set up must stay closed, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// No record + a probe that says „done" → closed at the restore, opened by the loop (the data had its admin).
|
||||
// COMPANION RED-PROOF: skip the probe in SetupGateTick → the gate never opens and this fails.
|
||||
func TestR1057_NoRecordProbeDoneOpensByTheLoop(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
t.Cleanup(SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(`{"data":{"initialized":true}}`), nil }))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
|
||||
if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() {
|
||||
t.Fatalf("never open by default: closed until the probe has spoken, got %+v", got.SetupGate)
|
||||
}
|
||||
markRunning(m)
|
||||
m.SetupGateTick()
|
||||
got := LoadAppConfig(gappDir(m))
|
||||
if got.SetupGate.Closed() || got.SetupGate.OpenedBy != SetupGateByProbe || gateFileExists(m) {
|
||||
t.Fatalf("the probe says done — the loop must open it, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// No record + no probe (Plex's shape: setup_gate, no probe) → closed until the household's „Kész".
|
||||
// COMPANION RED-PROOF: as TestR1057_NoRecordProbeNotDoneStaysClosed.
|
||||
func TestR1057_NoRecordNoProbeStaysClosed(t *testing.T) {
|
||||
m := gateManager(t, plexShapeYml)
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
|
||||
markRunning(m)
|
||||
m.SetupGateTick()
|
||||
if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) {
|
||||
t.Fatalf("no probe: only the household opens it, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// Plex's own gate lines (app-catalog-felhom.eu templates/plex/.felhom.yml, 2026-10-15): gated, no probe — the
|
||||
// controller asks Plex whether it is claimed when the household presses „Kész".
|
||||
const plexShapeYml = "display_name: Plex\nsetup_gate: true\n" +
|
||||
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: plex\n"
|
||||
|
||||
// The gate file exists BEFORE the restored app's first start (decision 46 spike F2), through the real write + the real
|
||||
// compose call: the docker stand-in on PATH (R-650's seam) records, for every `compose up`, whether the file stood.
|
||||
// COMPANION RED-PROOF: drop prepareSetupGate from applyGateAtBackup's closed case → „no" is recorded and this fails.
|
||||
func TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
mark := filepath.Join(t.TempDir(), "up.log")
|
||||
stub := "#!/bin/sh\ncase \"$*\" in *\" up \"*|*\" up\") if [ -f '" + m.setupGatePath("gapp") + "' ]; then echo yes >> '" + mark +
|
||||
"'; else echo no >> '" + mark + "'; fi;; esac\nexit 0\n"
|
||||
bin := filepath.SplitList(os.Getenv("PATH"))[0]
|
||||
must(t, os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true}))
|
||||
_ = m.upFromAppConfig("gapp")
|
||||
b, _ := os.ReadFile(mark)
|
||||
if string(b) != "yes\n" {
|
||||
t.Fatalf("compose up saw the gate file: %q — the restored app started before its gate stood", b)
|
||||
}
|
||||
}
|
||||
|
||||
// An app NOT removed whose backup was closed (restored over a set-up install) is gated again: the data it gets back
|
||||
// has no admin. COMPANION RED-PROOF: restrict the closed case to `removed` → this fails.
|
||||
func TestR1057_InPlaceRestoreOfAClosedBackupRegates(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
must(t, SaveAppConfig(gappDir(m), &AppConfig{Deployed: true, Env: gateEnv,
|
||||
SetupGate: &SetupGateRecord{State: SetupGateOpen, OpenedBy: SetupGateByHousehold}}, m.encKey, nil))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true}))
|
||||
if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) {
|
||||
t.Fatalf("a pre-setup backup restored over a set-up app must be gated, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// An app re-gated since (reinstalled, gate closed) restored from a backup taken after its setup → opened.
|
||||
// COMPANION RED-PROOF: drop the reopen case → it stays closed and this fails.
|
||||
func TestR1057_InPlaceRestoreOfAnOpenBackupOpens(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
closedGate(t, m)
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: false}))
|
||||
if got := LoadAppConfig(gappDir(m)); got.SetupGate.Closed() || gateFileExists(m) {
|
||||
t.Fatalf("the restored data has its admin — the gate must open, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// An app NOT removed and a backup with no record: the app keeps its own record (unchanged behaviour).
|
||||
func TestR1057_InPlaceNoRecordKeepsTheAppsRecord(t *testing.T) {
|
||||
m := gateManager(t, gatedYml)
|
||||
must(t, SaveAppConfig(gappDir(m), &AppConfig{Deployed: true, Env: gateEnv,
|
||||
SetupGate: &SetupGateRecord{State: SetupGateOpen, OpenedBy: SetupGateByHousehold}}, m.encKey, nil))
|
||||
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
|
||||
if got := LoadAppConfig(gappDir(m)); got.SetupGate.Closed() || got.SetupGate.OpenedBy != SetupGateByHousehold {
|
||||
t.Fatalf("an installed app keeps its record when the backup has none, got %+v", got.SetupGate)
|
||||
}
|
||||
}
|
||||
|
||||
// The capture side: what a backup records, and that ReadGateAtBackup reads it back from a unit's app.yaml.
|
||||
// COMPANION RED-PROOF: make GateCapture return nil for a nil record → the never-gated case fails.
|
||||
func TestR1057_CaptureAndReadBack(t *testing.T) {
|
||||
gated := &Metadata{SetupGate: true}
|
||||
if GateCapture(&Metadata{}, nil) != nil {
|
||||
t.Fatal("a template with no gate records nothing (its unit bytes stay unchanged)")
|
||||
}
|
||||
if g := GateCapture(gated, nil); g == nil || g.State != SetupGateOpen {
|
||||
t.Fatalf("installed before gates existed = never gated = open, got %+v", g)
|
||||
}
|
||||
if g := GateCapture(gated, &SetupGateRecord{State: SetupGateClosed}); g == nil || g.State != SetupGateClosed {
|
||||
t.Fatalf("closed record → closed, got %+v", g)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "app.yaml")
|
||||
for body, want := range map[string]GateAtBackup{
|
||||
"deployed: true\nenv: {}\nsetup_gate:\n state: closed\n": {Known: true, Closed: true},
|
||||
"deployed: true\nenv: {}\nsetup_gate:\n state: open\n opened_by: probe\n": {Known: true, Closed: false, OpenedBy: "probe"},
|
||||
"deployed: true\nenv: {}\n": {},
|
||||
"deployed: true\nenv: {}\nsetup_gate:\n state: weird\n": {Known: true, Closed: true}, // fail closed
|
||||
} {
|
||||
must(t, os.WriteFile(p, []byte(body), 0o600))
|
||||
if got := ReadGateAtBackup(p); got != want {
|
||||
t.Errorf("%q → %+v, want %+v", body, got, want)
|
||||
}
|
||||
}
|
||||
if got := ReadGateAtBackup(filepath.Join(dir, "absent.yaml")); got.Known {
|
||||
t.Fatal("an absent file is not a record")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user