F9: auto-re-assert enrolled guest data-drive binds on agent startup
The in-guest bind (pct set -mpN) is config state that a destroy+re-provision drops, and nothing restored it — so a re-provisioned guest came up with its enrolled HDD unattached (the live-drive F9 finding). New GuestBindStore persists, per guest, the durable-ids of enrolled drives (recorded at guest-attach); ReassertGuestBinds runs on agent startup (the host's bring-up/reconcile trigger) and re-adds any bind a guest is MISSING — but ONLY when the durable-id still resolves to a present, mounted drive (a swapped/absent drive is never auto-bound) and the guest lacks it (idempotent). The re-added bind activates on the guest's next reboot, like the enroll flow. Wired in main.go (store opened beside drive-intents.json; ReassertGuestBinds called before the local API serves). Tests: restores a missing bind with no manual call (the operator's real-trigger proof); skips absent/swapped durable-id; no-op when already bound; store survives reopen (restart).
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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 {"<vmid>": ["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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user