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 }