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:
@@ -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)
|
||||
presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll)
|
||||
|
||||
skipped := 0
|
||||
var bootStacks []bootStack
|
||||
for _, st := range s.stackMgr.GetStacks() {
|
||||
cfg := s.stackMgr.LoadAppConfigByName(st.Name)
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
if !shouldRecreateOnBoot(cfg.Deployed, cfg.Env["HDD_PATH"], presentStable) {
|
||||
if cfg.Deployed && strings.HasPrefix(cfg.Env["HDD_PATH"], StableParentDir+"/") {
|
||||
skipped++ // a deployed drive-backed app whose bind never went live → gate's job
|
||||
}
|
||||
continue
|
||||
}
|
||||
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.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)
|
||||
bootStacks = append(bootStacks, bootStack{name: st.Name, deployed: cfg.Deployed, hdd: cfg.Env["HDD_PATH"], state: string(st.State)})
|
||||
}
|
||||
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)
|
||||
_ = s.stackMgr.StopStack(bs.name)
|
||||
if serr := s.stackMgr.StartStack(bs.name); serr != nil {
|
||||
s.logger.Printf("[WARN] [gate] boot recreate %s: %v", bs.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 {
|
||||
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) -----------------
|
||||
|
||||
// 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) {
|
||||
if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" {
|
||||
t.Errorf("stablePathForName = %q", got)
|
||||
|
||||
Reference in New Issue
Block a user