controller v0.72.0: FileBrowser converges on boot-recreate

processGuestBootChange recreated the drive-backed app stacks but never re-synced
FileBrowser (base-infra, no HDD_PATH), so its drive mounts went stale after a
reboot. Now, AFTER pollLiveBinds confirms the live binds and the apps are
recreated, trigger go s.SyncFileBrowserMounts() so FileBrowser converges against
the now-live drives. Refactored into pure recreateDriveBackedApps(stacks, present,
recreate, syncFB). Tests: FB sync runs once after recreate (red-proofed companion);
runs even when nothing recreated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:37:12 +02:00
parent 14f200c9a1
commit 6ea25388d7
3 changed files with 92 additions and 11 deletions
+13
View File
@@ -1,5 +1,18 @@
## Changelog ## Changelog
### v0.72.0 — FileBrowser converges on boot-recreate (2026-06-16)
Follow-up to v0.71.0: a host-reboot test found `processGuestBootChange` recreated the drive-backed app
stacks but **never re-synced FileBrowser**, so its drive mounts went stale after a reboot (FileBrowser
binds each drive's `userdata` but is base-infra with no `HDD_PATH`, so it is not in the recreate set).
Now, **after** `pollLiveBinds` confirms the live binds and the apps are recreated, the boot-recreate path
triggers `go s.SyncFileBrowserMounts()` so FileBrowser converges against the now-live drives (the sync
runs unconditionally so FileBrowser reflects the current bind state even if no app needed recreating).
Refactored the recreate loop into a pure, testable `recreateDriveBackedApps(stacks, present, recreate,
syncFB)`. Tests: FileBrowser sync invoked once, AFTER every recreate (red-proofed companion: pre-fix path
never synced); and synced even when nothing was recreated. Pairs with felhom-agent's host-reboot
remount-by-UUID fix; live-accepted with a real `felhom-pve` reboot.
### v0.71.0 — fix guest-reboot recovery of drive-backed apps (boot-race + the agent-path blocker) (2026-06-16) ### v0.71.0 — fix guest-reboot recovery of drive-backed apps (boot-race + the agent-path blocker) (2026-06-16)
A `pct reboot` of the guest left drive-backed apps (audiobookshelf, calibre-web, immich-server, A `pct reboot` of the guest left drive-backed apps (audiobookshelf, calibre-web, immich-server,
+42 -11
View File
@@ -370,24 +370,26 @@ func (s *Server) processGuestBootChange() {
s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", resp.GuestBootID, bootBindWait, paths) s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", resp.GuestBootID, bootBindWait, paths)
presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll) presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll)
skipped := 0 var bootStacks []bootStack
for _, st := range s.stackMgr.GetStacks() { for _, st := range s.stackMgr.GetStacks() {
cfg := s.stackMgr.LoadAppConfigByName(st.Name) cfg := s.stackMgr.LoadAppConfigByName(st.Name)
if cfg == nil { if cfg == nil {
continue continue
} }
if !shouldRecreateOnBoot(cfg.Deployed, cfg.Env["HDD_PATH"], presentStable) { bootStacks = append(bootStacks, bootStack{name: st.Name, deployed: cfg.Deployed, hdd: cfg.Env["HDD_PATH"], state: string(st.State)})
if cfg.Deployed && strings.HasPrefix(cfg.Env["HDD_PATH"], StableParentDir+"/") { }
skipped++ // a deployed drive-backed app whose bind never went live → gate's job recreate := func(bs bootStack) {
} s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd)
continue _ = s.stackMgr.StopStack(bs.name)
} if serr := s.stackMgr.StartStack(bs.name); serr != nil {
s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, st.Name, st.State, cfg.Env["HDD_PATH"]) s.logger.Printf("[WARN] [gate] boot recreate %s: %v", bs.name, serr)
_ = s.stackMgr.StopStack(st.Name)
if serr := s.stackMgr.StartStack(st.Name); serr != nil {
s.logger.Printf("[WARN] [gate] boot recreate %s: %v", st.Name, serr)
} }
} }
syncFB := func() {
s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", resp.GuestBootID)
go s.SyncFileBrowserMounts()
}
_, skipped := recreateDriveBackedApps(bootStacks, presentStable, recreate, syncFB)
if skipped > 0 { if skipped > 0 {
s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", resp.GuestBootID, skipped, bootBindWait) s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", resp.GuestBootID, skipped, bootBindWait)
} }
@@ -396,6 +398,35 @@ func (s *Server) processGuestBootChange() {
} }
} }
// bootStack is one deployed stack's boot-recreate inputs (decoupled from stacks.Manager for testing).
type bootStack struct {
name string
deployed bool
hdd string
state string
}
// recreateDriveBackedApps recreates every deployed drive-backed app whose drive bind is live, then
// triggers the FileBrowser sync. FileBrowser binds the drives' userdata but is base-infra (no HDD_PATH),
// so it is NOT in the recreate set — it must be converged HERE, AFTER the recreate (which itself only
// ran once pollLiveBinds confirmed the live binds), so FileBrowser's mounts reflect the now-live drives
// instead of going stale (the gap a host/guest reboot left before this fix). syncFB is always called so
// FileBrowser reflects the current bind state even if no app needed recreating. Pure (ops injected).
func recreateDriveBackedApps(stacks []bootStack, presentStable map[string]bool, recreate func(bootStack), syncFB func()) (recreated, skipped int) {
for _, bs := range stacks {
if !shouldRecreateOnBoot(bs.deployed, bs.hdd, presentStable) {
if bs.deployed && strings.HasPrefix(bs.hdd, StableParentDir+"/") {
skipped++ // a deployed drive-backed app whose bind never went live → gate's job
}
continue
}
recreate(bs)
recreated++
}
syncFB()
return
}
// ---- H1 endpoints (the UI's settings.js calls these; previously 404/unrouted) ----------------- // ---- H1 endpoints (the UI's settings.js calls these; previously 404/unrouted) -----------------
// handleStorageDisconnect EJECTS a drive without restart: stop its apps (gate-stopped), agent-detach the // handleStorageDisconnect EJECTS a drive without restart: stop its apps (gate-stopped), agent-detach the
@@ -139,6 +139,43 @@ func TestPollLiveBinds_TimeoutLeavesAbsent(t *testing.T) {
} }
} }
// TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate (Task B): FileBrowser must be re-synced
// AFTER the drive-backed apps are recreated (so it syncs against live binds). COMPANION: the pre-fix
// boot-recreate path never synced FileBrowser — red-proofed by dropping the syncFB() call.
func TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate(t *testing.T) {
flash := "/mnt/felhom-drives/felhom-flash"
present := map[string]bool{flash: true}
stacks := []bootStack{
{name: "romm", deployed: true, hdd: flash}, // drive-backed, live → recreate
{name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}, // SSD → not recreated
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb"}, // drive-backed, bind NOT live → skipped
}
var seq []string
recreate := func(bs bootStack) { seq = append(seq, "recreate:"+bs.name) }
syncFB := func() { seq = append(seq, "syncFB") }
recreated, skipped := recreateDriveBackedApps(stacks, present, recreate, syncFB)
if recreated != 1 || skipped != 1 {
t.Fatalf("recreated=%d skipped=%d, want 1/1", recreated, skipped)
}
// FileBrowser sync MUST be invoked, and AFTER every recreate.
if len(seq) != 2 || seq[0] != "recreate:romm" || seq[len(seq)-1] != "syncFB" {
t.Fatalf("FileBrowser sync must run once, AFTER the recreate; seq=%v", seq)
}
}
// TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate: FileBrowser is re-synced to reflect the live
// binds even when no app needed recreating (so its mounts never go stale).
func TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate(t *testing.T) {
stacks := []bootStack{{name: "x", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}} // SSD only
synced := false
recreateDriveBackedApps(stacks, map[string]bool{}, func(bootStack) { t.Fatal("should not recreate") }, func() { synced = true })
if !synced {
t.Fatal("FileBrowser sync must run even when nothing was recreated")
}
}
func TestStablePathForName(t *testing.T) { func TestStablePathForName(t *testing.T) {
if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" { if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" {
t.Errorf("stablePathForName = %q", got) t.Errorf("stablePathForName = %q", got)