hub v0.13.0: DR recipe — assemble + store + view the secret-free reconstruction recipe

DR recipe slice (hub half), grounded in SPIKE-dr-recipe-2026-06-16. The hub
receives two additive dr_recipe halves on the existing report paths (agent
storage/guest/PBS on host-report; controller customer/apps on the controller
report), stores them PLAINTEXT in a DEDICATED dr_recipe table keyed by customer
(each half preserves the other), and AssembleDRRecipe stitches them into one
operator-readable recipe (ignore-unknown + version-skew tolerant).

View: a DR-recipe panel on the customer page + GET /customers/{id}/dr-recipe.json
download (operator-auth, no secrets to redact). Plaintext-at-rest is correct —
the recipe is the clean inverse of the retired infra-backup.

Tests: store round-trip (each half preserves the other), assemble-matches-golden,
ignore-unknown + version skew, partial halves, no-secrets sweep. Manifest tag
bumped to v0.13.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:49:45 +02:00
parent 228dac4c06
commit 5f5e3c54a1
13 changed files with 597 additions and 69 deletions
+35
View File
@@ -241,6 +241,21 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
}
}
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
var drPayload struct {
DRRecipe json.RawMessage `json:"dr_recipe"`
}
if err := json.Unmarshal(body, &drPayload); err == nil && len(drPayload.DRRecipe) > 0 {
var ver drRecipeVersionOnly
_ = json.Unmarshal(drPayload.DRRecipe, &ver)
if err := h.store.SaveDRRecipeAppHalf(payload.CustomerID, ver.RecipeVersion, drPayload.DRRecipe); err != nil {
h.logger.Printf("[WARN] Failed to save DR-recipe app-half for %s: %v", payload.CustomerID, err)
} else {
h.logger.Printf("[INFO] DR-recipe app-half stored for customer %s (v%d)", payload.CustomerID, ver.RecipeVersion)
}
}
h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))
// Build response with optional customer_blocked flag
@@ -297,6 +312,14 @@ type hostReportPayload struct {
Cloudflared struct {
Status string `json:"status"`
} `json:"cloudflared"`
// DR recipe — the agent's storage/guest/PBS half (secret-free). RawMessage = stored verbatim,
// ignore-unknown (forward-compat). Persisted to dr_recipe, assembled with the controller half.
DRRecipe json.RawMessage `json:"dr_recipe"`
}
// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
type drRecipeVersionOnly struct {
RecipeVersion int `json:"recipe_version"`
}
// hostPBSSnapshot mirrors the agent's hub.PBSSnapshot wire contract (slice 6 Phase B). The
@@ -525,6 +548,18 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d backups, %d restore-tests, %d pbs-snapshots, %d bytes)",
hostID, len(rep.Guests), len(rep.StorageTargets), len(rep.Backups), len(rep.RestoreTests), len(rep.PBSSnapshots), len(body))
// DR recipe — persist the agent's secret-free storage/guest/PBS half (preserving any app half).
// A failure here must NOT drop the heartbeat (the report already saved); just warn.
if len(rep.DRRecipe) > 0 && custID != "" {
var ver drRecipeVersionOnly
_ = json.Unmarshal(rep.DRRecipe, &ver)
if err := h.store.SaveDRRecipeHostHalf(custID, hostID, ver.RecipeVersion, rep.DRRecipe); err != nil {
h.logger.Printf("[WARN] Failed to save DR-recipe host-half for customer %s (host %s): %v", custID, hostID, err)
} else {
h.logger.Printf("[INFO] DR-recipe host-half stored for customer %s (host %s, v%d)", custID, hostID, ver.RecipeVersion)
}
}
blocked := false
if cc, err := h.store.GetCustomerConfig(custID); err == nil && cc != nil && cc.Status == "blocked" {
blocked = true