package backup import ( "os" "strings" "time" ) // R-897 (2026-10-07): after a host restart the agent binds each drive under /mnt/felhom-drives, and when the guest // started after that first bind it NORMALIZES it once more (umount + mount, felhom-agent localapi/intermediary.go). A // recovery-unit capture that ran in that second failed — `mkdir /mnt/felhom-drives/hdd_1/backups: permission // denied` — and the operator got a backup failure for a healthy app. The kernel lane restarts boxes at night, so this // would have come after every kernel step. // // The rule: for DriveWaitWindow after the controller starts, a capture for an app on a drive runs only once that // drive's bind is LIVE in this process's mount namespace (the same signal the startup app gate uses, // web/intermediary.go driveBindLive). A periodic refresh skips the app until then (it runs every 5 minutes anyway); // a data run waits for it. After the window the capture runs whatever the bind says, and says so once at WARN. The // agent's bind order is unchanged. Pinned by driveready_test.go. // DriveWaitWindow is how long after the controller starts a capture waits for a drive's bind. const DriveWaitWindow = 10 * time.Minute const drivesParent = "/mnt/felhom-drives/" // driveRootOf is "/mnt/felhom-drives/" for a path under it, "" for any other path (the system-data SSD). func driveRootOf(p string) string { if !strings.HasPrefix(p, drivesParent) { return "" } name := strings.SplitN(strings.TrimPrefix(p, drivesParent), "/", 2)[0] if name == "" { return "" } return drivesParent + name } // mountinfoHasTarget reports whether root is a mount target in this process's own mount namespace. func mountinfoHasTarget(root string) bool { data, err := os.ReadFile("/proc/self/mountinfo") if err != nil { return false } for _, line := range strings.Split(string(data), "\n") { if f := strings.Fields(line); len(f) >= 5 && f[4] == root { return true } } return false } func (m *Manager) now() time.Time { if m.nowFn != nil { return m.nowFn() } return time.Now() } // driveReady says whether a capture for an app at drivePath may run now (true outside the post-boot window, for a // non-drive path, or once the bind is live). dataRun: wait (poll) for the bind until the window ends instead of // skipping. func (m *Manager) driveReady(drivePath string, dataRun bool) bool { root := driveRootOf(drivePath) if root == "" || m.startedAt.IsZero() { return true } live := m.driveLive if live == nil { live = mountinfoHasTarget } deadline := m.startedAt.Add(DriveWaitWindow) for { if live(root) { return true } if !m.now().Before(deadline) { m.driveMu.Lock() warned := m.driveWarned[root] if m.driveWarned == nil { m.driveWarned = map[string]bool{} } m.driveWarned[root] = true m.driveMu.Unlock() if !warned { m.logger.Printf("[WARN] [backup] %s is still not bound live %s after the controller started — capturing anyway (R-897)", root, DriveWaitWindow) } return true } if !dataRun { if m.isDebug() { m.logger.Printf("[DEBUG] [backup] recovery-unit refresh waits for %s — its bind is not live yet after the start (R-897)", root) } return false } sleep := m.driveWaitPoll if sleep == nil { sleep = time.Sleep } m.logger.Printf("[INFO] [backup] waiting for %s to be bound live before the capture (post-boot, R-897)", root) sleep(5 * time.Second) } }