237b85f420
IntentStore (durable-id-keyed: new/enrolled/ejected/decommissioned, OnAbsent replug rule). Watchdog re-mounts only enrolled drives (out-of-band unmount heals; ejected/decommissioned/new left alone) + exp-backoff flapping guard (alert@4, cap@8). guest-attach records enrolled; eject records ejected. Non-hollow tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
4.8 KiB
Go
132 lines
4.8 KiB
Go
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)
|
|
}
|