package localapi import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "sync" ) // GuestBindStore persists, per guest, the DURABLE-IDs of the user-data drives enrolled (guest-attached) // into it. F9: the in-guest bind (`pct set -mpN`) is config state that does NOT survive a destroy + // re-provision, and nothing re-asserted it — so a re-provisioned guest came up with the HDD unattached // even though it had been enrolled. This store is the record the startup re-assert (ReassertGuestBinds) // replays: for each enrolled durable-id still physically present, re-add the bind if the guest lacks it. // // Keyed by durable-id (NOT host path or sdX) so the re-assert is "on durable-id match" — a swapped or // absent drive is never auto-bound. Thread-safe; atomic file writes (tmp+rename), 0600. Mirrors // storage.IntentStore. type GuestBindStore struct { path string mu sync.Mutex m map[int][]string // vmid -> sorted set of enrolled durable-ids } // OpenGuestBindStore loads (or initializes) the store. Missing file = empty store; corrupt file = error // (fail loud — losing a bind record would silently drop the re-assert). func OpenGuestBindStore(path string) (*GuestBindStore, error) { s := &GuestBindStore{path: path, m: map[int][]string{}} data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return s, nil } return nil, fmt.Errorf("guest-bind store: read %s: %w", path, err) } if len(data) > 0 { // stored as {"": ["durable-id", ...]} (string keys — JSON object keys are strings) raw := map[string][]string{} if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("guest-bind store: parse %s: %w", path, err) } for k, v := range raw { vmid, err := strconv.Atoi(k) if err != nil { return nil, fmt.Errorf("guest-bind store: bad vmid key %q: %w", k, err) } s.m[vmid] = v } } return s, nil } // Record adds (vmid, durableID) to the enrolled set. Idempotent — no write if already present. A blank // durable-id is refused (the re-assert must never act on a drive whose identity it can't pin). func (s *GuestBindStore) Record(vmid int, durableID string) error { if durableID == "" { return fmt.Errorf("guest-bind store: refusing to record an empty durable-id for vmid %d", vmid) } s.mu.Lock() defer s.mu.Unlock() for _, id := range s.m[vmid] { if id == durableID { return nil // idempotent } } s.m[vmid] = append(s.m[vmid], durableID) sort.Strings(s.m[vmid]) return s.saveLocked() } // Remove drops (vmid, durableID) from the enrolled set. Idempotent — absent (vmid or id) is a no-op // returning nil. Atomic write (tmp+rename) like Record/saveLocked. Called by the self-serve // decommission endpoint so a permanently-removed drive no longer lingers in the startup re-assert // record (hygiene — the intent-aware ReassertGuestBinds is the load-bearing guard). func (s *GuestBindStore) Remove(vmid int, durableID string) error { s.mu.Lock() defer s.mu.Unlock() ids, ok := s.m[vmid] if !ok { return nil } kept := ids[:0:0] found := false for _, id := range ids { if id == durableID { found = true continue } kept = append(kept, id) } if !found { return nil // idempotent: nothing to remove } if len(kept) == 0 { delete(s.m, vmid) } else { s.m[vmid] = kept } return s.saveLocked() } // Guests returns a copy of the vmid → enrolled-durable-ids map. func (s *GuestBindStore) Guests() map[int][]string { s.mu.Lock() defer s.mu.Unlock() out := make(map[int][]string, len(s.m)) for vmid, ids := range s.m { out[vmid] = append([]string(nil), ids...) } return out } func (s *GuestBindStore) saveLocked() error { raw := make(map[string][]string, len(s.m)) for vmid, ids := range s.m { raw[strconv.Itoa(vmid)] = ids } data, err := json.MarshalIndent(raw, "", " ") 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) }