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