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:
+26
-1
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user