Files
admin 237b85f420 agent v0.27.0: slice 10 P3 — self-heal watchdog reconcile + 4-state intent model
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>
2026-06-12 17:49:25 +02:00

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)
}