// Package dr consumes the host-loss restore_directive (slice 10D / S5) into an inspectable restore // PLAN. It is DERIVE-AND-SURFACE only: the plan is logged (and exposed for the report), never // executed — the destructive restore is a separate, operator-present, STOP-gated step. The Consumer // has NO restore/destroy API by construction, so "execute nothing" is a structural guarantee. package dr import ( "context" "log/slog" "sync" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // RestorePlan is the derived-but-not-executed host-loss plan: per guest → restore coords + sizing; // per drive → durable_id → expected mount. No secrets (coordinates/identifiers/sizes only). type RestorePlan struct { Mode string `json:"mode"` Guests []PlannedGuest `json:"guests"` Drives []PlannedDrive `json:"drives"` PBS *hub.DRPBSCoord `json:"pbs,omitempty"` // WHERE the offsite backups live (repo/ns/latest snapshot) } // PlannedGuest is one guest to restore in place, from the offsite datastore, at its original sizing. type PlannedGuest struct { VMID int `json:"vmid"` Archive string `json:"archive,omitempty"` // explicit archive from the directive; "" → resolve latest at restore time TargetStorage string `json:"target_storage"` // where the restored volumes land (e.g. local-lvm) Cores int `json:"cores"` MemoryBytes int64 `json:"memory_bytes"` DiskBytes int64 `json:"disk_bytes"` } // PlannedDrive is one data drive to re-attach BY DURABLE_ID (the wrong-disk guard: a match attaches, // a non-match is refused — the matcher, exercised in the Part-4 spike, never resolves to a near disk). type PlannedDrive struct { DurableID string `json:"durable_id"` ExpectedMount string `json:"expected_mount"` Intent string `json:"intent"` } // BuildRestorePlan derives the plan from a host_loss directive + the live DR recipe. Returns // (nil,false) for a guest_loss/absent directive or a nil recipe (nothing to plan). PURE: reads // nothing, executes nothing — the whole point of this slice's safe half. func BuildRestorePlan(directive *hub.WireRestoreDirective, recipe *hub.DRRecipeHostHalf, restoreStorage string) (*RestorePlan, bool) { if directive == nil || directive.Mode != "host_loss" || recipe == nil { return nil, false } plan := &RestorePlan{Mode: directive.Mode, PBS: recipe.PBS} for _, g := range recipe.Guests { pg := PlannedGuest{ VMID: g.VMID, TargetStorage: restoreStorage, Cores: g.Cores, MemoryBytes: g.MemoryBytes, DiskBytes: g.DiskBytes, } // The directive may name an explicit archive for a specific guest (else the restore step // resolves the latest snapshot from the PBS coord at execution time). if directive.Archive != "" && (directive.VMID == 0 || directive.VMID == g.VMID) { pg.Archive = directive.Archive } plan.Guests = append(plan.Guests, pg) } for _, d := range recipe.Drives { plan.Drives = append(plan.Drives, PlannedDrive{ DurableID: d.DurableID, ExpectedMount: d.MountPath, Intent: d.Intent, }) } return plan, true } // RecipeFunc yields the current DR recipe (the agent-derived scaffolding). It is called ONLY when a // host_loss directive is present (a rare DR event), so an on-demand Collect is acceptable. type RecipeFunc func(ctx context.Context) *hub.DRRecipeHostHalf // Consumer implements desired.RawConsumer: on a host_loss restore_directive it builds + SURFACES the // plan (structured log + LastPlan for the report/inspection) and executes NOTHING. A guest_loss or // absent directive clears the plan. It holds no restore/destroy dependency — surfacing is all it can do. type Consumer struct { recipe RecipeFunc restoreStorage string logger *slog.Logger mu sync.Mutex lastPlan *RestorePlan } // NewConsumer builds the DR plan consumer. recipe may be nil (then no plan can be built — logged). func NewConsumer(recipe RecipeFunc, restoreStorage string, logger *slog.Logger) *Consumer { if logger == nil { logger = slog.Default() } return &Consumer{recipe: recipe, restoreStorage: restoreStorage, logger: logger} } // OnDesiredState implements desired.RawConsumer. Non-host_loss → clear + no-op. func (c *Consumer) OnDesiredState(ctx context.Context, resp *hub.DesiredStateResponse) { if resp == nil { return } dir := resp.DesiredState.RestoreDirective if dir == nil || dir.Mode != "host_loss" { c.mu.Lock() c.lastPlan = nil c.mu.Unlock() return } var recipe *hub.DRRecipeHostHalf if c.recipe != nil { recipe = c.recipe(ctx) } plan, ok := BuildRestorePlan(dir, recipe, c.restoreStorage) if !ok { c.logger.Warn("dr: host_loss restore_directive present but no DR recipe available yet — cannot build a plan", "directive_vmid", dir.VMID) return } c.mu.Lock() c.lastPlan = plan c.mu.Unlock() // SURFACE only — the destructive restore is a separate, operator-present step. c.logger.Warn("dr: host_loss RESTORE PLAN derived (NOT executed — supervised in-place restore is a separate, gated step)", "mode", plan.Mode, "guests", len(plan.Guests), "drives", len(plan.Drives), "plan", plan) } // LastPlan returns the most recently derived plan (nil if none / cleared). For the report + tests. func (c *Consumer) LastPlan() *RestorePlan { c.mu.Lock() defer c.mu.Unlock() return c.lastPlan }