Files
felhom.eu/hub/internal/store/dr_recipe.go
T
admin 5f5e3c54a1 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>
2026-06-16 18:49:45 +02:00

131 lines
5.4 KiB
Go

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
}