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
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The two halves as the agent (host) and controller (app) emit them — keys must match the cross-repo
|
||||
// golden (the agent's host-report.golden.json dr_recipe section + the controller's emitter).
|
||||
const drHostHalf = `{
|
||||
"recipe_version": 1,
|
||||
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" },
|
||||
"drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "role": "bulk-data", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
|
||||
"pve_storage": [ { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, { "name": "felhom-usb", "type": "usb", "content": "backup" } ]
|
||||
}`
|
||||
|
||||
const drAppHalf = `{
|
||||
"recipe_version": 1,
|
||||
"customer": { "id": "cust-demo", "display": "Demo Customer", "domain": "demo-felhom.eu" },
|
||||
"apps": [ { "catalog_ref": "romm", "enabled": true, "storage_bindings": [ { "container_path": "/roms", "drive": "felhom-flash", "subpath": "userdata/roms" } ] } ]
|
||||
}`
|
||||
|
||||
// TestDRRecipe_StoreRoundTrip: each half upserts independently and preserves the other; GetDRRecipe
|
||||
// returns both.
|
||||
func TestDRRecipe_StoreRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
// App half lands first.
|
||||
if err := s.SaveDRRecipeAppHalf("cust-demo", 1, []byte(drAppHalf)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := s.GetDRRecipe("cust-demo")
|
||||
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON != "" {
|
||||
t.Fatalf("after app-half: want app set, host empty, got %+v", rec)
|
||||
}
|
||||
|
||||
// Host half lands later — must NOT clobber the app half.
|
||||
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ = s.GetDRRecipe("cust-demo")
|
||||
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON == "" || rec.HostID != "host-01" {
|
||||
t.Fatalf("after host-half: both halves must be present + host_id set, got %+v", rec)
|
||||
}
|
||||
|
||||
// A re-report of the host half preserves the app half (and vice-versa).
|
||||
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ = s.GetDRRecipe("cust-demo")
|
||||
if rec.AppHalfJSON == "" {
|
||||
t.Fatal("re-reporting the host half clobbered the app half")
|
||||
}
|
||||
|
||||
// Absent customer → nil, no error.
|
||||
if got, err := s.GetDRRecipe("nobody"); err != nil || got != nil {
|
||||
t.Fatalf("absent customer should be (nil,nil), got (%v,%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleDRRecipe_MatchesGolden: assembling both halves yields the golden's key shape (the
|
||||
// cross-repo wire pin) and the correct stitched values.
|
||||
func TestAssembleDRRecipe_MatchesGolden(t *testing.T) {
|
||||
rec := &DRRecipe{CustomerID: "cust-demo", RecipeVersion: 1, HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf}
|
||||
asm, err := AssembleDRRecipe(rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(asm)
|
||||
var got map[string]any
|
||||
json.Unmarshal(b, &got)
|
||||
|
||||
raw, err := os.ReadFile("testdata/dr-recipe.golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var golden map[string]any
|
||||
if err := json.Unmarshal(raw, &golden); err != nil {
|
||||
t.Fatalf("golden invalid: %v", err)
|
||||
}
|
||||
|
||||
// Top-level key set must match the golden (the assembled wire shape).
|
||||
if ga, gb := keysOf(golden), keysOf(got); !reflect.DeepEqual(ga, gb) {
|
||||
t.Errorf("assembled key drift:\n golden=%v\n got =%v", ga, gb)
|
||||
}
|
||||
// And the stitched values: customer from the app half, drives/pbs from the host half.
|
||||
if asm.RecipeVersion != 1 {
|
||||
t.Errorf("recipe_version=%d want 1", asm.RecipeVersion)
|
||||
}
|
||||
if !jsonContains(t, asm.Customer, "cust-demo") || !jsonContains(t, asm.Customer, "demo-felhom.eu") {
|
||||
t.Errorf("customer not stitched from app half: %s", asm.Customer)
|
||||
}
|
||||
if !jsonContains(t, asm.Drives, "uuid:da9e7089-cf8e-4617-adcb-a377743fae00") {
|
||||
t.Errorf("drives not stitched from host half: %s", asm.Drives)
|
||||
}
|
||||
if !jsonContains(t, asm.Apps, "romm") {
|
||||
t.Errorf("apps not stitched from app half: %s", asm.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew: a half carrying an UNKNOWN top-level field and a
|
||||
// HIGHER recipe_version still assembles (forward-compat), and recipe_version reflects the max.
|
||||
func TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew(t *testing.T) {
|
||||
futureHost := `{ "recipe_version": 2, "drives": [], "pve_storage": [], "guests": [],
|
||||
"future_section": { "whatever": 1 }, "network_topology": ["a","b"] }`
|
||||
rec := &DRRecipe{CustomerID: "c", RecipeVersion: 2, HostHalfJSON: futureHost, AppHalfJSON: drAppHalf}
|
||||
asm, err := AssembleDRRecipe(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("ignore-unknown failed to parse a forward-compat half: %v", err)
|
||||
}
|
||||
if asm.RecipeVersion != 2 {
|
||||
t.Errorf("recipe_version=%d, want max(2,1)=2", asm.RecipeVersion)
|
||||
}
|
||||
// The unknown sections are dropped (not in AssembledRecipe), but the assembly did not error.
|
||||
b, _ := json.Marshal(asm)
|
||||
if string(b) == "" {
|
||||
t.Fatal("empty assembly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleDRRecipe_PartialHalves: only one half present → assemble what we have, no error.
|
||||
func TestAssembleDRRecipe_PartialHalves(t *testing.T) {
|
||||
onlyApp, err := AssembleDRRecipe(&DRRecipe{AppHalfJSON: drAppHalf})
|
||||
if err != nil || onlyApp.Apps == nil || onlyApp.Drives != nil {
|
||||
t.Errorf("only-app assembly wrong: %+v err=%v", onlyApp, err)
|
||||
}
|
||||
onlyHost, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf})
|
||||
if err != nil || onlyHost.Drives == nil || onlyHost.Customer != nil {
|
||||
t.Errorf("only-host assembly wrong: %+v err=%v", onlyHost, err)
|
||||
}
|
||||
empty, err := AssembleDRRecipe(nil)
|
||||
if err != nil || empty.RecipeVersion != 1 {
|
||||
t.Errorf("nil assembly should be a v1 empty recipe, got %+v err=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleDRRecipe_NoSecrets: defense-in-depth — the assembled output carries no credential-shaped
|
||||
// key. (The load-bearing boundary is enforced at the controller emitter; this guards the hub side.)
|
||||
func TestAssembleDRRecipe_NoSecrets(t *testing.T) {
|
||||
asm, _ := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf})
|
||||
b, _ := json.Marshal(asm)
|
||||
re := regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
|
||||
var v any
|
||||
json.Unmarshal(b, &v)
|
||||
var walk func(any)
|
||||
walk = func(n any) {
|
||||
switch x := n.(type) {
|
||||
case map[string]any:
|
||||
for k, c := range x {
|
||||
if re.MatchString(k) {
|
||||
t.Errorf("secret-shaped key %q in assembled recipe", k)
|
||||
}
|
||||
walk(c)
|
||||
}
|
||||
case []any:
|
||||
for _, c := range x {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(v)
|
||||
}
|
||||
|
||||
func jsonContains(t *testing.T, raw json.RawMessage, substr string) bool {
|
||||
t.Helper()
|
||||
return len(raw) > 0 && string(raw) != "null" && strings.Contains(string(raw), substr)
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
@@ -309,6 +309,26 @@ func (s *Store) migrate() error {
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN identity_blob BLOB`)
|
||||
s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN directive_json TEXT NOT NULL DEFAULT '{}'`)
|
||||
|
||||
// dr_recipe (SPIKE-dr-recipe-2026-06-16): the secret-free DR reconstruction recipe, stored
|
||||
// PLAINTEXT (it has NO secrets — the clean inverse of the retired infra_backup). Two halves keyed
|
||||
// by customer: the agent's storage/guest/PBS half (host_half_json, from the host-report) and the
|
||||
// controller's customer/apps half (app_half_json, from the controller report); the hub assembles
|
||||
// them on read. DEDICATED table, separate from the opaque host_escrow. One row per customer;
|
||||
// each half is last-write-wins and preserves the other.
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS dr_recipe (
|
||||
customer_id TEXT PRIMARY KEY,
|
||||
recipe_version INTEGER NOT NULL DEFAULT 1,
|
||||
host_id TEXT NOT NULL DEFAULT '',
|
||||
host_half_json TEXT NOT NULL DEFAULT '',
|
||||
app_half_json TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"recipe_version": 1,
|
||||
"customer": { "id": "cust-demo", "display": "Demo Customer", "domain": "demo-felhom.eu" },
|
||||
"guests": [
|
||||
{ "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 }
|
||||
],
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" },
|
||||
"drives": [
|
||||
{
|
||||
"durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00",
|
||||
"role": "bulk-data",
|
||||
"mount_path": "/mnt/felhom-usb",
|
||||
"intent": "enrolled",
|
||||
"total_bytes": 1000000000000
|
||||
}
|
||||
],
|
||||
"pve_storage": [
|
||||
{ "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" },
|
||||
{ "name": "felhom-usb", "type": "usb", "content": "backup" }
|
||||
],
|
||||
"apps": [
|
||||
{
|
||||
"catalog_ref": "romm",
|
||||
"enabled": true,
|
||||
"storage_bindings": [
|
||||
{ "container_path": "/roms", "drive": "felhom-flash", "subpath": "userdata/roms" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user