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:
@@ -0,0 +1,130 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// DR recipe (SPIKE-dr-recipe-2026-06-16) — the secret-free reconstruction recipe, stored PLAINTEXT
|
||||
// because it has NO secrets (it is the clean inverse of the retired infra-backup). The hub receives two
|
||||
// halves via the existing report paths — the agent's storage/guest/PBS half (on the host-report) and
|
||||
// the controller's customer/apps half (on the controller report) — and assembles them into one record
|
||||
// keyed by customer. Both halves carry ONLY identifiers/intents/sizes/coordinates; the table is a
|
||||
// DEDICATED `dr_recipe`, NOT host_escrow (opaque) and NOT the dropped infra_backup* tables.
|
||||
|
||||
// DRRecipe is the stored two-half record for one customer.
|
||||
type DRRecipe struct {
|
||||
CustomerID string
|
||||
RecipeVersion int
|
||||
HostID string
|
||||
HostHalfJSON string // the agent half (guests/pbs/drives/pve_storage); "" until a host-report lands
|
||||
AppHalfJSON string // the controller half (customer/apps); "" until a controller report lands
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// SaveDRRecipeHostHalf upserts the agent (storage/guest/PBS) half for a customer, preserving any
|
||||
// app half already stored. Last-write-wins on the host half + host_id + recipe_version.
|
||||
func (s *Store) SaveDRRecipeHostHalf(customerID, hostID string, recipeVersion int, hostHalf []byte) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO dr_recipe (customer_id, recipe_version, host_id, host_half_json, app_half_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, '', datetime('now'))
|
||||
ON CONFLICT(customer_id) DO UPDATE SET
|
||||
recipe_version = excluded.recipe_version,
|
||||
host_id = excluded.host_id,
|
||||
host_half_json = excluded.host_half_json,
|
||||
updated_at = datetime('now')`,
|
||||
customerID, recipeVersion, hostID, string(hostHalf),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDRRecipeAppHalf upserts the controller (customer/apps) half for a customer, preserving any
|
||||
// host half already stored. Last-write-wins on the app half + recipe_version.
|
||||
func (s *Store) SaveDRRecipeAppHalf(customerID string, recipeVersion int, appHalf []byte) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO dr_recipe (customer_id, recipe_version, host_id, host_half_json, app_half_json, updated_at)
|
||||
VALUES (?, ?, '', '', ?, datetime('now'))
|
||||
ON CONFLICT(customer_id) DO UPDATE SET
|
||||
recipe_version = excluded.recipe_version,
|
||||
app_half_json = excluded.app_half_json,
|
||||
updated_at = datetime('now')`,
|
||||
customerID, recipeVersion, string(appHalf),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDRRecipe returns the stored two-half record for a customer (nil if none).
|
||||
func (s *Store) GetDRRecipe(customerID string) (*DRRecipe, error) {
|
||||
var r DRRecipe
|
||||
err := s.db.QueryRow(`
|
||||
SELECT customer_id, recipe_version, host_id, host_half_json, app_half_json, updated_at
|
||||
FROM dr_recipe WHERE customer_id = ?`, customerID).
|
||||
Scan(&r.CustomerID, &r.RecipeVersion, &r.HostID, &r.HostHalfJSON, &r.AppHalfJSON, &r.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// AssembledRecipe is the operator-facing single recipe: the two halves stitched together. The
|
||||
// sub-sections are passed through as json.RawMessage so the assembly is robust to forward-compat
|
||||
// additions inside either half (ignore-unknown at the top level, verbatim passthrough below).
|
||||
type AssembledRecipe struct {
|
||||
RecipeVersion int `json:"recipe_version"`
|
||||
Customer json.RawMessage `json:"customer,omitempty"`
|
||||
Guests json.RawMessage `json:"guests,omitempty"`
|
||||
PBS json.RawMessage `json:"pbs,omitempty"`
|
||||
Drives json.RawMessage `json:"drives,omitempty"`
|
||||
PVEStorage json.RawMessage `json:"pve_storage,omitempty"`
|
||||
Apps json.RawMessage `json:"apps,omitempty"`
|
||||
}
|
||||
|
||||
// hostHalfShape / appHalfShape capture only the top-level keys the assembly stitches; encoding/json
|
||||
// drops any unknown top-level key (forward-compat — a newer half with extra sections still parses).
|
||||
type hostHalfShape struct {
|
||||
RecipeVersion int `json:"recipe_version"`
|
||||
Guests json.RawMessage `json:"guests"`
|
||||
PBS json.RawMessage `json:"pbs"`
|
||||
Drives json.RawMessage `json:"drives"`
|
||||
PVEStorage json.RawMessage `json:"pve_storage"`
|
||||
}
|
||||
type appHalfShape struct {
|
||||
RecipeVersion int `json:"recipe_version"`
|
||||
Customer json.RawMessage `json:"customer"`
|
||||
Apps json.RawMessage `json:"apps"`
|
||||
}
|
||||
|
||||
// AssembleDRRecipe stitches the two stored halves into one operator-facing recipe. Either half may be
|
||||
// empty (not yet reported); the assembly fills what it has. recipe_version = the max of the two halves'
|
||||
// versions (1 if both absent). Ignore-unknown: extra top-level keys in either half are dropped, nested
|
||||
// shapes pass through verbatim — so the three repos can evolve the recipe without silent drift here.
|
||||
func AssembleDRRecipe(rec *DRRecipe) (AssembledRecipe, error) {
|
||||
out := AssembledRecipe{RecipeVersion: 1}
|
||||
if rec == nil {
|
||||
return out, nil
|
||||
}
|
||||
if rec.HostHalfJSON != "" {
|
||||
var h hostHalfShape
|
||||
if err := json.Unmarshal([]byte(rec.HostHalfJSON), &h); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Guests, out.PBS, out.Drives, out.PVEStorage = h.Guests, h.PBS, h.Drives, h.PVEStorage
|
||||
if h.RecipeVersion > out.RecipeVersion {
|
||||
out.RecipeVersion = h.RecipeVersion
|
||||
}
|
||||
}
|
||||
if rec.AppHalfJSON != "" {
|
||||
var a appHalfShape
|
||||
if err := json.Unmarshal([]byte(rec.AppHalfJSON), &a); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Customer, out.Apps = a.Customer, a.Apps
|
||||
if a.RecipeVersion > out.RecipeVersion {
|
||||
out.RecipeVersion = a.RecipeVersion
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user