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
+25 -4
View File
@@ -254,12 +254,28 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
AppTelemetry []store.CustomerAppSummary
HasAppTelemetry bool
HasDRRecipe bool
DRRecipeUpdatedAt string
DRRecipeHasHost bool
DRRecipeHasApps bool
Flash string
ActiveNav string
CSRFField template.HTML
CSRFToken string
}
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
// either half has landed (host-report and/or controller report).
var hasDR, drHost, drApps bool
var drUpdated string
if rec, err := s.store.GetDRRecipe(customerID); err == nil && rec != nil {
hasDR = rec.HostHalfJSON != "" || rec.AppHalfJSON != ""
drHost = rec.HostHalfJSON != ""
drApps = rec.AppHalfJSON != ""
drUpdated = rec.UpdatedAt
}
data := pageData{
CustomerID: customerID,
CustomerName: name,
@@ -293,6 +309,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
AppTelemetry: appTelemetry,
HasAppTelemetry: len(appTelemetry) > 0,
HasDRRecipe: hasDR,
DRRecipeUpdatedAt: drUpdated,
DRRecipeHasHost: drHost,
DRRecipeHasApps: drApps,
Flash: r.URL.Query().Get("flash"),
ActiveNav: "configs",
CSRFField: s.csrfField(r),
@@ -741,10 +762,10 @@ func flattenYAML(m map[string]interface{}, prefix string) map[string]string {
// configDiff represents a single key-value difference between two configs.
type configDiff struct {
Key string `json:"key"`
HubValue string `json:"hub"`
CtrlValue string `json:"controller"`
Status string `json:"status"` // "changed", "hub_only", "controller_only"
Key string `json:"key"`
HubValue string `json:"hub"`
CtrlValue string `json:"controller"`
Status string `json:"status"` // "changed", "hub_only", "controller_only"
}
// compareYAMLValues parses two YAML strings and returns their value differences.