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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user