package storage import ( "encoding/json" "fmt" "os" "path/filepath" "sync" ) // Drive INTENT model (slice 10 P3 self-heal). The agent persists, per external user-data drive // (keyed by DURABLE-ID — UUID/WWN, never sdX/path), the operator/customer INTENT — which is distinct // from "is it currently mounted". The self-heal reconciler acts ONLY on this intent, so an // out-of-band unmount (a colleague's Proxmox action) — which records NO intent — gets healed, while an // official eject is respected. // // Four states (3A): // - new : durable-id NOT in the store. Never auto-mounted (the user must enroll it). // - enrolled : desired = mounted + bound into the guest. Drift (present && unmounted) with // this intent → reconcile. // - ejected : an intentional, temporary unmount, set ONLY via the official eject endpoint. // Present && unmounted → left alone. CLEARED to `enrolled` when the drive goes // physically ABSENT, so a replug auto-mounts (the replug rule, for free). // - decommissioned : permanent. Never auto-mounted; SURVIVES absent/present; cleared only by an // explicit re-commission. // // The elegant invariant: intent is recorded ONLY through the official enroll/eject/decommission // paths. "Is it unmounted" ≠ "did someone officially eject it." type DriveIntent string const ( IntentNew DriveIntent = "" // not in the store IntentEnrolled DriveIntent = "enrolled" // desired mounted → reconcile drift IntentEjected DriveIntent = "ejected" // intentional unmount → leave alone IntentDecommissioned DriveIntent = "decommissioned" // permanent → never auto-mount ) // IntentStore is the durable, durable-id-keyed intent map. Thread-safe; atomic file writes // (tmp+rename), 0600. Shared between the local-API (records enroll/eject) and the self-heal // reconciler (reads to gate remounts; clears `ejected` on absent). type IntentStore struct { path string mu sync.Mutex m map[string]DriveIntent // durable-id -> intent } // OpenIntentStore loads (or initializes) the store at path. A missing file is an empty store; a // corrupt file is an error (fail loud — the reconciler's gate must not silently lose intent). func OpenIntentStore(path string) (*IntentStore, error) { s := &IntentStore{path: path, m: map[string]DriveIntent{}} data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return s, nil } return nil, fmt.Errorf("intent store: read %s: %w", path, err) } if len(data) > 0 { if err := json.Unmarshal(data, &s.m); err != nil { return nil, fmt.Errorf("intent store: parse %s: %w", path, err) } } return s, nil } // Get returns the intent for a durable-id (IntentNew if absent). An empty durable-id is always // IntentNew — the reconciler must never act on a drive whose identity it can't pin. func (s *IntentStore) Get(durableID string) DriveIntent { if durableID == "" { return IntentNew } s.mu.Lock() defer s.mu.Unlock() return s.m[durableID] } // SetEnrolled records a drive as enrolled (on successful enroll/guest-attach). Idempotent. func (s *IntentStore) SetEnrolled(durableID string) error { return s.set(durableID, IntentEnrolled) } // SetEjected records an intentional eject (official eject endpoint only). func (s *IntentStore) SetEjected(durableID string) error { return s.set(durableID, IntentEjected) } // SetDecommissioned records a permanent decommission (operator path). func (s *IntentStore) SetDecommissioned(durableID string) error { return s.set(durableID, IntentDecommissioned) } // OnAbsent transitions a drive that has gone physically ABSENT: an `ejected` drive becomes `enrolled` // again (so a replug auto-mounts — the replug rule); `decommissioned` and `enrolled` are unchanged; // `new` stays new. This is the ONLY place ejected→enrolled happens. func (s *IntentStore) OnAbsent(durableID string) error { if durableID == "" { return nil } s.mu.Lock() defer s.mu.Unlock() if s.m[durableID] == IntentEjected { s.m[durableID] = IntentEnrolled return s.saveLocked() } return nil } func (s *IntentStore) set(durableID string, intent DriveIntent) error { if durableID == "" { return fmt.Errorf("intent store: refusing to record intent for an empty durable-id") } s.mu.Lock() defer s.mu.Unlock() if s.m[durableID] == intent { return nil // idempotent — no write } s.m[durableID] = intent return s.saveLocked() } func (s *IntentStore) saveLocked() error { data, err := json.MarshalIndent(s.m, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, s.path) }