dr: recovered WG-key install + host_loss directive→restore-PLAN (S5 safe halves)
wgtunnel.InstallRecoveredKey: write an escrow-recovered WG private key (create-
only, refuse-overwrite) so the tunnel re-establishes with the same identity/pubkey
(same /32), no keygen. Wired into identity-consume -install-wg-key (opt-in;
pre-S3 blob → logged fresh-keygen fallback). Value never logged.
internal/dr (new): consume the host_loss restore_directive (was logged-ignored)
into an inspectable RestorePlan via the AddConsumer raw seam — per guest
{vmid,archive,target,sizing} + per drive {durable_id→mount} + offsite PBS coord.
DERIVE-AND-SURFACE only; the Consumer has no restore/destroy dependency (execute-
nothing is structural). guest_loss/absent → no plan.
Tests + red-proofs (WG create-only overwrite; plan mode-gate). No secrets on
argv/stdout/logs. The destructive in-place restore is a separate operator-present
STOP-gated drill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user