package storage import ( "context" "log/slog" "net" "sync" "time" ) // Default watchdog timings (configurable via WatchdogOptions). The poll is FAST (seconds) // so a USB drop is caught in seconds, not at the slow ~15-minute host-report cycle; the // debounce keeps a flapping drive from storming the hub. const ( DefaultWatchdogInterval = 8 * time.Second DefaultWatchdogDebounce = 30 * time.Second ) // KnownTarget is the watchdog's lightweight view of a target it watches. "Known" means a // defined Proxmox storage (and/or a previously-observed-attached one); the watchdog only // flags transitions for targets it has seen — it never reports a never-attached device. type KnownTarget struct { Name string Type string DurableID string UUID string // fs-UUID (mount-backed targets) — the by-UUID re-mount key Network bool // nfs/cifs/pbs — liveness is a reachability dial, not a device check MountBacked bool // usb/local-dir — a drop = its mountpoint disappears BackingDevice string // resolved block device (local targets) MountPath string // the mountpoint a mount-backed target must occupy ReachEndpoint string // host:port to dial for a network target's reachability } // KnownTargets enumerates the currently-known target set. Production wraps the Observer in // CachingKnownTargets so the fast poll doesn't hammer the Proxmox API. type KnownTargets interface { Known(ctx context.Context) ([]KnownTarget, error) } // TargetLiveness reports a known target's liveness. Production is HostLiveness (device/mount // presence + a reachability dial, all non-privileged); tests inject a fake. type TargetLiveness interface { // Present is the "in service" signal: mounted + reachable. Present(ctx context.Context, t KnownTarget) bool // DevicePresent is the "backing device is physically back" signal, independent of // whether it is mounted — the trigger for a benign re-mount of a returned drive. DevicePresent(ctx context.Context, t KnownTarget) bool } // Remounter performs the benign re-mount response when a known mount-backed target's device // returns but its mountpoint is missing. The watchdog dispatches to it OFF its poll path // (a goroutine), never synchronously under the lock. Production routes through the gate // (benign) then HostOps.EnsureMount; wired in main.go so storage stays decoupled from // reconcile. A nil Remounter disables the response (observe-only, Phase-A behaviour). type Remounter interface { Remount(ctx context.Context, t KnownTarget) } // Transition is one observed state change for a known target (for logging/diagnostics). type Transition struct { Name string From string // attached | disconnected To string } // IntentReader gates self-heal by the drive's INTENT (slice 10 P3): the watchdog reconciles // (re-mounts) ONLY a drive whose intent is `enrolled`. Satisfied by *IntentStore. When nil, the // watchdog is ungated (legacy observe+remount-any behaviour) — production always wires it. type IntentReader interface { Get(durableID string) DriveIntent } // Flapping guard (3C): a drive whose re-mount keeps not sticking gets exponential backoff, an alert // after AlertThreshold consecutive failed cycles, and stops being retried after MaxRetries — instead // of looping forever. Reset when the drive goes present (healed) or absent (gone, not flapping). const ( flappingAlertThreshold = 4 // consecutive failed re-mount cycles before raising the alert flappingMaxRetries = 8 // after this many, stop retrying (alert stands until present/absent) flappingBackoffCap = 5 // backoff window = debounce * 2^min(fails, cap) ) // Watchdog is the third daemon goroutine (alongside the hub loop + reconcile engine). It // fast-polls the known target set, detects attached↔disconnected transitions, and triggers // an immediate, debounced out-of-band host-report so the hub learns of a drop in seconds. // // It NEVER mutates anything (Phase A is read-only) — the benign re-mount-by-UUID response // to a return lands in Phase B. Here it only observes and signals. type Watchdog struct { targets KnownTargets liveness TargetLiveness remounter Remounter // may be nil (observe-only) interval time.Duration debounce time.Duration trigger func() // request an out-of-band report (debounced by the watchdog) logger *slog.Logger now func() time.Time spawn func(func()) // spawn a background task (overridable in tests; default `go f()`) intent IntentReader // P3: gate re-mount by drive intent (nil = ungated legacy) onAbsent func(durableID string) // P3: called when a known target goes ABSENT (clears `ejected`) mu sync.Mutex last map[string]bool // name -> last observed present (only for seen targets) lastFire time.Time fired bool // lastFire is valid pending bool // a transition is awaiting the debounce window lastRemount map[string]time.Time // name -> last re-mount dispatch (rate-limit / backoff base) lastUUID map[string]string // name -> last fs-UUID observed while ATTACHED (re-mount key) remountFails map[string]int // name -> consecutive failed re-mount cycles (flapping guard) remountPending map[string]bool // name -> a re-mount was dispatched, awaiting present-confirm flapAlerted map[string]bool // name -> the "keeps dropping" alert has fired (once) } // WatchdogOptions configures a Watchdog. Targets, Liveness and Trigger are required; the // rest default. Remounter is optional (nil = observe-only). type WatchdogOptions struct { Targets KnownTargets Liveness TargetLiveness Remounter Remounter Trigger func() Interval time.Duration Debounce time.Duration Logger *slog.Logger // Intent gates self-heal to `enrolled` drives (slice 10 P3). OnAbsent is called (durable-id) when a // known target goes physically absent, so the intent store can clear an `ejected` flag (replug // rule). Both optional — nil Intent = ungated legacy remount; nil OnAbsent = no intent clearing. Intent IntentReader OnAbsent func(durableID string) } // NewWatchdog builds a Watchdog. A nil Trigger is tolerated (the watchdog still tracks // state, just signals nothing) so it degrades cleanly when no report sink is wired. func NewWatchdog(opts WatchdogOptions) *Watchdog { interval := opts.Interval if interval <= 0 { interval = DefaultWatchdogInterval } debounce := opts.Debounce if debounce <= 0 { debounce = DefaultWatchdogDebounce } logger := opts.Logger if logger == nil { logger = slog.Default() } trigger := opts.Trigger if trigger == nil { trigger = func() {} } return &Watchdog{ targets: opts.Targets, liveness: opts.Liveness, remounter: opts.Remounter, intent: opts.Intent, onAbsent: opts.OnAbsent, interval: interval, debounce: debounce, trigger: trigger, logger: logger, now: func() time.Time { return time.Now().UTC() }, spawn: func(f func()) { go f() }, last: map[string]bool{}, lastRemount: map[string]time.Time{}, lastUUID: map[string]string{}, remountFails: map[string]int{}, remountPending: map[string]bool{}, flapAlerted: map[string]bool{}, } } // reconcileAllowed reports whether self-heal may re-mount this target: only an `enrolled` drive is // auto-mounted (3C: never auto-adopt a new/unknown drive; respect an intentional eject; never touch a // decommissioned drive). An ungated watchdog (no intent reader) allows it (legacy). func (w *Watchdog) reconcileAllowed(t KnownTarget) bool { if w.intent == nil { return true } return w.intent.Get(t.DurableID) == IntentEnrolled } // backoffWindow returns the re-mount backoff for a target with `fails` consecutive failed cycles: // debounce * 2^min(fails, cap). fails==0 → the plain debounce. func (w *Watchdog) backoffWindow(fails int) time.Duration { if fails > flappingBackoffCap { fails = flappingBackoffCap } win := w.debounce for i := 0; i < fails; i++ { win *= 2 } return win } // Run fast-polls until ctx is cancelled. The first tick establishes the baseline (no // trigger); subsequent ticks detect transitions. Returns nil on ctx cancellation. func (w *Watchdog) Run(ctx context.Context) error { if w.targets == nil || w.liveness == nil { w.logger.Info("storage: watchdog idle (no target source / liveness probe configured)") <-ctx.Done() return nil } w.logger.Info("storage: watchdog starting", "interval", w.interval, "debounce", w.debounce) t := time.NewTicker(w.interval) defer t.Stop() w.tick(ctx) // immediate baseline for { select { case <-ctx.Done(): w.logger.Info("storage: watchdog shutting down", "reason", ctx.Err()) return nil case <-t.C: w.tick(ctx) } } } // tick performs one poll. Structure: probe liveness OUTSIDE the lock (the probes do IO — // mount reads, dials), then take the lock only for the state diff + debounce decision, then // perform side-effects (report trigger, re-mount dispatch) AFTER unlocking. The re-mount is // handed to a background task — never run synchronously under the lock or on the poll path. // Deterministic given w.now — tests drive it directly with a fake clock. func (w *Watchdog) tick(ctx context.Context) { known, err := w.targets.Known(ctx) if err != nil { w.logger.Warn("storage: watchdog could not read known targets; skipping tick", "err", err) return } // Remember each target's fs-UUID while it is observable (attached), and backfill it // onto a target the current observe couldn't resolve (the unmounted case loses it). The // re-mount key is "sourced from the existing definition" (doc 03 §7): the agent learns // the UUID while attached, so a drop+return cycle can re-mount by-UUID even after the // known-set cache refreshed mid-drop. Single-goroutine (tick), guarded for -race. w.mu.Lock() for i := range known { if known[i].UUID != "" { w.lastUUID[known[i].Name] = known[i].UUID } else if u := w.lastUUID[known[i].Name]; u != "" { known[i].UUID = u } } w.mu.Unlock() // Probe outside the lock. type probe struct { t KnownTarget present bool devicePresent bool } probes := make([]probe, 0, len(known)) for _, k := range known { p := probe{t: k, present: w.liveness.Present(ctx, k)} if k.MountBacked && !p.present { p.devicePresent = w.liveness.DevicePresent(ctx, k) } probes = append(probes, p) } now := w.now() w.mu.Lock() var transitions []Transition var remounts []KnownTarget current := make(map[string]bool, len(probes)) var absentNow []KnownTarget // P3: targets that just went physically ABSENT (clear `ejected`) var flapAlerts []KnownTarget // P3: "drive keeps dropping" alerts to surface for _, p := range probes { current[p.t.Name] = p.present if prev, seen := w.last[p.t.Name]; seen && prev != p.present { transitions = append(transitions, Transition{Name: p.t.Name, From: stateStr(prev), To: stateStr(p.present)}) // Physically ABSENT (device gone, not merely unmounted): a mount-backed target that went // !present AND whose device is no longer present. The intent store clears `ejected` here so // a replug auto-mounts (the replug rule); flapping state resets (it's gone, not flapping). if prev && !p.present && p.t.MountBacked && !p.devicePresent { absentNow = append(absentNow, p.t) w.resetFlap(p.t.Name) } } // Re-mount candidate (self-heal): an ENROLLED, mount-backed target that is NOT mounted but // whose backing device IS physically present (out-of-band unmount → device-still-there). Gated // by intent (never auto-adopt a new/ejected/decommissioned drive) + an exponential-backoff // flapping guard (3C): a re-mount that doesn't stick (still !present next cycle) counts as a // failure → grow the backoff; alert after AlertThreshold; stop after MaxRetries. if w.remounter != nil && p.t.MountBacked && !p.present && p.devicePresent && w.reconcileAllowed(p.t) { fails := w.remountFails[p.t.Name] // Only act once a full backoff window has elapsed since the last dispatch (or the first // time). Gating the WHOLE evaluation on the window means a re-mount that's merely slow // (the dispatch is async, ticks are fast) isn't miscounted as a failure — a failure is // "still not present a full window after we dispatched". if last, ok := w.lastRemount[p.t.Name]; !ok || now.Sub(last) >= w.backoffWindow(fails) { if w.remountPending[p.t.Name] { // prior dispatch didn't take within its window → failed fails++ w.remountFails[p.t.Name] = fails w.remountPending[p.t.Name] = false } if fails >= flappingAlertThreshold && !w.flapAlerted[p.t.Name] { w.flapAlerted[p.t.Name] = true flapAlerts = append(flapAlerts, p.t) } if fails < flappingMaxRetries { w.lastRemount[p.t.Name] = now w.remountPending[p.t.Name] = true remounts = append(remounts, p.t) } } } // Healed (present again): reset re-mount rate-limit + flapping state so a future drop re-mounts // promptly and the alert can re-fire. if p.present { w.resetFlap(p.t.Name) } } w.last = current // targets no longer known drop out doFire := false if len(transitions) > 0 { if !w.fired || now.Sub(w.lastFire) >= w.debounce { doFire = true w.lastFire, w.fired, w.pending = now, true, false } else { w.pending = true } } else if w.pending && now.Sub(w.lastFire) >= w.debounce { doFire = true w.lastFire, w.pending = now, false } w.mu.Unlock() // Side-effects, off the lock. for _, tr := range transitions { w.logger.Warn("storage: watchdog detected target state change", "target", tr.Name, "from", tr.From, "to", tr.To) } if doFire { w.logger.Info("storage: watchdog triggering out-of-band host-report", "transitions", len(transitions)) w.trigger() } for _, t := range remounts { t := t w.logger.Info("storage: watchdog self-heal — re-mounting enrolled drive (device returned, no eject intent)", "target", t.Name, "where", t.MountPath, "durable_id", t.DurableID) w.spawn(func() { w.remounter.Remount(ctx, t) }) } // P3: a drive that went physically absent — clear an `ejected` intent so a replug auto-mounts. for _, t := range absentNow { if w.onAbsent != nil && t.DurableID != "" { w.logger.Info("storage: watchdog — known drive went absent; clearing any eject intent (replug will auto-mount)", "target", t.Name, "durable_id", t.DurableID) w.onAbsent(t.DurableID) } } // P3 flapping guard: a drive whose re-mount keeps not sticking — alert instead of looping silently. for _, t := range flapAlerts { w.logger.Warn("storage: watchdog ALERT — drive keeps dropping (re-mount not sticking); backing off", "target", t.Name, "where", t.MountPath, "durable_id", t.DurableID, "failed_cycles", flappingAlertThreshold) w.trigger() // surface out-of-band so the hub/operator sees the unhealthy drive } } // resetFlap clears all per-target re-mount/backoff/alert state (target healed or went absent). The // caller holds w.mu. func (w *Watchdog) resetFlap(name string) { delete(w.lastRemount, name) delete(w.remountFails, name) delete(w.remountPending, name) delete(w.flapAlerted, name) } func stateStr(present bool) string { if present { return "attached" } return "disconnected" } // --- production liveness + a caching known-target source --- // HostLiveness is the production TargetLiveness: device + mount presence for local // targets (the fast USB-drop signal) and a short reachability dial for network targets. // All non-privileged. type HostLiveness struct { host HostReader dialTimeout time.Duration dial func(network, address string, timeout time.Duration) (net.Conn, error) } // NewHostLiveness builds a HostLiveness over a HostReader. dialTimeout defaults to 3s. func NewHostLiveness(host HostReader, dialTimeout time.Duration) *HostLiveness { if host == nil { host = NewProcHostReader() } if dialTimeout <= 0 { dialTimeout = 3 * time.Second } return &HostLiveness{host: host, dialTimeout: dialTimeout, dial: net.DialTimeout} } // Present probes one target without touching Proxmox. func (h *HostLiveness) Present(ctx context.Context, t KnownTarget) bool { if t.Network { if t.ReachEndpoint == "" { return true // can't probe → don't false-alarm; the 15-min cycle uses the active flag } conn, err := h.dial("tcp", t.ReachEndpoint, h.dialTimeout) if err != nil { return false } _ = conn.Close() return true } if t.MountBacked { // A mount-backed target (USB / extra disk) is present iff its mountpoint is an // active mount AND the backing device node exists. if !h.mounted(t.MountPath) { return false } return t.BackingDevice == "" || h.host.DeviceExists(t.BackingDevice) } // Non-removable builtin targets (local/lvmthin): treated as present here — they don't // "drop" without the whole host going down, which the heartbeat covers. return true } // DevicePresent reports whether the backing device is physically present (regardless of // mount state) — the re-mount trigger. Checks /dev/disk/by-uuid/ first (the by-UUID // link appears when the drive is plugged), then any known backing-device node. func (h *HostLiveness) DevicePresent(ctx context.Context, t KnownTarget) bool { if !t.MountBacked { return false } if t.UUID != "" { if dev, err := ByUUIDDevicePath(t.UUID); err == nil && h.host.DeviceExists(dev) { return true } } return t.BackingDevice != "" && h.host.DeviceExists(t.BackingDevice) } func (h *HostLiveness) mounted(path string) bool { if path == "" { return false } mounts, err := h.host.Mounts() if err != nil { return false } _, _, ok := exactMountDevice(mounts, path) return ok } // CachingKnownTargets wraps a slow KnownTargets source (the Observer, which hits Proxmox) // with a TTL so the fast watchdog poll re-derives the known SET only every ttl, while // still probing liveness every tick. A read error returns the last good set (so a // transient Proxmox blip doesn't blank the watchdog's world). type CachingKnownTargets struct { src KnownTargets ttl time.Duration now func() time.Time mu sync.Mutex cached []KnownTarget at time.Time loaded bool } // NewCachingKnownTargets wraps src, refreshing at most every ttl (default 60s). func NewCachingKnownTargets(src KnownTargets, ttl time.Duration) *CachingKnownTargets { if ttl <= 0 { ttl = 60 * time.Second } return &CachingKnownTargets{src: src, ttl: ttl, now: func() time.Time { return time.Now().UTC() }} } // Known returns the cached set, refreshing it when the TTL has elapsed. func (c *CachingKnownTargets) Known(ctx context.Context) ([]KnownTarget, error) { c.mu.Lock() defer c.mu.Unlock() now := c.now() if c.loaded && now.Sub(c.at) < c.ttl { return c.cached, nil } fresh, err := c.src.Known(ctx) if err != nil { if c.loaded { return c.cached, nil // serve stale rather than blank on a transient error } return nil, err } c.cached, c.at, c.loaded = fresh, now, true return c.cached, nil }