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.
+61
View File
@@ -0,0 +1,61 @@
package web
import (
"encoding/json"
"net/http"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// handleDRRecipeDownload serves the assembled secret-free DR recipe for a customer as a JSON download
// (SPIKE-dr-recipe-2026-06-16). The recipe is PLAINTEXT because it carries NO secrets — only the
// reconstruction scaffolding (guest sizing, drive inventory, PVE storage, PBS coordinates, app
// inventory + storage bindings). Operator (dashboard-auth) only; no decrypt, nothing to redact.
func (s *Server) handleDRRecipeDownload(w http.ResponseWriter, r *http.Request, customerID string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
rec, err := s.store.GetDRRecipe(customerID)
if err != nil {
s.logger.Printf("[ERROR] DR-recipe lookup failed for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if rec == nil {
http.Error(w, "No DR recipe for this customer yet (awaiting a host-report + controller report)", http.StatusNotFound)
return
}
assembled, err := store.AssembleDRRecipe(rec)
if err != nil {
s.logger.Printf("[ERROR] DR-recipe assemble failed for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
out, err := json.MarshalIndent(assembled, "", " ")
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\"dr-recipe-"+safeFilename(customerID)+".json\"")
w.Write(out)
s.logger.Printf("[INFO] DR-recipe downloaded for customer %s (v%d)", customerID, assembled.RecipeVersion)
}
// safeFilename keeps a customer id safe for a Content-Disposition filename (alnum/-/_ only).
func safeFilename(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) == 0 {
return "customer"
}
return string(out)
}
+4
View File
@@ -238,6 +238,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/dr-recipe.json"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
s.handleDRRecipeDownload(w, r, customerID)
case strings.HasPrefix(path, "/customers/"):
customerID := strings.TrimPrefix(path, "/customers/")
s.handleCustomerUnified(w, r, customerID)
@@ -559,6 +559,36 @@
</section>
{{end}}
{{if .HasDRRecipe}}
<!-- DR recipe (secret-free reconstruction recipe) -->
<section class="card">
<h2>DR Recipe <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(secret-free reconstruction plan)</span></h2>
<p class="text-muted" style="margin-top: 0;">
The non-secret re-provision plan — guest sizing, drive inventory (durable-id → role → mount → intent),
PVE storage defs, PBS coordinates, and app inventory + storage bindings. It complements escrow (keys)
and PBS/restic (bytes): <strong>it contains no key, password, or token</strong>. Use it to rebuild the
host/guest/storage scaffolding before the PBS bytes land.
</p>
<div class="info-grid">
<div class="info-item">
<span class="label">Storage / guest / PBS half (agent)</span>
<span class="value">{{if .DRRecipeHasHost}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting host-report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Customer / apps half (controller)</span>
<span class="value">{{if .DRRecipeHasApps}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting controller report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last updated</span>
<span class="value">{{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}}</span>
</div>
</div>
<div style="margin-top: 1rem;">
<a href="/customers/{{.CustomerID}}/dr-recipe.json" class="btn" download>Download recipe (JSON)</a>
</div>
</section>
{{end}}
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>