Files
felhom.eu/hub/internal/store/dr_recipe.go
T
admin acfc2b7e95 R-109 + R-122: the recipe assembly stops dropping sections (hub v0.83.0)
AssembleDRRecipe's hostHalfShape/appHalfShape are ALLOW-LISTS, not the
forward-compat their comment advertised: a section an emitter adds is silently
discarded until it is named in both the shape struct and AssembledRecipe. No
error, no log, no failing test.

R-122 (found this session): that already happened and shipped. The controller
has emitted offsite_restic since fork-4 — the offsite recovery LOCATION — the
hub stored it for all three real customers, and appHalfShape never listed the
key, so no delivered recipe has ever contained it. It stayed green because the
fixture drAppHalf is hand-written and omits the field.

R-109: the agent's new backup_target is a new top-level host-half section and
would have been dropped identically, making the fix read as shipped while
changing nothing an operator can see.

3 tests built on halves read verbatim out of the live dr_recipe table, plus
2 red-proofs (each mutation asserted to have landed). vet rc=0, suite rc=0, 17 ok.

Registers: R-106 + R-109 dispositioned; R-105/R-106 were READY in ROADMAP with
no OPEN-ITEMS row (→ R-123, registered); R-124 filed on the "root" spelling.
2026-07-30 13:13:56 +02:00

151 lines
6.8 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"`
// BackupTarget (R-109) names WHICH storage holds the local whole-guest archives — the agent's
// host-half emits it from v0.118.0.
BackupTarget json.RawMessage `json:"backup_target,omitempty"`
Apps json.RawMessage `json:"apps,omitempty"`
// OffsiteRestic (R-122) is the offsite restic repo's non-secret coordinates — WHERE to recover from.
// The controller has emitted it since fork-4 and the hub dropped it for the whole time; see the
// allow-list warning on appHalfShape.
OffsiteRestic json.RawMessage `json:"offsite_restic,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).
//
// THAT FORWARD-COMPAT IS ALSO A TRAP, and it has already cost one shipped section. These two structs are
// ALLOW-LISTS: a section an emitter adds is silently discarded here until it is named in BOTH the shape
// struct and AssembledRecipe. `offsite_restic` proved it — the controller emitted it from fork-4, the hub
// stored it intact for every customer, and the delivered recipe never contained it because nothing here
// listed the key. Nothing failed; the section simply was not there (R-122).
//
// SO: adding a section to either half is a TWO-REPO change. TestAssembleDRRecipe_CarriesEveryEmittedSection
// pins the current set against captured real halves — extend it in the same commit as any new section.
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"`
BackupTarget json.RawMessage `json:"backup_target"`
}
type appHalfShape struct {
RecipeVersion int `json:"recipe_version"`
Customer json.RawMessage `json:"customer"`
Apps json.RawMessage `json:"apps"`
OffsiteRestic json.RawMessage `json:"offsite_restic"`
}
// 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
out.BackupTarget = h.BackupTarget
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
out.OffsiteRestic = a.OffsiteRestic
if a.RecipeVersion > out.RecipeVersion {
out.RecipeVersion = a.RecipeVersion
}
}
return out, nil
}