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

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:
2026-10-15 14:34:33 +02:00
parent e209a8d724
commit 17863fe8bc
22 changed files with 762 additions and 21 deletions
+26 -1
View File
@@ -1,5 +1,30 @@
## Unreleased (2026-10-15, afternoon) — the shared rule file; no code change ## v0.314.0 — a restore puts the setup gate back as it was; a test build moves with the floor; Prowlarr links itself; the box claims Plex again (R-1057, R-1053, R-1058, R-1060) (2026-10-15)
**MinAgent: 0.155.0** (unchanged — no new agent call). No golden: the first release under `09` §3 decision 250 (the floor
carries it after the three test boxes). Design: felhom.eu `architecture/09-update-architecture.md` §3 decision 253 (the
restore rule), `07-backup-architecture.md` (what a per-app backup carries), `15-media-library.md` §6 (the links).
- **R-1057 (P2) — a restored app never comes back open.** The per-app backup's `app.yaml` now carries `setup_gate:`
(state and who opened it; `stacks.GateCapture`; an app installed before gates existed is recorded open, as it was).
The restore applies it (`stacks.ReadGateAtBackup`, `applyGateAtBackup` in `PersistUnitRedeployConfigGate`): closed at
the backup → closed now, the gate file written before anything starts (decision 46 F2), also over an installed app;
open at the backup → open (an app re-gated since is opened again); no record (a backup made before 0.314.0) of a
removed app → closed, and the gate's loop opens it at once if the app's probe says the setup is done. Never open by
default. Measured before: Prowlarr restored from a pre-setup backup answered a stranger 200 (felhom.eu
`audits/prowlarr-2026-10-15/box/run3/restore.txt`). Tests `restore_gate_test.go`, `r1057_unit_gate_test.go`; ten
mutations each seen failing.
- **R-1053 — a box on a test build moves with the floor.** `util.ParseVersionPre` reads `X.Y.Z-rc.N` as a pre-release
BELOW `X.Y.Z` (semver §11); the floor check uses it for the box's own version only. `ParseVersion` still refuses a
suffix, so the household's button never offers a test build (`TestR1053_TheButtonNeverOffersATestBuild`). A version the
floor cannot compare is now a WARN, once per (version, floor) — it was a DEBUG line. The floor tests that read the
result before the background update could run now wait for it (six tests; one mutation proven).
- **R-1058 — Prowlarr links itself.** Prowlarr's own start (install, start, restore) runs the media wiring
(`linkOnlyApps`, `wiresOnUp` in `internal/stacks/manager.go`); installed after Sonarr/Radarr it no longer waits for
„Bekötés most". No marker in the catalog (the marker binds drives; Prowlarr uses none).
- **R-1060 (P2) — the box claims Plex again.** Every call to Plex (claim, identity, library wiring, sessions) goes to
the container's ADDRESS, resolved at each call (`mediawire.PlexURL`): Plex refuses the Host name `plex` as non-local
and answered the claim 401 in 0 ms on demo-hp 9201 (felhom.eu `audits/media-build-2026-10-15/G7/G5c`–`G5e`). Tests
against a fake Plex that refuses the name, as the real one does.
- `.claude/rules/unprompted-work.md` §3 item 6: **a controller release needs no golden** — the floor carries it after the - `.claude/rules/unprompted-work.md` §3 item 6: **a controller release needs no golden** — the floor carries it after the
three test boxes; agent releases still wait for the operator's vouch; the golden is weekly (operator ruling 2026-10-15 three test boxes; agent releases still wait for the operator's vouch; the golden is weekly (operator ruling 2026-10-15
11:26, `09` §3 decision 250; the same morning's „bake first", decision 247, reversed). Same text in all five copies. 11:26, `09` §3 decision 250; the same morning's „bake first", decision 247, reversed). Same text in all five copies.
+3
View File
@@ -366,6 +366,9 @@
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression | | `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go | | `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) | | `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production | | `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go | | `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go |
+7
View File
@@ -2025,6 +2025,10 @@ that folder is never a dead end, and an install never runs into it silently (R-6
owns, and opens a gate whose probe says done; the household's „Kész, beállítottam" (`POST /apps/<slug>/setup-gate/open`) owns, and opens a gate whose probe says done; the household's „Kész, beállítottam" (`POST /apps/<slug>/setup-gate/open`)
opens one without a probe. Opening writes the record, then removes the file. A restore keeps the record; a kept-data opens one without a probe. Opening writes the record, then removes the file. A restore keeps the record; a kept-data
load never gates. Code: `internal/stacks/setup_gate.go`, `internal/web/setup_gate.go`. load never gates. Code: `internal/stacks/setup_gate.go`, `internal/web/setup_gate.go`.
**v0.314.0 (R-1057, decision 253):** the per-app backup records the gate (`setup_gate:` in the unit's `app.yaml`) and
a restore puts it back as it was — closed then → closed now (file first, also over an installed app); open then →
open; no record (a backup before 0.314.0) of a removed app → closed until the probe or „Kész". Never open by default.
Code: `internal/stacks/restore_gate.go`.
**v0.281.0:** the household's press asks the probe first and refuses while it says "not done" or cannot be read; an **v0.281.0:** the household's press asks the probe first and refuses while it says "not done" or cannot be read; an
app without a probe confirms in the page first. app without a probe confirms in the page first.
- **Sign-up closed after the first admin (v0.281.0, decision 47)** — `.felhom.yml` `signup_block:` (traefik matcher). - **Sign-up closed after the first admin (v0.281.0, decision 47)** — `.felhom.yml` `signup_block:` (traefik matcher).
@@ -3317,6 +3321,9 @@ no key into the household's account). Code: `internal/stacks/after_unclean_boot.
- **A real restart.** `RestartStack` = `up -d --force-recreate` with the stack's env; start times before/after; an - **A real restart.** `RestartStack` = `up -d --force-recreate` with the stack's env; start times before/after; an
error when nothing started again. error when nothing started again.
- **Wiring** (`internal/mediawire`): after a media app comes up (debounced 60 s), after the media restart, and on - **Wiring** (`internal/mediawire`): after a media app comes up (debounced 60 s), after the media restart, and on
**v0.314.0:** Prowlarr's own start runs it too (R-1058, `linkOnlyApps`); every call to Plex goes to its container ADDRESS, resolved
at each call (`mediawire.PlexURL`) — Plex refuses the name `plex` as non-local (R-1060). A box on `X.Y.Z-rc.N` is below
`X.Y.Z` for the floor (`util.ParseVersionPre`, R-1053); the button never offers a test build.
„Bekötés most": qBittorrent categories + automatic management, Transmission's incomplete folder off, Sonarr/Radarr tag, „Bekötés most": qBittorrent categories + automatic management, Transmission's incomplete folder off, Sonarr/Radarr tag,
root folder, download clients, minimum free space, Prowlarr → Sonarr/Radarr, Plex libraries + „scan on change" (the root folder, download clients, minimum free space, Prowlarr → Sonarr/Radarr, Plex libraries + „scan on change" (the
server's own token, read at use), Jellyfin libraries (the API key named „felhom", read at use from its database). server's own token, read at use), Jellyfin libraries (the API key named „felhom", read at use from its database).
+14 -1
View File
@@ -3202,9 +3202,20 @@ func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, b
PortableSecretEnvVars: portableNames, PortableSecretEnvVars: portableNames,
PortableSecrets: portable, PortableSecrets: portable,
InstalledImages: installedRefs(s.AppConfig), InstalledImages: installedRefs(s.AppConfig),
// R-1057 (v0.314.0): the gate as it stands at this backup — read from the file, the record of truth.
SetupGate: gateAtCapture(&meta, stackDir),
}, true }, true
} }
// gateAtCapture reads the app's setup-gate record from its app.yaml on disk for the backup (stacks.GateCapture).
func gateAtCapture(meta *stacks.Metadata, stackDir string) *backup.GateState {
var rec *stacks.SetupGateRecord
if cfg := stacks.LoadAppConfig(stackDir); cfg != nil {
rec = cfg.SetupGate
}
return stacks.GateCapture(meta, rec)
}
// installedRefs renders app.yaml's `installed_images` observation as service -> `ref@digest` (v0.275.0, // installedRefs renders app.yaml's `installed_images` observation as service -> `ref@digest` (v0.275.0,
// R-696) for the data stamps. Nil when nothing was observed — absent means UNKNOWN (deploy.go). // R-696) for the data stamps. Nil when nothing was observed — absent means UNKNOWN (deploy.go).
func installedRefs(cfg *stacks.AppConfig) map[string]string { func installedRefs(cfg *stacks.AppConfig) map[string]string {
@@ -3263,7 +3274,9 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return fmt.Errorf("restoring %s from unit: %w", fname, err) return fmt.Errorf("restoring %s from unit: %w", fname, err)
} }
} }
if err := a.mgr.PersistUnitRedeployConfig(name, fullEnv); err != nil { // R-1057 (v0.314.0): the gate as the backup recorded it — the unit's app.yaml, read BEFORE the write.
gate := stacks.ReadGateAtBackup(filepath.Join(composeSrcDir, "app.yaml"))
if err := a.mgr.PersistUnitRedeployConfigGate(name, fullEnv, gate); err != nil {
return err return err
} }
+11
View File
@@ -87,6 +87,17 @@ type RecoveryInfo struct {
// `installed_images`, as `ref@digest` (the plain ref when no digest was recorded). Stamped beside each // `installed_images`, as `ref@digest` (the plain ref when no digest was recorded). Stamped beside each
// data file the backup writes, so the data says which versions wrote it. Nil = unknown, never current. // data file the backup writes, so the data says which versions wrote it. Nil = unknown, never current.
InstalledImages map[string]string InstalledImages map[string]string
// SetupGate (v0.314.0, R-1057) is the app's setup gate AT THIS BACKUP: „closed" (its first setup was not done) or
// „open" (and by whom). Nil for an app whose template has no gate. The restore applies it, so a backup made before
// the household's first setup never comes back open (`09` §3 decision 253).
SetupGate *GateState
}
// GateState is the setup gate as a backup records it (the unit's app.yaml `setup_gate:`). The same two keys as the
// live app.yaml's record, so a restore reads either file the same way (stacks.ReadGateAtBackup).
type GateState struct {
State string `yaml:"state"`
OpenedBy string `yaml:"opened_by,omitempty"`
} }
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a // ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
@@ -27,6 +27,7 @@ type AppBackupInfo = appbackup.AppBackupInfo
type AppDataPath = appbackup.AppDataPath type AppDataPath = appbackup.AppDataPath
type AppDockerVolume = appbackup.AppDockerVolume type AppDockerVolume = appbackup.AppDockerVolume
type RecoveryInfo = appbackup.RecoveryInfo type RecoveryInfo = appbackup.RecoveryInfo
type GateState = appbackup.GateState
type ClassifiedBind = appbackup.ClassifiedBind type ClassifiedBind = appbackup.ClassifiedBind
// --- type aliases (dbdump) --- // --- type aliases (dbdump) ---
@@ -0,0 +1,35 @@
package backup
import (
"bytes"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1057 (v0.314.0): the unit's app.yaml carries the setup gate as it stood at the backup, in the keys the restore
// reads (stacks.ReadGateAtBackup); an app with no gate gets byte-identical bytes to before (the checksum-skip guard).
// COMPANION RED-PROOF: drop `SetupGate: info.SetupGate` from buildUnitAppYaml → the read-back says „not known".
func TestR1057_TheUnitCarriesTheGateAndTheRestoreReadsIt(t *testing.T) {
base := RecoveryInfo{NonSecretEnv: map[string]string{"DOMAIN": "example.hu"}}
plain := buildUnitAppYaml(base)
if bytes.Contains(plain, []byte("setup_gate")) {
t.Fatalf("an app with no gate must not carry a gate record:\n%s", plain)
}
withNil := base
withNil.SetupGate = nil
if !bytes.Equal(plain, buildUnitAppYaml(withNil)) {
t.Fatal("an ungated app's unit bytes changed")
}
closed := base
closed.SetupGate = &GateState{State: stacks.SetupGateClosed}
p := filepath.Join(t.TempDir(), "app.yaml")
if err := os.WriteFile(p, buildUnitAppYaml(closed), 0o600); err != nil {
t.Fatal(err)
}
if got := stacks.ReadGateAtBackup(p); !got.Known || !got.Closed {
t.Fatalf("the restore must read the closed gate back from the unit, got %+v", got)
}
}
+4 -1
View File
@@ -503,6 +503,9 @@ func (m *Manager) versionLocked() string {
type strippedAppYaml struct { type strippedAppYaml struct {
Deployed bool `yaml:"deployed"` Deployed bool `yaml:"deployed"`
Env map[string]string `yaml:"env"` Env map[string]string `yaml:"env"`
// SetupGate (v0.314.0, R-1057): the gate at this backup; absent for an app with no gate, so an ungated app's
// unit bytes are unchanged (the checksum-skip guard).
SetupGate *GateState `yaml:"setup_gate,omitempty"`
} }
// buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret // buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret
@@ -521,7 +524,7 @@ func buildUnitAppYaml(info RecoveryInfo) []byte {
for k, v := range info.PortableSecrets { for k, v := range info.PortableSecrets {
env[k] = v env[k] = v
} }
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env}) body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate})
if err != nil { if err != nil {
body = []byte("deployed: true\nenv: {}\n") body = []byte("deployed: true\nenv: {}\n")
} }
+20 -4
View File
@@ -13,7 +13,7 @@
// Prowlarr : one application each for Sonarr and Radarr, „Full Sync" (once) // Prowlarr : one application each for Sonarr and Radarr, „Full Sync" (once)
// Plex : the drive's folders added to the EXISTING TV/film libraries, a scan, „scan when a change is // Plex : the drive's folders added to the EXISTING TV/film libraries, a scan, „scan when a change is
// detected" ON — with the server's own PlexOnlineToken read from Preferences.xml AT USE (`09` §3 // detected" ON — with the server's own PlexOnlineToken read from Preferences.xml AT USE (`09` §3
// decision 248); never stored, never logged, sent only to http://plex:32400 // decision 248); never stored, never logged, sent only to the plex container (by address, R-1060)
// Jellyfin : the drive's folders added, a scan — with the API key named „felhom" read from Jellyfin's own // Jellyfin : the drive's folders added, a scan — with the API key named „felhom" read from Jellyfin's own
// database AT USE. No such key → one household step, said on the app's page. // database AT USE. No such key → one household step, said on the app's page.
// //
@@ -89,10 +89,26 @@ type Wirer struct {
mu sync.Mutex mu sync.Mutex
} }
// New is the production Wirer: apps by container name on the docker network. // New is the production Wirer: apps by container name on the docker network — Plex by its ADDRESS (R-1060, plexaddr.go).
func New(box Box, logger *log.Logger) *Wirer { func New(box Box, logger *log.Logger) *Wirer {
return &Wirer{Box: box, Logger: logger, HTTP: &http.Client{Timeout: 60 * time.Second}, return &Wirer{Box: box, Logger: logger, HTTP: &http.Client{Timeout: 60 * time.Second}, URL: productionURL(logger)}
URL: func(c string, port int) string { return fmt.Sprintf("http://%s:%d", c, port) }} }
// productionURL maps a container to its base URL: by name, except Plex by address. A Plex that cannot be resolved falls
// back to the name (its calls then fail as before) with a WARN, never silently.
func productionURL(logger *log.Logger) func(string, int) string {
return func(c string, port int) string {
if c == "plex" {
u, err := PlexURL(port)
if err == nil {
return u
}
if logger != nil {
logger.Printf("[WARN] [mediawire] plex: %v — calling it by name, which Plex refuses as non-local (R-1060)", err)
}
}
return fmt.Sprintf("http://%s:%d", c, port)
}
} }
// Run wires every present drive into every installed media app. Single-flight: a second caller waits. // Run wires every present drive into every installed media app. Single-flight: a second caller waits.
+44
View File
@@ -0,0 +1,44 @@
package mediawire
import (
"context"
"fmt"
"net"
"strconv"
"time"
)
// R-1060 (v0.314.0): every call the box makes to Plex goes to its ADDRESS, never to the name „plex".
//
// Plex refuses a request whose Host header is a name it does not know as non-local: „Request came in with unrecognized
// domain / IP 'plex' in header Host; treating as non-local" (its own log, demo-hp 9201, 2026-10-15). The claim answered
// 401 in 0 ms with two fresh codes; by address the same request is local (a fake code: 500 from plex.tv; measured,
// felhom.eu audits/media-build-2026-10-15/G7/G5e-host-measure.txt). The K6 spike on Tester 1 called by address and passed;
// the product called by name, and its tests replaced Plex with a seam, so nothing met the refusal before a household did.
// Used by all four calls: the claim and the identity (internal/web), the library wiring and the sessions (this package).
// Pinned by plexaddr_test.go and internal/web/r1060_plex_by_address_test.go.
// PlexLookup resolves the plex container on the docker network AT CALL TIME — never cached: a restarted container may
// get another address. A seam for tests.
var PlexLookup = func(ctx context.Context) ([]net.IPAddr, error) {
return net.DefaultResolver.LookupIPAddr(ctx, "plex")
}
// PlexURL is Plex's base URL by address: http://<ip>:<port> (IPv4 first).
func PlexURL(port int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
addrs, err := PlexLookup(ctx)
if err != nil {
return "", fmt.Errorf("resolving the plex container: %w", err)
}
for _, a := range addrs {
if ip4 := a.IP.To4(); ip4 != nil {
return "http://" + net.JoinHostPort(ip4.String(), strconv.Itoa(port)), nil
}
}
for _, a := range addrs {
return "http://" + net.JoinHostPort(a.IP.String(), strconv.Itoa(port)), nil
}
return "", fmt.Errorf("the plex container resolves to no address")
}
@@ -0,0 +1,35 @@
package mediawire
import (
"context"
"errors"
"net"
"testing"
)
// R-1060 — the library wiring and the sessions call Plex by ADDRESS (productionURL), resolved at every call; every
// other app stays by name. COMPANION RED-PROOF: drop the plex case in productionURL → „http://plex:32400" and this fails.
func TestR1060_ProductionURLCallsPlexByAddress(t *testing.T) {
old := PlexLookup
t.Cleanup(func() { PlexLookup = old })
calls := 0
PlexLookup = func(context.Context) ([]net.IPAddr, error) {
calls++
return []net.IPAddr{{IP: net.ParseIP("fd00::5")}, {IP: net.ParseIP("172.18.0.15")}}, nil
}
url := productionURL(nil)
if got := url("plex", 32400); got != "http://172.18.0.15:32400" {
t.Fatalf("plex → %q, want its IPv4 address", got)
}
_ = url("plex", 32400)
if calls != 2 {
t.Fatalf("resolved %d times for 2 calls — the address must be resolved at each call, never cached", calls)
}
if got := url("sonarr", 8989); got != "http://sonarr:8989" {
t.Fatalf("sonarr → %q, other apps stay by name", got)
}
PlexLookup = func(context.Context) ([]net.IPAddr, error) { return nil, errors.New("no such host") }
if got := url("plex", 32400); got != "http://plex:32400" {
t.Fatalf("an unresolvable plex falls back to the name (logged), got %q", got)
}
}
@@ -1,6 +1,8 @@
package selfupdate package selfupdate
import ( import (
"log"
"strings"
"testing" "testing"
) )
@@ -38,6 +40,7 @@ func TestMaybeAutoUpdate_AtFloor_NoAction(t *testing.T) {
u.SetFloor("0.87.0") // current == floor u.SetFloor("0.87.0") // current == floor
u.MaybeAutoUpdate() u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if pulled { if pulled {
t.Error("must NOT pull when at/above floor (latest>current is the customer's button, not the floor)") t.Error("must NOT pull when at/above floor (latest>current is the customer's button, not the floor)")
@@ -57,6 +60,7 @@ func TestMaybeAutoUpdate_NoFloor_Inert(t *testing.T) {
u.SetFloor("") // explicitly none u.SetFloor("") // explicitly none
u.MaybeAutoUpdate() u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if pulled || len(agent.swapCalls()) != 0 { if pulled || len(agent.swapCalls()) != 0 {
t.Errorf("no floor must be inert; pulled=%v swaps=%d", pulled, len(agent.swapCalls())) t.Errorf("no floor must be inert; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
@@ -74,6 +78,7 @@ func TestMaybeAutoUpdate_FloorAboveLatest_NoChase(t *testing.T) {
u.SetFloor("0.88.0") // floor > latest available u.SetFloor("0.88.0") // floor > latest available
u.MaybeAutoUpdate() u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if pulled { if pulled {
t.Error("must NOT pull when the floor exceeds the latest available tag") t.Error("must NOT pull when the floor exceeds the latest available tag")
@@ -143,6 +148,7 @@ func TestMaybeAutoUpdate_DevCurrent_NoAction(t *testing.T) {
u.SetFloor("0.87.0") u.SetFloor("0.87.0")
u.MaybeAutoUpdate() u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if pulled || len(agent.swapCalls()) != 0 { if pulled || len(agent.swapCalls()) != 0 {
t.Errorf("dev build must not auto-update; pulled=%v swaps=%d", pulled, len(agent.swapCalls())) t.Errorf("dev build must not auto-update; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
@@ -158,8 +164,74 @@ func TestMaybeAutoUpdate_NoAgent_NoAction(t *testing.T) {
u.SetFloor("0.87.0") u.SetFloor("0.87.0")
u.MaybeAutoUpdate() u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if pulled { if pulled {
t.Error("must not pull when no agent is wired") t.Error("must not pull when no agent is wired")
} }
} }
// R-1053 (v0.314.0) — a box on a TEST BUILD of the floor's version is below the floor and is moved to it.
// COMPANION RED-PROOF: parse the current version with ParseVersion again in MaybeAutoUpdate → nothing is pulled.
func TestR1053_ATestBuildIsMovedToTheFloor(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.313.0-rc.4", agent)
u.queryFn = func() (string, error) { return "0.313.0", nil }
var pulled string
u.pullFn = func(img string) error { pulled = img; return nil }
u.SetFloor("0.313.0")
u.MaybeAutoUpdate()
waitDone(t, u)
if want := imageBase + ":0.313.0"; pulled != want || len(agent.swapCalls()) != 1 {
t.Fatalf("a box on rc.4 must be moved to the floor 0.313.0: pulled %q, swaps %v", pulled, agent.swapCalls())
}
}
// A test build of a NEWER version is above the floor: nothing happens (the floor never moves a box down).
// COMPANION RED-PROOF: skip the at/above check for any pre-release in MaybeAutoUpdate → it is pulled and this fails.
func TestR1053_ATestBuildAboveTheFloorStays(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.314.0-rc.1", agent)
u.queryFn = func() (string, error) { return "0.313.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("0.313.0")
u.MaybeAutoUpdate()
waitDone(t, u) // the update runs in the background — read the result only after it could have run
if pulled || len(agent.swapCalls()) != 0 {
t.Fatal("0.314.0-rc.1 is above the floor 0.313.0 — it must stay")
}
}
// A version the floor cannot compare is said at WARN, once per (version, floor).
// COMPANION RED-PROOF: log the line at DEBUG again (or on every call) → the count is 0 (or 2).
func TestR1053_AnUnreadableVersionWarnsOncePerChange(t *testing.T) {
var buf strings.Builder
u := newTestUpdater(t, "dev", &fakeAgent{})
u.logger = log.New(&buf, "", 0)
u.queryFn = func() (string, error) { return "0.313.0", nil }
u.SetFloor("0.313.0")
u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if n := strings.Count(buf.String(), "[WARN] [selfupdate] auto-update: this box runs \"dev\""); n != 1 {
t.Fatalf("want exactly one WARN for the same version and floor, got %d:\n%s", n, buf.String())
}
u.SetFloor("0.314.0")
u.MaybeAutoUpdate()
waitDone(t, u) // v0.314.0: the update runs in the background; read only after it could have run
if n := strings.Count(buf.String(), "[WARN]"); n != 2 {
t.Fatalf("a new floor must warn again, got %d WARN lines", n)
}
}
// The household's button never offers a test build: the highest tag picked from the registry skips every pre-release.
// COMPANION RED-PROOF: parse tags with ParseVersionPre in decodeHighestTag → 0.314.0-rc.1 is offered and this fails.
func TestR1053_TheButtonNeverOffersATestBuild(t *testing.T) {
u := newTestUpdater(t, "0.312.0", &fakeAgent{})
got, err := u.decodeHighestTag(strings.NewReader(`{"name":"x","tags":["0.312.0","0.313.0","0.314.0-rc.1","latest"]}`))
if err != nil || got != "0.313.0" {
t.Fatalf("offered %q (%v), want 0.313.0", got, err)
}
}
+12 -2
View File
@@ -70,6 +70,8 @@ type Updater struct {
// persisted UpdateState guards across restarts). Auto-target is ALWAYS the floor, never latest. // persisted UpdateState guards across restarts). Auto-target is ALWAYS the floor, never latest.
floor string floor string
lastAutoFloorAttempt string lastAutoFloorAttempt string
// lastUnparseableWarn (v0.314.0, R-1053): the (version|floor) last warned about, so the WARN is once per change.
lastUnparseableWarn string
// Seams (default to the real implementations; overridden in tests to avoid network/docker). // Seams (default to the real implementations; overridden in tests to avoid network/docker).
queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry) queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry)
@@ -624,10 +626,18 @@ func (u *Updater) MaybeAutoUpdate() {
u.dbg("maybeAutoUpdate: an update is already running — skip") u.dbg("maybeAutoUpdate: an update is already running — skip")
return return
} }
curVer, err := ParseVersion(u.currentVer) // R-1053 (v0.314.0): a test build (0.313.0-rc.4) is BELOW its release, so the floor moves it. A version that still
// cannot be read is said at WARN once per (version, floor) — it used to be a DEBUG line nobody saw while Tester 1
// sat on rc.4 below the floor.
curVer, err := util.ParseVersionPre(u.currentVer)
if err != nil { if err != nil {
key := u.currentVer + "|" + floor
warn := u.lastUnparseableWarn != key
u.lastUnparseableWarn = key
u.mu.Unlock() u.mu.Unlock()
u.dbg("maybeAutoUpdate: current %q not parseable (dev?) — skip", u.currentVer) if warn {
u.logger.Printf("[WARN] [selfupdate] auto-update: this box runs %q, which is not a version the floor can compare — the floor %s will NOT move it", u.currentVer, floor)
}
return return
} }
floorVer, err := ParseVersion(floor) floorVer, err := ParseVersion(floor)
+11 -1
View File
@@ -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 // RedeployFromEnv itself is this function plus the unchanged up-and-report tail, so its public
// behaviour is identical to before the split. // behaviour is identical to before the split.
func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string) error { 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) stack, ok := m.GetStack(name)
if !ok { if !ok {
return fmt.Errorf("stack %q not found", name) 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) cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
priorRaw := LoadAppConfig(stackDir) priorRaw := LoadAppConfig(stackDir)
carryLifeRecords(m.logger, name, priorRaw, cfg) 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 // 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 + // 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 // 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 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) rec, err := m.restoreSignupLock(name, stack.ComposePath, env, &meta)
if err != nil { if err != nil {
return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err) return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err)
+11 -1
View File
@@ -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 // 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. // side debounces). Never for a stack without the marker.
if len(args) > 0 && args[0] == "up" && m.onMediaUp != nil { 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)) m.onMediaUp(filepath.Base(dir))
} }
} }
return stdout.String(), nil 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). // 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 } 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). // /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. // 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) { func TestR773_ARemovedAppComesBackWithSignupClosed(t *testing.T) {
m := gateManager(t, signupYml) m := gateManager(t, signupYml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp") dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
if LoadAppConfig(dir) != nil { if LoadAppConfig(dir) != nil {
t.Fatal("precondition: the removed app has no app.yaml") 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) got := LoadAppConfig(dir)
if got == nil || got.SetupGate == nil || got.SetupGate.State != SetupGateOpen || got.SetupGate.OpenedBy != SetupGateByRestore { 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) 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")
}
}
+78 -2
View File
@@ -18,6 +18,9 @@ type Version struct {
Minor int Minor int
Patch int Patch int
Raw string Raw string
// Pre (v0.314.0, R-1053) is a pre-release suffix — „rc.4" in 0.313.0-rc.4 — set ONLY by ParseVersionPre.
// ParseVersion refuses a suffix, so every caller that lists or offers tags still never sees a test build.
Pre string
} }
// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats. // ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats.
@@ -45,6 +48,76 @@ func ParseVersion(s string) (Version, error) {
return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil
} }
// ParseVersionPre is ParseVersion that also accepts a pre-release suffix (semver §9): „0.313.0-rc.4" is 0.313.0 with
// Pre „rc.4", which Compare orders BELOW 0.313.0. A build suffix („+abc") is dropped. Used where the question is „is
// this box below the floor?" (R-1053: a box on a test build was never moved by the floor) — never to pick a tag to offer.
func ParseVersionPre(s string) (Version, error) {
s = strings.TrimPrefix(s, "v")
if i := strings.IndexByte(s, '+'); i >= 0 {
s = s[:i]
}
base, pre, hasPre := strings.Cut(s, "-")
v, err := ParseVersion(base)
if err != nil {
return Version{}, err
}
if hasPre {
if pre == "" {
return Version{}, fmt.Errorf("invalid version: %q (empty pre-release)", s)
}
for _, id := range strings.Split(pre, ".") {
if id == "" || strings.Trim(id, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-") != "" {
return Version{}, fmt.Errorf("invalid pre-release in %q", s)
}
}
v.Pre = pre
}
v.Raw = s
return v, nil
}
// comparePre orders pre-release suffixes per semver §11: none > any; identifiers left to right, numeric ones
// numerically and below alphanumeric ones; a shorter list below a longer one with the same prefix.
func comparePre(a, b string) int {
switch {
case a == b:
return 0
case a == "":
return 1
case b == "":
return -1
}
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
an, aerr := strconv.Atoi(as[i])
bn, berr := strconv.Atoi(bs[i])
switch {
case aerr == nil && berr == nil:
if an != bn {
if an < bn {
return -1
}
return 1
}
case aerr == nil:
return -1
case berr == nil:
return 1
default:
if c := strings.Compare(as[i], bs[i]); c != 0 {
return c
}
}
}
switch {
case len(as) < len(bs):
return -1
case len(as) > len(bs):
return 1
}
return 0
}
// Compare returns -1 if a < b, 0 if a == b, 1 if a > b. // Compare returns -1 if a < b, 0 if a == b, 1 if a > b.
func (a Version) Compare(b Version) int { func (a Version) Compare(b Version) int {
if a.Major != b.Major { if a.Major != b.Major {
@@ -65,10 +138,13 @@ func (a Version) Compare(b Version) int {
} }
return 1 return 1
} }
return 0 return comparePre(a.Pre, b.Pre)
} }
// String returns the version as "X.Y.Z". // String returns the version as "X.Y.Z" (or "X.Y.Z-pre" for a ParseVersionPre result).
func (v Version) String() string { func (v Version) String() string {
if v.Pre != "" {
return fmt.Sprintf("%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre)
}
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
} }
@@ -0,0 +1,35 @@
package util
import "testing"
// R-1053 (v0.314.0): a test build orders BELOW its release, so the floor moves a box off it; ParseVersion itself still
// refuses a suffix, so nothing that lists or offers tags ever sees a test build.
// COMPANION RED-PROOF: make comparePre return 0 → „0.313.0-rc.4 < 0.313.0" fails.
func TestR1053_PreReleaseOrdersBelowItsRelease(t *testing.T) {
order := []string{"0.312.9", "0.313.0-rc.1", "0.313.0-rc.4", "0.313.0-rc.10", "0.313.0", "0.313.1-rc.1", "0.313.1"}
for i := 0; i+1 < len(order); i++ {
a, err := ParseVersionPre(order[i])
if err != nil {
t.Fatal(err)
}
b, err := ParseVersionPre(order[i+1])
if err != nil {
t.Fatal(err)
}
if a.Compare(b) >= 0 || b.Compare(a) <= 0 {
t.Errorf("%s must be below %s", order[i], order[i+1])
}
}
v, err := ParseVersionPre("v0.313.0-rc.4+build7")
if err != nil || v.Pre != "rc.4" || v.String() != "0.313.0-rc.4" {
t.Fatalf("got %+v %v", v, err)
}
for _, bad := range []string{"0.313.0-", "0.313.0-rc..4", "0.313.0-r c", "dev", "0.313"} {
if _, err := ParseVersionPre(bad); err == nil {
t.Errorf("%q must be refused", bad)
}
}
if _, err := ParseVersion("0.313.0-rc.4"); err == nil {
t.Fatal("ParseVersion must still refuse a test build (it picks the tags the household is offered)")
}
}
+19 -6
View File
@@ -368,7 +368,7 @@ var _ = settings.StoragePath{}
var plexClaimCode = regexp.MustCompile(`^claim-[A-Za-z0-9_-]{8,64}$`) var plexClaimCode = regexp.MustCompile(`^claim-[A-Za-z0-9_-]{8,64}$`)
// appPlexClaimHandler (R-1028, `15` §10 K6): the household's code claims the RUNNING Plex — POST // appPlexClaimHandler (R-1028, `15` §10 K6): the household's code claims the RUNNING Plex — POST
// http://plex:32400/myplex/claim?token=<code> (no Plex token needed: the server is unclaimed). The code is never logged // POST <plex by address>:32400/myplex/claim?token=<code> (no Plex token needed: the server is unclaimed; R-1060). The code is never logged
// and never stored. The setup gate opens only when Plex itself then says claimed (the probe), never on the press alone. // and never stored. The setup gate opens only when Plex itself then says claimed (the probe), never on the press alone.
func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
@@ -398,14 +398,21 @@ func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) {
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed")) escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed"))
} }
// plexClaim is the call (a seam for tests). // plexHTTP is the client for the box's own calls to Plex (a seam: tests dial a fake that refuses the name „plex").
var plexHTTP = &http.Client{Timeout: 30 * time.Second}
// plexClaim is the call (a seam for tests). R-1060 (v0.314.0): by ADDRESS — Plex answered 401 to the name „plex".
var plexClaim = func(code string) (int, error) { var plexClaim = func(code string) (int, error) {
req, err := http.NewRequest(http.MethodPost, "http://plex:32400/myplex/claim?token="+url.QueryEscape(code), nil) base, err := mediawire.PlexURL(32400)
if err != nil {
return 0, err
}
req, err := http.NewRequest(http.MethodPost, base+"/myplex/claim?token="+url.QueryEscape(code), nil)
if err != nil { if err != nil {
return 0, err return 0, err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) resp, err := plexHTTP.Do(req)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -447,12 +454,18 @@ func ejectStep(sp settings.StoragePath, disks []agentapi.DiskInfo) (int, *agenta
// measured 2026-10-15). A seam for tests. The live template carries NO setup_done_probe on purpose: a controller older // measured 2026-10-15). A seam for tests. The live template carries NO setup_done_probe on purpose: a controller older
// than 0.313.0 would send no Accept header, read XML as „not JSON" and refuse the household's „Kész" forever. // than 0.313.0 would send no Accept header, read XML as „not JSON" and refuse the household's „Kész" forever.
var plexClaimed = func() bool { var plexClaimed = func() bool {
req, err := http.NewRequest(http.MethodGet, "http://plex:32400/identity", nil) base, err := mediawire.PlexURL(32400)
if err != nil {
return false
}
req, err := http.NewRequest(http.MethodGet, base+"/identity", nil)
if err != nil { if err != nil {
return false return false
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()
resp, err := plexHTTP.Do(req.WithContext(ctx))
if err != nil { if err != nil {
return false return false
} }