diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index f18f3d26d5..0cf7d6096d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -218,6 +218,11 @@ metadata. If a stored recovery row contains bad `subject_ref_json`, `repository_ref_json`, or `details_json`, the list endpoints should log and drop only the malformed derived field for that row rather than returning `500` for the entire recovery points or rollups surface. +That same fail-soft contract also applies to downstream consumers that reuse +those shared store reads, including recovery-backed reporting, alert rollups, +and tenant-scoped AI recovery-point adapters. A malformed metadata blob may +degrade row-local enrichment, but it must not take down adjacent readers that +consume the same canonical recovery store. That same hook-boundary normalization also owns the runtime recovery display model. Canonical recovery points and rollups must expose `display.itemLabel` and `display.itemType` to recovery consumers, while legacy transport fields diff --git a/internal/ai/tools/recovery_adapter_resilience_test.go b/internal/ai/tools/recovery_adapter_resilience_test.go new file mode 100644 index 0000000000..c1a894289b --- /dev/null +++ b/internal/ai/tools/recovery_adapter_resilience_test.go @@ -0,0 +1,106 @@ +package tools + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/recovery" + recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager" + + _ "modernc.org/sqlite" +) + +func TestRecoveryPointsMCPAdapter_ListPointsToleratesMalformedPersistedMetadata(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + manager := recoverymanager.New(mtp) + store, err := manager.StoreForOrg("default") + if err != nil { + t.Fatalf("StoreForOrg(default): %v", err) + } + + completedAt := time.Date(2026, 2, 22, 7, 45, 0, 0, time.UTC) + if err := store.UpsertPoints(context.Background(), []recovery.RecoveryPoint{ + { + ID: "adapter-point-bad-json", + Provider: recovery.ProviderProxmoxPBS, + Kind: recovery.KindBackup, + Mode: recovery.ModeRemote, + Outcome: recovery.OutcomeSuccess, + SubjectResourceID: "vm-321", + SubjectRef: &recovery.ExternalRef{ + Type: "proxmox-vm", + Name: "321", + }, + RepositoryRef: &recovery.ExternalRef{ + Type: "pbs-datastore", + Name: "archive-store", + }, + CompletedAt: &completedAt, + Details: map[string]any{ + "comment": "nightly archive", + }, + }, + }); err != nil { + t.Fatalf("UpsertPoints(): %v", err) + } + + persistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + corruptRecoveryAdapterRowJSON( + t, + filepath.Join(persistence.DataDir(), "recovery", "recovery.db"), + "adapter-point-bad-json", + ) + + adapter := NewRecoveryPointsMCPAdapter(manager, "default") + points, total, err := adapter.ListPoints(context.Background(), recovery.ListPointsOptions{ + Page: 1, + Limit: 50, + }) + if err != nil { + t.Fatalf("ListPoints() error = %v, want graceful degradation", err) + } + if total != 1 || len(points) != 1 { + t.Fatalf("ListPoints() total=%d len=%d, want 1/1", total, len(points)) + } + if points[0].SubjectRef != nil { + t.Fatalf("expected malformed item ref to be omitted, got %#v", points[0].SubjectRef) + } + if points[0].RepositoryRef != nil { + t.Fatalf("expected malformed repository ref to be omitted, got %#v", points[0].RepositoryRef) + } + if points[0].Details != nil { + t.Fatalf("expected malformed details to be omitted, got %#v", points[0].Details) + } + if points[0].Display.SubjectLabel != "321" { + t.Fatalf("display.subjectLabel = %q, want %q", points[0].Display.SubjectLabel, "321") + } + if points[0].Display.ItemType != "vm" { + t.Fatalf("display.itemType = %q, want %q", points[0].Display.ItemType, "vm") + } +} + +func corruptRecoveryAdapterRowJSON(t *testing.T, dbPath string, rowID string) { + t.Helper() + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open(%q): %v", dbPath, err) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.ExecContext( + context.Background(), + "UPDATE recovery_points SET subject_ref_json = '{', repository_ref_json = '{', details_json = '{' WHERE id = ?", + rowID, + ); err != nil { + t.Fatalf("corrupt recovery row json: %v", err) + } +}