@skyhook-io/radar-app 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,709 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CNPGRecoveryResponse } from '../../../api/cnpg-recovery'
3
+ import {
4
+ resourcesText,
5
+ restoreBackupDeclared,
6
+ restoreNextSteps,
7
+ restorePermission,
8
+ buildRestoreManifest,
9
+ cnpgRestoredPrimaryUp,
10
+ observeRestore,
11
+ pitrWarnings,
12
+ preflightFacts,
13
+ recoveryEvidenceFor,
14
+ latestEvidence,
15
+ assessRestoreSources,
16
+ restoreSourceForBackup,
17
+ restoreSourcesFor,
18
+ restoreSourcesForStore,
19
+ restoreTargetKinds,
20
+ describeSource,
21
+ targetIsoFrom,
22
+ sourceClusterFor,
23
+ } from './restoreModel'
24
+ import { restoreOperationObserver } from './restoreOperation'
25
+
26
+ const cluster = {
27
+ apiVersion: 'postgresql.cnpg.io/v1',
28
+ kind: 'Cluster',
29
+ metadata: { name: 'pg-a', namespace: 'db' },
30
+ spec: {
31
+ instances: 3,
32
+ imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'std', major: 17 },
33
+ storage: { size: '10Gi', storageClass: 'fast' },
34
+ walStorage: { size: '2Gi' },
35
+ postgresql: { parameters: { max_connections: '200', shared_buffers: '256MB' } },
36
+ plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'store', serverName: 'pg-a-v2' } }],
37
+ },
38
+ status: { conditions: [{ type: 'ContinuousArchiving', status: 'True' }] },
39
+ }
40
+
41
+ const pluginBackup = {
42
+ apiVersion: 'postgresql.cnpg.io/v1',
43
+ metadata: { name: 'b-plugin', namespace: 'db' },
44
+ spec: { cluster: { name: 'pg-a' }, method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io' } },
45
+ status: { phase: 'completed', backupId: '20260929T220000', stoppedAt: '2026-09-29T22:00:09Z', majorVersion: 17 },
46
+ }
47
+
48
+ const store = {
49
+ metadata: { name: 'store', namespace: 'db' },
50
+ status: { serverRecoveryWindow: { 'pg-a-v2': { firstRecoverabilityPoint: '2026-09-20T00:00:00Z', lastSuccessfulBackupTime: '2026-09-29T22:00:09Z' } } },
51
+ }
52
+
53
+ describe('restore sources', () => {
54
+ it('matches an in-tree archive by destination and endpoint as well as server name', () => {
55
+ const source = { kind: 'inTree' as const, serverName: 'pg-a', barmanObjectStore: { destinationPath: 's3://bucket/archive/', endpointURL: 'https://storage.example' } }
56
+ const sourceCluster = { metadata: { name: 'pg-a', namespace: 'db' }, spec: { backup: { barmanObjectStore: { ...source.barmanObjectStore, destinationPath: 's3://bucket/archive' } } } }
57
+ const other = { metadata: { name: 'pg-a', namespace: 'db' }, spec: {} }
58
+ expect(sourceClusterFor(source, [other, sourceCluster], 'db')).toBe(sourceCluster)
59
+ expect(sourceClusterFor(source, [{ ...sourceCluster, spec: { backup: { barmanObjectStore: { ...source.barmanObjectStore, endpointURL: 'https://other.example' } } } }], 'db')).toBeNull()
60
+ })
61
+ it('recognizes a restored Cluster with its own archive declaration, while ignoring disabled plugins', () => {
62
+ const restored = { ...cluster, spec: { ...cluster.spec, bootstrap: { recovery: { source: 'origin' } } } }
63
+ const source = { kind: 'objectStore' as const, objectStore: 'store', serverName: 'pg-a-v2' }
64
+ expect(sourceClusterFor(source, [restored], 'db')).toBe(restored)
65
+ expect(sourceClusterFor(source, [{ ...restored, spec: { ...restored.spec, plugins: [{ ...cluster.spec.plugins[0], enabled: false }] } }], 'db')).toBeNull()
66
+ })
67
+ it('copies source settings from a backup-only plugin when restoring from its ObjectStore', () => {
68
+ const source = { kind: 'objectStore' as const, objectStore: 'store', serverName: 'pg-a-v2' }
69
+ const backupOnly = { ...cluster, spec: { ...cluster.spec, resources: { requests: { cpu: '1' } }, plugins: [{ ...cluster.spec.plugins[0], isWALArchiver: false }] } }
70
+ const picked = sourceClusterFor(source, [backupOnly], 'db')
71
+ const manifest = buildRestoreManifest({ sourceCluster: picked, source, namespace: 'db', newName: 'restored', target: { kind: 'latest' } })
72
+ expect(picked).toBe(backupOnly)
73
+ expect(manifest.spec).toMatchObject({ instances: 3, storage: cluster.spec.storage, walStorage: cluster.spec.walStorage, postgresql: cluster.spec.postgresql, resources: backupOnly.spec.resources })
74
+ expect(manifest.spec.plugins).toBeUndefined()
75
+ expect(sourceClusterFor(source, [backupOnly], 'other')).toBeNull()
76
+ })
77
+ it('restores a plugin Backup through its ObjectStore with the backup ID pinned', () => {
78
+ expect(restoreSourceForBackup(pluginBackup, cluster)).toEqual({
79
+ kind: 'objectStore',
80
+ majorVersion: 17,
81
+ objectStore: 'store',
82
+ serverName: 'pg-a-v2',
83
+ backupID: '20260929T220000',
84
+ backupName: 'b-plugin',
85
+ backupEnd: '2026-09-29T22:00:09Z',
86
+ })
87
+ const inTree = { ...pluginBackup, spec: { cluster: { name: 'pg-a' } }, status: { phase: 'completed', stoppedAt: 'x' } }
88
+ expect(restoreSourceForBackup(inTree, cluster)).toMatchObject({ kind: 'backup', backup: 'b-plugin' })
89
+ expect(restoreSourceForBackup({ ...pluginBackup, status: { phase: 'running' } }, cluster)).toBeNull()
90
+ })
91
+
92
+ it('ignores Backup parameters for the store and server, including the Cluster-name default', () => {
93
+ const conflicting = { ...pluginBackup, spec: { ...pluginBackup.spec, pluginConfiguration: { ...pluginBackup.spec.pluginConfiguration, parameters: { barmanObjectName: 'backup-store', serverName: 'backup-server' } } } }
94
+ expect(restoreSourceForBackup(conflicting, cluster)).toEqual(restoreSourceForBackup(pluginBackup, cluster))
95
+ const defaultServer = { ...cluster, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'cluster-store' } }] } }
96
+ expect(restoreSourceForBackup(conflicting, defaultServer)).toMatchObject({ objectStore: 'cluster-store', serverName: 'pg-a' })
97
+ expect(restoreSourceForBackup(conflicting, null)).toBeNull()
98
+ expect(restoreSourceForBackup(conflicting, { ...cluster, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io' }] } })).toBeNull()
99
+ expect(restoreSourceForBackup(conflicting, { ...cluster, spec: { plugins: [{ ...cluster.spec.plugins[0], enabled: false }] } })).toBeNull()
100
+ })
101
+
102
+ it('lists the store first, then completed Backups of this cluster only', () => {
103
+ const other = { ...pluginBackup, metadata: { name: 'x', namespace: 'db' }, spec: { ...pluginBackup.spec, cluster: { name: 'other' } } }
104
+ const sources = restoreSourcesFor(cluster, [pluginBackup, other])
105
+ expect(sources.map((s) => s.kind)).toEqual(['objectStore', 'objectStore'])
106
+ expect(sources[1]).toMatchObject({ backupName: 'b-plugin' })
107
+ })
108
+
109
+ it('offers one source per server an ObjectStore reports', () => {
110
+ expect(restoreSourcesForStore(store)).toEqual([{ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }])
111
+ })
112
+ })
113
+
114
+ describe('restore manifest', () => {
115
+ it('copies what the data needs, reads the source store and never archives into it', () => {
116
+ const m = buildRestoreManifest({ sourceCluster: cluster, source: { kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }, namespace: 'db', newName: 'pg-a-restore', target: { kind: 'time', iso: '2026-09-28T12:00:00Z' } })
117
+ expect(m.spec.bootstrap.recovery).toEqual({ source: 'origin', recoveryTarget: { targetTime: '2026-09-28T12:00:00Z' } })
118
+ expect(m.spec.externalClusters[0].plugin.parameters).toEqual({ barmanObjectName: 'store', serverName: 'pg-a-v2' })
119
+ expect(m.spec.plugins).toBeUndefined()
120
+ expect(m.spec.backup).toBeUndefined()
121
+ expect(m.spec).toMatchObject({ instances: 3, walStorage: { size: '2Gi' }, postgresql: { parameters: { max_connections: '200' } } })
122
+ expect(m.spec.imageCatalogRef.major).toBe(17)
123
+ })
124
+
125
+ it('stops at the end of a pinned backup with targetImmediate', () => {
126
+ const source = restoreSourceForBackup(pluginBackup, cluster)!
127
+ const end = buildRestoreManifest({ sourceCluster: cluster, source, namespace: 'db', newName: 'r', target: { kind: 'backupEnd' } })
128
+ expect(end.spec.bootstrap.recovery.recoveryTarget).toEqual({ backupID: '20260929T220000', targetImmediate: true })
129
+ const latest = buildRestoreManifest({ sourceCluster: cluster, source, namespace: 'db', newName: 'r', target: { kind: 'latest' } })
130
+ expect(latest.spec.bootstrap.recovery.recoveryTarget).toEqual({ backupID: '20260929T220000' })
131
+ })
132
+
133
+ it('restores an in-tree Backup by reference', () => {
134
+ const m = buildRestoreManifest({ sourceCluster: cluster, source: { kind: 'backup', backup: 'b1' }, namespace: 'db', newName: 'r', target: { kind: 'latest' } })
135
+ expect(m.spec.bootstrap.recovery).toEqual({ backup: { name: 'b1' } })
136
+ expect(m.spec.externalClusters).toBeUndefined()
137
+ const pitr = buildRestoreManifest({ sourceCluster: cluster, source: { kind: 'backup', backup: 'b1' }, namespace: 'db', newName: 'r', target: { kind: 'time', iso: '2026-09-28T12:00:00Z' } })
138
+ expect(pitr.spec.bootstrap.recovery).toEqual({ backup: { name: 'b1' }, recoveryTarget: { targetTime: '2026-09-28T12:00:00Z' } })
139
+ })
140
+
141
+ it('offers only the end of a volume snapshot, and writes no later target for it', () => {
142
+ const snap = { apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'snap', namespace: 'db' }, spec: { cluster: { name: 'pg-a' }, method: 'volumeSnapshot' }, status: { phase: 'completed', method: 'volumeSnapshot', stoppedAt: '2026-09-30T12:00:00Z' } }
143
+ const source = restoreSourceForBackup(snap, cluster)!
144
+ expect(source).toMatchObject({ kind: 'backup', backup: 'snap', snapshot: true, backupEnd: '2026-09-30T12:00:00Z' })
145
+ expect(restoreTargetKinds(source)).toEqual(['backupEnd'])
146
+ expect(describeSource(source)).toBe('Backup snap (volume snapshot)')
147
+ for (const target of [{ kind: 'backupEnd' as const }, { kind: 'latest' as const }, { kind: 'time' as const, iso: '2026-09-30T13:00:00Z' }]) {
148
+ const m = buildRestoreManifest({ sourceCluster: cluster, source, namespace: 'db', newName: 'r', target })
149
+ expect(m.spec.bootstrap.recovery).toEqual({ backup: { name: 'snap' } })
150
+ expect(m.spec.externalClusters).toBeUndefined()
151
+ }
152
+ const inTree = restoreSourceForBackup({ ...snap, spec: { cluster: { name: 'pg-a' }, method: 'barmanObjectStore' }, status: { ...snap.status, method: 'barmanObjectStore' } }, cluster)!
153
+ expect(inTree).not.toHaveProperty('snapshot')
154
+ expect(restoreTargetKinds(inTree)).toEqual(['latest', 'time'])
155
+ expect(restoreTargetKinds(restoreSourceForBackup(pluginBackup, cluster)!)).toEqual(['backupEnd', 'latest', 'time'])
156
+ expect(restoreTargetKinds({ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' })).toEqual(['latest', 'time'])
157
+ })
158
+
159
+ it('lists copied facts and says what is missing without a source cluster', () => {
160
+ const facts = preflightFacts(cluster)
161
+ expect(facts.find((f) => f.path === 'spec.walStorage')?.copied).toBe(true)
162
+ expect(facts.find((f) => f.path === 'spec.postgresql.parameters')?.value).toContain('max_connections')
163
+ const none = preflightFacts(null)
164
+ expect(none.every((f) => !f.copied)).toBe(true)
165
+ })
166
+ })
167
+
168
+ describe('PITR evidence', () => {
169
+ const evidence = recoveryEvidenceFor({ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }, { sourceCluster: cluster, stores: [store], backups: [pluginBackup], namespace: 'db' })
170
+ const runtime = {
171
+ permission: { proxy: 'allowed' },
172
+ instances: [{ pod: 'pg-a-1', role: 'primary', status: { state: 'ok', archiving: { lastArchivedAt: '2026-09-30T08:00:00Z', lastArchivedWal: 'wal', lastFailedAt: '2026-09-30T07:00:00Z' } } }],
173
+ } as any
174
+
175
+ it.each([false, true])('uses WAL evidence only from the matching archiver (pinned backup: %s)', (pinned) => {
176
+ const source = { kind: 'objectStore' as const, objectStore: 'store', serverName: 'pg-a-v2', ...(pinned ? { backupID: '20260929T220000', backupName: 'b-plugin' } : {}) }
177
+ const e = recoveryEvidenceFor(source, { sourceCluster: cluster, stores: [store], backups: [pluginBackup], namespace: 'db', runtime })
178
+ expect(e.archiving.tone).toBe('healthy')
179
+ expect(e.lastArchived).toMatchObject({ at: '2026-09-30T08:00:00Z', source: 'instance manager on pg-a-1' })
180
+ expect(e.lastArchiveFailure?.at).toBe('2026-09-30T07:00:00Z')
181
+ expect(pitrWarnings('2026-09-30T06:00:00Z', e, Date.parse('2026-09-30T12:00:00Z'))).toEqual([])
182
+
183
+ for (const plugin of [
184
+ { ...cluster.spec.plugins[0], isWALArchiver: false },
185
+ { ...cluster.spec.plugins[0], enabled: false },
186
+ { ...cluster.spec.plugins[0], parameters: { barmanObjectName: 'other', serverName: 'pg-a-v2' } },
187
+ { ...cluster.spec.plugins[0], parameters: { barmanObjectName: 'store', serverName: 'other' } },
188
+ ]) {
189
+ const sourceCluster = { ...cluster, spec: { ...cluster.spec, plugins: [plugin] } }
190
+ const unknown = recoveryEvidenceFor(source, { sourceCluster, stores: [store], backups: [pluginBackup], namespace: 'db', runtime })
191
+ expect(unknown.archiving.tone).toBe('unknown')
192
+ expect(unknown.lastArchived).toBeUndefined()
193
+ expect(unknown.lastArchiveFailure).toBeUndefined()
194
+ expect(latestEvidence(unknown)?.at).toBe('2026-09-29T22:00:09Z')
195
+ expect(unknown.gaps).toContain('No live cluster archives into this source, so WAL archived after the last backup is unknown')
196
+ expect(pitrWarnings('2026-09-30T06:00:00Z', unknown, Date.parse('2026-09-30T12:00:00Z'))[0]).toContain('after the newest evidence')
197
+ }
198
+ })
199
+
200
+ it('reads the window from the store and archiving from the condition, naming sources', () => {
201
+ expect(evidence.firstPoint).toEqual({ at: '2026-09-20T00:00:00Z', source: 'ObjectStore store status, server pg-a-v2' })
202
+ expect(evidence.lastBackup?.at).toBe('2026-09-29T22:00:09Z')
203
+ expect(evidence.archiving.tone).toBe('healthy')
204
+ expect(evidence.gaps.some((g) => g.includes('last archived WAL'))).toBe(true)
205
+ })
206
+
207
+ it('says the primary was unreachable rather than that it reported nothing', () => {
208
+ const runtime = {
209
+ permission: { proxy: 'allowed' },
210
+ instances: [{ pod: 'pg-a-1', role: 'primary', status: { state: 'unreachable', error: 'the Pod did not answer on port 8000 within 5 s' }, metrics: { state: 'unreachable' } }],
211
+ } as any
212
+ const e = recoveryEvidenceFor({ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }, { sourceCluster: cluster, stores: [store], backups: [pluginBackup], namespace: 'db', runtime })
213
+ expect(e.gaps).toContain('The primary’s instance manager could not be read (the Pod did not answer on port 8000 within 5 s), so the last archived WAL time is unknown')
214
+ })
215
+
216
+ it('warns, never blocks, outside the evidence', () => {
217
+ const now = Date.parse('2026-09-30T12:00:00Z')
218
+ expect(pitrWarnings('2026-09-25T00:00:00Z', evidence, now)).toEqual([])
219
+ expect(pitrWarnings('2026-09-01T00:00:00Z', evidence, now)[0]).toContain('before the first recoverability point')
220
+ expect(pitrWarnings('2026-09-30T06:00:00Z', evidence, now)[0]).toContain('after the newest evidence')
221
+ expect(pitrWarnings('2026-10-01T00:00:00Z', evidence, now)[0]).toContain('future')
222
+ })
223
+
224
+ describe('evidence per source', () => {
225
+ const now = Date.parse('2026-09-30T13:00:00Z')
226
+ const archive = { destinationPath: 's3://bucket/pg-b', endpointURL: 'https://storage.example' }
227
+ const inTreeCluster = {
228
+ apiVersion: 'postgresql.cnpg.io/v1',
229
+ metadata: { name: 'pg-b', namespace: 'db' },
230
+ spec: { backup: { barmanObjectStore: archive } },
231
+ status: {
232
+ conditions: [{ type: 'ContinuousArchiving', status: 'True' }],
233
+ firstRecoverabilityPoint: '2026-09-01T00:00:00Z',
234
+ firstRecoverabilityPointByMethod: { barmanObjectStore: '2026-09-29T00:00:00Z', volumeSnapshot: '2026-09-01T00:00:00Z' },
235
+ lastSuccessfulBackup: '2026-09-30T12:00:00Z',
236
+ lastSuccessfulBackupByMethod: { barmanObjectStore: '2026-09-30T09:00:00Z', volumeSnapshot: '2026-09-30T12:00:00Z' },
237
+ },
238
+ }
239
+ const backup = (name: string, method: string, stoppedAt: string, status: Record<string, unknown> = {}) => ({
240
+ apiVersion: 'postgresql.cnpg.io/v1',
241
+ metadata: { name, namespace: 'db' },
242
+ spec: { cluster: { name: 'pg-b' }, method },
243
+ status: { phase: 'completed', method, stoppedAt, ...status },
244
+ })
245
+ const base = backup('base-0900', 'barmanObjectStore', '2026-09-30T09:00:00Z', { destinationPath: 's3://bucket/pg-b/', endpointURL: 'https://storage.example', serverName: 'pg-b' })
246
+ const noonSnapshot = backup('snap-1200', 'volumeSnapshot', '2026-09-30T12:00:00Z')
247
+ const elsewhere = backup('other-1000', 'barmanObjectStore', '2026-09-30T10:00:00Z', { destinationPath: 's3://bucket/other', endpointURL: 'https://storage.example', serverName: 'pg-b' })
248
+ const unrecorded = backup('unrecorded-1030', 'barmanObjectStore', '2026-09-30T10:30:00Z')
249
+ const inTree = { kind: 'inTree' as const, barmanObjectStore: archive, serverName: 'pg-b' }
250
+ const backups = [base, noonSnapshot, elsewhere, unrecorded]
251
+
252
+ it('keeps a noon volume snapshot out of an in-tree archive whose newest base backup is 09:00', () => {
253
+ const e = recoveryEvidenceFor(inTree, { sourceCluster: inTreeCluster, stores: [], backups, namespace: 'db' })
254
+ expect(e.firstPoint?.at).toBe('2026-09-29T00:00:00Z')
255
+ expect(e.lastBackup?.at).toBe('2026-09-30T09:00:00Z')
256
+ expect(pitrWarnings('2026-09-30T11:00:00Z', e, now)[0]).toContain('after the newest evidence Radar has (2026-09-30 09:00:00 UTC')
257
+ expect(pitrWarnings('2026-09-28T12:00:00Z', e, now)[0]).toContain('before the first recoverability point')
258
+ })
259
+
260
+ it('reads the plain status fields from an operator that reports no per-method times', () => {
261
+ const plain = { conditions: inTreeCluster.status.conditions, firstRecoverabilityPoint: '2026-09-01T00:00:00Z', lastSuccessfulBackup: '2026-09-30T08:00:00Z' }
262
+ const e = recoveryEvidenceFor(inTree, { sourceCluster: { ...inTreeCluster, status: plain }, stores: [], backups: [base], namespace: 'db' })
263
+ expect(e.firstPoint?.at).toBe('2026-09-01T00:00:00Z')
264
+ expect(e.lastBackup).toEqual({ at: '2026-09-30T09:00:00Z', source: 'Backup base-0900' })
265
+ })
266
+
267
+ it('counts only plugin Backups into an ObjectStore, while their Cluster still points at it', () => {
268
+ const snapshotOfA = { ...noonSnapshot, spec: { ...noonSnapshot.spec, cluster: { name: 'pg-a' } } }
269
+ const inTreeOfA = { ...base, spec: { ...base.spec, cluster: { name: 'pg-a' } }, status: { ...base.status, stoppedAt: '2026-09-30T11:00:00Z' } }
270
+ const source = { kind: 'objectStore' as const, objectStore: 'store', serverName: 'pg-a-v2' }
271
+ const e = recoveryEvidenceFor(source, { sourceCluster: cluster, stores: [], backups: [pluginBackup, snapshotOfA, inTreeOfA], namespace: 'db' })
272
+ expect(e.lastBackup).toEqual({ at: '2026-09-29T22:00:09Z', source: 'Backup b-plugin' })
273
+ const moved = { ...cluster, spec: { ...cluster.spec, plugins: [{ ...cluster.spec.plugins[0], parameters: { barmanObjectName: 'elsewhere', serverName: 'pg-a-v2' } }] } }
274
+ expect(recoveryEvidenceFor(source, { sourceCluster: moved, stores: [], backups: [pluginBackup], namespace: 'db' }).lastBackup).toBeUndefined()
275
+ })
276
+
277
+ it('reads a Backup source as that Backup, and WAL only from the archive it recorded', () => {
278
+ const source = restoreSourceForBackup(base, inTreeCluster)!
279
+ const e = recoveryEvidenceFor(source, { sourceCluster: inTreeCluster, stores: [], backups, namespace: 'db', runtime })
280
+ expect(e.lastBackup).toEqual({ at: '2026-09-30T09:00:00Z', source: 'Backup base-0900' })
281
+ expect(e.archiving.tone).toBe('healthy')
282
+ expect(e.lastArchived?.at).toBe('2026-09-30T08:00:00Z')
283
+
284
+ const away = recoveryEvidenceFor(restoreSourceForBackup(elsewhere, inTreeCluster)!, { sourceCluster: inTreeCluster, stores: [], backups, namespace: 'db', runtime })
285
+ expect(away.lastBackup).toEqual({ at: '2026-09-30T10:00:00Z', source: 'Backup other-1000' })
286
+ expect(away.archiving.tone).toBe('unknown')
287
+ expect(away.lastArchived).toBeUndefined()
288
+ expect(away.gaps).toContain('No live cluster archives into this source, so WAL archived after the last backup is unknown')
289
+ expect(recoveryEvidenceFor(restoreSourceForBackup(unrecorded, inTreeCluster)!, { sourceCluster: inTreeCluster, stores: [], backups, namespace: 'db', runtime }).lastArchived).toBeUndefined()
290
+ })
291
+
292
+ it('reads a volume snapshot as its own end, with no archived WAL applied', () => {
293
+ const e = recoveryEvidenceFor(restoreSourceForBackup(noonSnapshot, inTreeCluster)!, { sourceCluster: inTreeCluster, stores: [], backups, namespace: 'db', runtime })
294
+ expect(e.lastBackup).toEqual({ at: '2026-09-30T12:00:00Z', source: 'Backup snap-1200' })
295
+ expect(e.archiving).toMatchObject({ text: 'Not replayed', tone: 'neutral' })
296
+ expect(e.lastArchived).toBeUndefined()
297
+ expect(e.lastArchiveFailure).toBeUndefined()
298
+ expect(e.gaps).toEqual([])
299
+ })
300
+ })
301
+
302
+ it('reads the target in the chosen zone and writes UTC without milliseconds', () => {
303
+ expect(targetIsoFrom('2026-09-28T12:00', 'utc')).toBe('2026-09-28T12:00:00Z')
304
+ expect(targetIsoFrom('2026-09-28T12:00:30', 'utc')).toBe('2026-09-28T12:00:30Z')
305
+ expect(targetIsoFrom('nope', 'utc')).toBeNull()
306
+ })
307
+ })
308
+
309
+ function snapshot(over: Partial<CNPGRecoveryResponse> & { phase?: string; ready?: number | null; instances?: number | null }): CNPGRecoveryResponse {
310
+ return {
311
+ cluster: { uid: 'u', phase: over.phase, instances: over.instances ?? 1, readyInstances: over.ready === undefined ? 0 : over.ready },
312
+ recovery: { sourceKind: 'objectStore', objectStore: 'store', serverName: 'pg-a' },
313
+ pods: over.pods ?? [],
314
+ jobs: over.jobs ?? [],
315
+ events: [],
316
+ coverage: over.coverage ?? { pods: { state: 'ok' }, jobs: { state: 'ok' }, events: { state: 'ok' } },
317
+ capturedAt: '2026-09-30T00:00:00Z',
318
+ }
319
+ }
320
+
321
+ describe('observeRestore', () => {
322
+ const recoveryPod = (init: 'running' | 'crash') => ({
323
+ name: 'r-1-full-recovery-x',
324
+ uid: 'p',
325
+ kind: 'job' as const,
326
+ job: 'r-1-full-recovery',
327
+ ownerVerified: true,
328
+ phase: init === 'crash' ? 'Pending' : 'Running',
329
+ ready: false,
330
+ initContainers: [init === 'crash' ? { name: 'bootstrap-controller', state: 'waiting' as const, reason: 'CrashLoopBackOff', restarts: 3, ready: false } : { name: 'bootstrap-controller', state: 'terminated' as const, exitCode: 0, restarts: 0, ready: false }],
331
+ containers: [{ name: 'full-recovery', state: init === 'crash' ? ('waiting' as const) : ('running' as const), restarts: 0, ready: false }],
332
+ })
333
+
334
+ it('follows the recovery Pod and points at its logs', () => {
335
+ const o = observeRestore(snapshot({ phase: 'Setting up primary', pods: [recoveryPod('running')] }))
336
+ expect(o.state).toBe('progressing')
337
+ expect(o.logsPod).toEqual({ name: 'r-1-full-recovery-x', container: 'full-recovery' })
338
+ })
339
+
340
+ it('surfaces a crashing init container', () => {
341
+ const o = observeRestore(snapshot({ phase: 'Setting up primary', pods: [recoveryPod('crash')] }))
342
+ expect(o.tone).toBe('alert')
343
+ expect(o.logsPod?.container).toBe('bootstrap-controller')
344
+ })
345
+
346
+ it('fails on a failed recovery Job, by role or by name', () => {
347
+ const failedWith = 'BackoffLimitExceeded: Job has reached the specified backoff limit'
348
+ expect(observeRestore(snapshot({ jobs: [{ name: 'r-1-full-recovery', active: 0, succeeded: 0, failed: 6, complete: false, failedWith }] })).state).toBe('failed')
349
+ expect(observeRestore(snapshot({ jobs: [{ name: 'r-1-restore', role: 'snapshot-recovery', active: 0, succeeded: 0, failed: 6, complete: false, failedWith }] })).state).toBe('failed')
350
+ })
351
+
352
+ it('completes once a restored instance is ready, read from its Pod or the Cluster', () => {
353
+ const instance = { name: 'r-1', uid: 'i1', kind: 'instance' as const, ownerVerified: true, phase: 'Running', ready: true, initContainers: [], containers: [] }
354
+ const fromPod = observeRestore(snapshot({ phase: 'Creating a new replica', ready: 0, instances: 3, pods: [instance] }))
355
+ expect(fromPod.state).toBe('completed')
356
+ const denied = { pods: { state: 'denied' as const }, jobs: { state: 'denied' as const }, events: { state: 'denied' as const } }
357
+ expect(observeRestore(snapshot({ phase: 'Cluster in healthy state', ready: 1, instances: 3, coverage: denied })).state).toBe('completed')
358
+ expect(observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, instances: 3 })).state).toBe('progressing')
359
+ })
360
+
361
+ describe('weeks after the restore', () => {
362
+ const ready = (name: string) => ({ name, uid: name, kind: 'instance' as const, ownerVerified: true, phase: 'Running', ready: true, initContainers: [], containers: [] })
363
+ const join = (phase: string) => ({
364
+ name: 'r-4-join-x',
365
+ uid: 'j',
366
+ kind: 'job' as const,
367
+ job: 'r-4-join',
368
+ ownerVerified: true,
369
+ phase,
370
+ ready: false,
371
+ initContainers: [{ name: 'bootstrap-controller', state: 'terminated' as const, exitCode: 0, restarts: 0, ready: false }],
372
+ containers: [{ name: 'join', state: 'running' as const, restarts: 0, ready: false }],
373
+ })
374
+ const recovered = { name: 'r-1-full-recovery', role: 'full-recovery', active: 0, succeeded: 1, failed: 0, complete: true }
375
+
376
+ it('a switchover reads restored, not "Restore in progress"', () => {
377
+ const o = observeRestore(snapshot({ phase: 'Switchover in progress', ready: 2, instances: 3, pods: [ready('r-1'), ready('r-2')], jobs: [recovered] }))
378
+ expect(o.state).toBe('completed')
379
+ })
380
+
381
+ it('a scale-up join Pod is not read as restoring the base backup', () => {
382
+ const o = observeRestore(snapshot({ phase: 'Creating a new replica', ready: 3, instances: 4, pods: [ready('r-1'), ready('r-2'), ready('r-3'), join('Running')], jobs: [recovered, { name: 'r-4-join', role: 'join', active: 1, succeeded: 0, failed: 0, complete: false }] }))
383
+ expect(o.state).toBe('completed')
384
+ expect(o.recoveryPods).toEqual([])
385
+ })
386
+
387
+ it('a failed join Job is not a failed restore', () => {
388
+ const failedJoin = { name: 'r-4-join', role: 'join', active: 0, succeeded: 0, failed: 6, complete: false, failedWith: 'BackoffLimitExceeded' }
389
+ expect(observeRestore(snapshot({ phase: 'Creating a new replica', ready: 3, instances: 4, pods: [ready('r-1'), ready('r-2'), ready('r-3')], jobs: [recovered, failedJoin] })).state).toBe('completed')
390
+ expect(observeRestore(snapshot({ phase: 'Creating a new replica', ready: 3, instances: 4, pods: [ready('r-1')], jobs: [{ ...failedJoin, role: undefined }] })).state).toBe('completed')
391
+ })
392
+
393
+ it('stays restored while every instance is down, from the completed recovery Job', () => {
394
+ const down = (name: string) => ({ ...ready(name), phase: 'Pending', ready: false })
395
+ const hibernated = observeRestore(snapshot({ phase: 'Cluster in hibernation', ready: 0, instances: 3, jobs: [recovered] }))
396
+ expect(hibernated.state).toBe('completed')
397
+ expect(hibernated.detail).toBe('Recovery Job r-1-full-recovery completed · 0/3 instances ready · Cluster in hibernation')
398
+ expect(observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [down('r-1')], jobs: [{ ...recovered, role: undefined, complete: false }] })).state).toBe('completed')
399
+ expect(observeRestore(snapshot({ phase: 'Waiting for the instances to become active', ready: 0, pods: [down('r-1')], jobs: [{ name: 'r-1-join', role: 'join', active: 0, succeeded: 1, failed: 0, complete: true }] })).state).toBe('progressing')
400
+ const snapshotJob = { name: 'r-1-snapshot-recovery', role: 'snapshot-recovery', active: 0, succeeded: 1, failed: 0, complete: true }
401
+ expect(observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [down('r-1')], jobs: [snapshotJob] })).state).toBe('progressing')
402
+ expect(observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [down('r-1')], jobs: [{ ...snapshotJob, role: undefined }] })).state).toBe('progressing')
403
+ const noJobs = { pods: { state: 'ok' as const }, jobs: { state: 'denied' as const }, events: { state: 'ok' as const } }
404
+ expect(observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [down('r-1')], coverage: noJobs }))).toMatchObject({ state: 'progressing', title: 'Waiting for the recovery to start' })
405
+ })
406
+
407
+ it('a Job read without its role counts as recovery only by its role-suffixed name', () => {
408
+ const o = observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [join('Running')], jobs: [{ name: 'r-4-join', active: 1, succeeded: 0, failed: 0, complete: false }] }))
409
+ expect(o.recoveryPods).toEqual([])
410
+ expect(o.title).toBe('Waiting for the recovery to start')
411
+ })
412
+ })
413
+
414
+ it('still reads an in-progress recovery as progressing', () => {
415
+ const o = observeRestore(snapshot({ phase: 'Setting up primary', ready: 0, pods: [recoveryPod('running')], jobs: [{ name: 'r-1-full-recovery', role: 'full-recovery', active: 1, succeeded: 0, failed: 0, complete: false }] }))
416
+ expect(o.state).toBe('progressing')
417
+ expect(o.title).toBe('Restoring the base backup and replaying WAL')
418
+ expect(o.recoveryPods.map((p) => p.name)).toEqual(['r-1-full-recovery-x'])
419
+ })
420
+
421
+ it('never reads missing access as progress or failure', () => {
422
+ const o = observeRestore(snapshot({ coverage: { pods: { state: 'denied', grant: { verb: 'list', resource: 'pods', namespace: 'db' } }, jobs: { state: 'denied' }, events: { state: 'denied' } } }))
423
+ expect(o.state).toBe('unobservable')
424
+ expect(o.detail).toContain('list pods')
425
+ })
426
+ })
427
+
428
+ describe('restore operation observer', () => {
429
+ const op = { id: '1', kind: 'restore', label: 'Restore into r', apiBase: '/api', context: 'c', namespace: 'db', cluster: 'r', startedAt: 0, baseline: {}, state: 'requested' as const, lastProgressAt: 0 }
430
+
431
+ it('waits for the Cluster, then completes on a healthy phase with all instances ready', () => {
432
+ expect(restoreOperationObserver(op, { now: 1000 }).state).toBe('requested')
433
+ const progressing = restoreOperationObserver(op, { now: 1000, cluster: { spec: { instances: 1 }, status: { phase: 'Setting up primary', readyInstances: 0 } } })
434
+ expect(progressing.state).not.toBe('completed')
435
+ const done = restoreOperationObserver(op, {
436
+ now: 1000,
437
+ cluster: { spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1 } },
438
+ ha: { jobs: { state: 'ok', items: [{ name: 'r-1-full-recovery', role: 'full-recovery', phase: 'succeeded' }] } } as any,
439
+ })
440
+ expect(done.state).toBe('completed')
441
+ })
442
+
443
+ it('completes without Job access once the restored cluster is healthy and ready', () => {
444
+ const done = restoreOperationObserver(op, {
445
+ now: 1000,
446
+ cluster: { spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1 } },
447
+ ha: { jobs: { state: 'denied' } } as any,
448
+ })
449
+ expect(done.state).toBe('completed')
450
+ })
451
+
452
+ it('fails when the recovery Job fails', () => {
453
+ const r = restoreOperationObserver(op, {
454
+ now: 1000,
455
+ cluster: { spec: { instances: 1 }, status: { phase: 'Setting up primary' } },
456
+ ha: { jobs: { state: 'ok', items: [{ name: 'r-1-full-recovery', role: 'full-recovery', phase: 'failed', reason: 'BackoffLimitExceeded' }] } } as any,
457
+ })
458
+ expect(r.state).toBe('failed')
459
+ })
460
+ })
461
+
462
+ describe('restorePermission', () => {
463
+ it('blocks review with the grant when create clusters is denied, whichever way the dialog opened', () => {
464
+ const r = restorePermission('db', { allowed: false, permission: 'denied', grant: { verb: 'create', group: 'postgresql.cnpg.io', resource: 'clusters', namespace: 'db' }, reason: 'x' }, undefined)
465
+ expect(r.blocked).toContain('needs create clusters (postgresql.cnpg.io) in namespace db')
466
+ })
467
+ it('blocks with the refusal when the operator webhook rejects writes', () => {
468
+ expect(restorePermission('db', { allowed: false, permission: 'allowed', reason: 'The API server would reject it: no ready endpoint' }, undefined).blocked).toContain('no ready endpoint')
469
+ })
470
+ it('is pending while checking, open when allowed, and unchecked (not blocked) when the check failed', () => {
471
+ expect(restorePermission('db', undefined, undefined).pending).toBeDefined()
472
+ expect(restorePermission('db', { allowed: true, permission: 'allowed' }, undefined)).toEqual({})
473
+ const failed = restorePermission('db', undefined, new Error('boom'))
474
+ expect(failed.blocked).toBeUndefined()
475
+ expect(failed.unchecked).toContain('boom')
476
+ })
477
+ })
478
+
479
+ describe('restoreNextSteps', () => {
480
+ it('marks done or to do only what Radar can read, never whether applications moved', () => {
481
+ const fresh = restoreNextSteps({ validationRecorded: false, backup: 'none' })
482
+ expect(fresh.map((s) => [s.id, s.state])).toEqual([
483
+ ['connect', 'unknown'],
484
+ ['validate', 'todo'],
485
+ ['backup', 'todo'],
486
+ ])
487
+ expect(fresh[2].note).toContain('no backup destination')
488
+ expect(restoreNextSteps({ validationRecorded: true, backup: 'walArchiving' }).map((s) => s.state)).toEqual(['unknown', 'done', 'partial'])
489
+ expect(restoreNextSteps({ validationRecorded: false, backup: undefined })[2].state).toBe('unknown')
490
+ })
491
+ it('never counts volume snapshots alone, or backups without WAL archiving, as done', () => {
492
+ const plugin = (isWALArchiver: boolean) => ({ spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver, parameters: { barmanObjectName: 'store' } }] } })
493
+ const step = (cluster: any) => restoreNextSteps({ validationRecorded: false, backup: restoreBackupDeclared(cluster) })[2]
494
+ expect(step({ spec: { backup: { volumeSnapshot: { className: 'csi' } } } })).toMatchObject({
495
+ state: 'partial',
496
+ note: 'Volume snapshots declared, but no WAL archiving, so no point-in-time recovery',
497
+ })
498
+ expect(step(plugin(false))).toMatchObject({ state: 'partial' })
499
+ expect(step(plugin(true)).state).toBe('partial')
500
+ const noDestination = step({ spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true }] } })
501
+ expect(noDestination.state).toBe('partial')
502
+ expect(noDestination.note).toContain('names no ObjectStore')
503
+ expect(step({ spec: { backup: { barmanObjectStore: { destinationPath: 's3://b' } } } }).state).toBe('partial')
504
+ expect(step({ spec: {} }).state).toBe('todo')
505
+ })
506
+ })
507
+
508
+ describe('resourcesText', () => {
509
+ it('reads requests and limits instead of JSON', () => {
510
+ expect(resourcesText({ requests: { cpu: '50m', memory: '128Mi' } })).toBe('requests: cpu 50m, memory 128Mi')
511
+ expect(resourcesText({ requests: { cpu: '50m' }, limits: { memory: '256Mi' } })).toBe('requests: cpu 50m · limits: memory 256Mi')
512
+ })
513
+ })
514
+
515
+ describe('cnpgRestoredPrimaryUp', () => {
516
+ const pod = (kind: 'job' | 'instance', phase: string, ready: boolean) => ({ name: `${kind}-x`, uid: kind, kind, ownerVerified: true, phase, ready, initContainers: [], containers: [] })
517
+ it('is up once a restored instance is ready, even if the cluster later degrades', () => {
518
+ // A replica lost after the restore does not reopen the restore.
519
+ const degraded = snapshot({ phase: 'Cluster in healthy state', ready: 1, instances: 2, pods: [pod('job', 'Succeeded', false), pod('instance', 'Running', true), pod('instance', 'Running', false)] as any })
520
+ expect(observeRestore(degraded).state).toBe('completed')
521
+ expect(cnpgRestoredPrimaryUp(degraded)).toBe(true)
522
+ })
523
+ it('is not up while recovery runs or when Pods cannot be seen, and a join Job does not hide it', () => {
524
+ expect(cnpgRestoredPrimaryUp(snapshot({ pods: [pod('job', 'Running', false)] as any }))).toBe(false)
525
+ // A replica joining after the restore runs a Job too.
526
+ expect(cnpgRestoredPrimaryUp(snapshot({ pods: [pod('job', 'Running', false), pod('instance', 'Running', true)] as any }))).toBe(true)
527
+ expect(cnpgRestoredPrimaryUp(snapshot({ pods: [pod('instance', 'Running', true)] as any, coverage: { pods: { state: 'denied' }, jobs: { state: 'ok' }, events: { state: 'ok' } } as any }))).toBe(false)
528
+ })
529
+ })
530
+
531
+ describe('restore availability and image review', () => {
532
+ it('disables only a readable, empty source list', async () => {
533
+ const { assessRestoreSources } = await import('./restoreModel')
534
+ const sourceCluster = { metadata: { name: 'pg', namespace: 'db' }, spec: {} }
535
+ const data = { objects: { backups: [] }, coverage: { backups: { state: 'full' } } } as any
536
+ expect(assessRestoreSources(data, 'db', sourceCluster).disabledReason).toContain('Nothing to restore')
537
+ for (const state of ['denied', 'error', 'syncing', 'uncached']) {
538
+ const result = assessRestoreSources({ ...data, coverage: { backups: { state } } }, 'db', sourceCluster)
539
+ expect(result.disabledReason).toBeUndefined()
540
+ expect(result.unreadReason).toContain('could not be read')
541
+ }
542
+ const partial = { ...data, coverage: { backups: { state: 'partial', deniedNamespaces: ['other'] } } }
543
+ expect(assessRestoreSources(partial, 'db', sourceCluster).disabledReason).toBeDefined()
544
+ expect(assessRestoreSources(partial, 'other', sourceCluster).disabledReason).toBeUndefined()
545
+ expect(assessRestoreSources(data, 'db', cluster).disabledReason).toBeUndefined()
546
+ })
547
+ it('copies status.image for an unpinned archive only without a declared image or catalog and labels its origin', () => {
548
+ const source = { kind: 'objectStore', objectStore: 'store', serverName: 'pg' } as const
549
+ const build = (sourceCluster: any) => buildRestoreManifest({ sourceCluster, source, namespace: 'db', newName: 'r', target: { kind: 'latest' } }).spec
550
+ const fromStatus = { spec: {}, status: { image: 'pg:17.6' } }
551
+ expect(build(fromStatus).imageName).toBe('pg:17.6')
552
+ expect(preflightFacts(fromStatus).find((f) => f.label === 'Image')).toMatchObject({ copied: true, value: 'pg:17.6 (from status.image)' })
553
+ const declared = { spec: { imageName: 'pg:17.7' }, status: { image: 'pg:17.6' } }
554
+ expect(build(declared).imageName).toBe('pg:17.7')
555
+ const catalog = { ...cluster, status: { image: 'pg:17.6' } }
556
+ expect(build(catalog).imageCatalogRef).toEqual(cluster.spec.imageCatalogRef)
557
+ expect(build(catalog).imageName).toBeUndefined()
558
+ expect(preflightFacts(catalog).find((f) => f.label === 'Image')?.path).toBe('spec.imageCatalogRef')
559
+ })
560
+ })
561
+
562
+ it('separates declared destinations, unread sources and recorded recovery evidence', async () => {
563
+ const { assessRestoreSources } = await import('./restoreModel')
564
+ const cluster = { metadata: { name: 'pg', namespace: 'db' }, spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } }, status: {} as any }
565
+ const data = { coverage: { backups: { state: 'full' } }, objects: { backups: [] as any[] } } as any
566
+ expect(assessRestoreSources(data, 'db', cluster).recoveryState).toBe('none')
567
+ expect(assessRestoreSources(undefined, 'db', cluster).recoveryState).toBe('unknown')
568
+ expect(assessRestoreSources({ ...data, coverage: { backups: { state: 'denied' } } }, 'db', cluster).recoveryState).toBe('unknown')
569
+ cluster.status.lastSuccessfulBackup = '2026-10-01T00:00:00Z'
570
+ expect(assessRestoreSources(data, 'db', cluster).recoveryState).toBe('available')
571
+ delete cluster.status.lastSuccessfulBackup
572
+ data.objects.backups = [{ metadata: { name: 'completed', namespace: 'db' }, spec: { cluster: { name: 'pg' }, method: 'volumeSnapshot' }, status: { phase: 'completed' } }]
573
+ expect(assessRestoreSources(data, 'db', { ...cluster, spec: {} }).recoveryState).toBe('available')
574
+ const plugin = { ...cluster, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store' } }] } }
575
+ data.objects.backups = []
576
+ expect(assessRestoreSources({ ...data, coverage: { ...data.coverage, objectStores: { state: 'denied' } } }, 'db', plugin).recoveryState).toBe('unknown')
577
+ data.objects.objectStores = [{ metadata: { name: 'store', namespace: 'db' }, status: { serverRecoveryWindow: { pg: { lastSuccessfulBackupTime: '2026-10-01T00:00:00Z' } } } }]
578
+ expect(assessRestoreSources({ ...data, coverage: { ...data.coverage, objectStores: { state: 'full' } } }, 'db', plugin).recoveryState).toBe('available')
579
+ })
580
+
581
+
582
+ describe('Cluster plugin recovery destinations', () => {
583
+ const sourceCluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg-a', namespace: 'db' }, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io' }] } }
584
+ const schedule = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'db' }, spec: { cluster: { name: 'pg-a' }, method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store', serverName: 'pg-a-v2' } } } }
585
+ const data = { coverage: { backups: { state: 'full' }, scheduledBackups: { state: 'full' }, objectStores: { state: 'full' } }, objects: { backups: [], scheduledBackups: [schedule], objectStores: [store] } } as any
586
+
587
+ it('cannot obtain a recovery destination from a schedule parameter', async () => {
588
+ const { assessRestoreSources } = await import('./restoreModel')
589
+ expect(assessRestoreSources(data, 'db', sourceCluster)).toEqual({ sources: [], recoveryState: 'none', unreadReason: undefined, disabledReason: 'Nothing to restore from yet: no backup destination and no completed Backup.' })
590
+ })
591
+ it('keeps a completed barman-cloud Backup unknown when the Cluster no longer names its ObjectStore', async () => {
592
+ const { assessRestoreSources } = await import('./restoreModel')
593
+ const completed = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Backup', metadata: { name: 'b1', namespace: 'db' }, spec: { cluster: { name: 'pg-a' }, method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io' } }, status: { phase: 'completed', backupId: 'x' } }
594
+ const assessment = assessRestoreSources({ ...data, objects: { ...data.objects, backups: [completed] } }, 'db', sourceCluster)
595
+ expect(assessment.recoveryState).toBe('unknown')
596
+ expect(assessment.unreadReason).toContain('Backup b1 completed with the barman-cloud plugin, but the Cluster no longer names an ObjectStore')
597
+ expect(assessment.disabledReason).toBeUndefined()
598
+ })
599
+ it('keeps unreadable or missing Cluster ObjectStores unknown and explains why', async () => {
600
+ const { assessRestoreSources } = await import('./restoreModel')
601
+ for (const state of ['denied', 'error', 'syncing', 'uncached']) {
602
+ const assessment = assessRestoreSources({ ...data, coverage: { ...data.coverage, objectStores: { state } } }, 'db', cluster)
603
+ expect(assessment.recoveryState).toBe('unknown')
604
+ expect(assessment.unreadReason).toContain('ObjectStore store could not be read in db')
605
+ expect(assessment.disabledReason).toBeUndefined()
606
+ expect(assessment.sources).toEqual([{ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }])
607
+ }
608
+ expect(assessRestoreSources({ ...data, objects: { ...data.objects, objectStores: [] } }, 'db', cluster)).toMatchObject({ recoveryState: 'unknown', unreadReason: expect.stringContaining('ObjectStore store could not be read') })
609
+ })
610
+ it('does not add a conflicting schedule destination to the Cluster recovery sources', async () => {
611
+ const { assessRestoreSources } = await import('./restoreModel')
612
+ const other = { ...schedule, spec: { ...schedule.spec, pluginConfiguration: { ...schedule.spec.pluginConfiguration, parameters: { barmanObjectName: 'schedule-store', serverName: 'schedule-server' } } } }
613
+ expect(assessRestoreSources({ ...data, objects: { ...data.objects, scheduledBackups: [other] } }, 'db', cluster)).toEqual({ sources: [{ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }], recoveryState: 'available', disabledReason: undefined, unreadReason: undefined })
614
+ expect(restoreSourcesFor(sourceCluster, [])).toEqual([])
615
+ })
616
+ it('ignores schedules from another namespace or API group when assessing unsupported methods', async () => {
617
+ const { assessRestoreSources } = await import('./restoreModel')
618
+ const unsupported = { ...schedule, spec: { ...schedule.spec, pluginConfiguration: { name: 'other.example.com' } } }
619
+ const foreign = [{ ...unsupported, metadata: { ...schedule.metadata, namespace: 'other' } }, { ...unsupported, apiVersion: 'other.io/v1' }]
620
+ expect(assessRestoreSources({ ...data, objects: { ...data.objects, scheduledBackups: foreign } }, 'db', cluster)).toEqual({ sources: [{ kind: 'objectStore', objectStore: 'store', serverName: 'pg-a-v2' }], recoveryState: 'available', disabledReason: undefined, unreadReason: undefined })
621
+ })
622
+ it('keeps unsupported plugin methods unknown instead of claiming there are no backups', async () => {
623
+ const { assessRestoreSources } = await import('./restoreModel')
624
+ const unsupported = { ...schedule, spec: { ...schedule.spec, pluginConfiguration: { name: 'other.example.com' } } }
625
+ const assessment = assessRestoreSources({ ...data, objects: { ...data.objects, scheduledBackups: [unsupported] } }, 'db', { ...sourceCluster, spec: { plugins: [{ name: 'other.example.com' }] } })
626
+ expect(assessment.recoveryState).toBe('unknown')
627
+ expect(assessment.sources).toEqual([])
628
+ expect(assessment.unreadReason).toContain('plugin other.example.com cannot be assessed')
629
+ expect(assessment.disabledReason).toBeUndefined()
630
+ expect(restoreSourceForBackup({ ...pluginBackup, spec: { ...pluginBackup.spec, pluginConfiguration: { name: 'other.example.com', parameters: { barmanObjectName: 'store' } } } }, cluster)).toBeNull()
631
+ })
632
+ it('reserves none for assessed destinations with no recorded evidence', async () => {
633
+ const { assessRestoreSources } = await import('./restoreModel')
634
+ const assessment = assessRestoreSources({ ...data, objects: { ...data.objects, objectStores: [{ ...store, status: {} }] } }, 'db', cluster)
635
+ expect(assessment.recoveryState).toBe('none')
636
+ expect(assessment.unreadReason).toBeUndefined()
637
+ expect(assessment.disabledReason).toBeUndefined()
638
+ })
639
+ })
640
+
641
+
642
+ it('does not let unreadable schedules hide that a Cluster has nothing to restore from', async () => {
643
+ const { assessRestoreSources } = await import('./restoreModel')
644
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'db' }, spec: {} }
645
+ const data = { coverage: { backups: { state: 'full' }, scheduledBackups: { state: 'denied' } }, objects: { backups: [], scheduledBackups: [] } } as any
646
+ expect(assessRestoreSources(data, 'db', cluster)).toMatchObject({ recoveryState: 'none', unreadReason: undefined, disabledReason: 'Nothing to restore from yet: no backup destination and no completed Backup.' })
647
+ })
648
+
649
+
650
+ it('does not count an unsupported plugin Backup as evidence for an assessable destination', async () => {
651
+ const { assessRestoreSources } = await import('./restoreModel')
652
+ const unsupported = { ...pluginBackup, kind: 'Backup', spec: { ...pluginBackup.spec, pluginConfiguration: { name: 'other.example.com' } } }
653
+ const data = { coverage: { backups: { state: 'full' }, scheduledBackups: { state: 'full' }, objectStores: { state: 'full' } }, objects: { backups: [unsupported], scheduledBackups: [], objectStores: [{ ...store, status: {} }] } } as any
654
+ const assessment = assessRestoreSources(data, 'db', cluster)
655
+ expect(assessment.recoveryState).toBe('unknown')
656
+ expect(assessment.unreadReason).toContain('Backup b-plugin using plugin other.example.com cannot be assessed')
657
+ expect(assessment.disabledReason).toBeUndefined()
658
+ })
659
+
660
+
661
+ describe('physical recovery compatibility', () => {
662
+ it('uses the backup major in the source catalog after a major upgrade', () => {
663
+ const source = restoreSourceForBackup({ ...pluginBackup, status: { ...pluginBackup.status, majorVersion: 16 } }, cluster)!
664
+ const spec = buildRestoreManifest({ sourceCluster: cluster, source, namespace: 'db', newName: 'restore', target: { kind: 'backupEnd' } }).spec
665
+ expect(spec.imageCatalogRef).toEqual({ ...cluster.spec.imageCatalogRef, major: 16 })
666
+ })
667
+ it('requires an explicit image when the recorded major differs or is missing', () => {
668
+ const current = { ...cluster, spec: { ...cluster.spec, imageCatalogRef: undefined, imageName: 'custom:17' }, status: { pgDataImageInfo: { majorVersion: 17 } } }
669
+ for (const majorVersion of [16, undefined]) {
670
+ const source = restoreSourceForBackup({ ...pluginBackup, status: { ...pluginBackup.status, majorVersion } }, current)!
671
+ expect(buildRestoreManifest({ sourceCluster: current, source, namespace: 'db', newName: 'restore', target: { kind: 'backupEnd' } }).spec.imageName).toBeUndefined()
672
+ }
673
+ })
674
+ it('rejects plugin backups belonging to a predecessor Cluster', () => {
675
+ const current = { ...cluster, metadata: { ...cluster.metadata, uid: 'current', creationTimestamp: '2026-09-30T00:00:00Z' } }
676
+ expect(restoreSourceForBackup({ ...pluginBackup, status: { ...pluginBackup.status, pluginMetadata: { clusterUID: 'old' } } }, current)).toBeNull()
677
+ expect(restoreSourceForBackup({ ...pluginBackup, status: { ...pluginBackup.status, startedAt: '2026-09-29T22:00:00Z' } }, current)).toBeNull()
678
+ })
679
+ it('does not mark backup protection complete from its declaration alone', () => {
680
+ const step = restoreNextSteps({ validationRecorded: false, backup: 'walArchiving' })[2]
681
+ expect(step.state).toBe('partial')
682
+ expect(step.note).toContain('complete a new base backup')
683
+ })
684
+ })
685
+
686
+
687
+ it('pins all named Backup methods to their recorded major and requires an image when unknown', () => {
688
+ for (const method of ['barmanObjectStore', 'volumeSnapshot']) {
689
+ const backup = { ...pluginBackup, spec: { cluster: { name: 'pg-a' }, method }, status: { ...pluginBackup.status, method, majorVersion: 16 } }
690
+ const source = restoreSourceForBackup(backup, cluster)!
691
+ expect(source).toMatchObject({ kind: 'backup', majorVersion: 16 })
692
+ const args = { sourceCluster: cluster, source, namespace: 'db', newName: 'restore', target: { kind: 'latest' as const } }
693
+ expect(buildRestoreManifest(args).spec.imageCatalogRef.major).toBe(16)
694
+ const unknown = restoreSourceForBackup({ ...backup, status: { ...backup.status, majorVersion: undefined } }, cluster)!
695
+ expect(buildRestoreManifest({ ...args, source: unknown }).spec.imageCatalogRef).toBeUndefined()
696
+ expect(buildRestoreManifest({ ...args, source: unknown }).spec.imageName).toBeUndefined()
697
+ }
698
+ })
699
+
700
+ it('does not use predecessor Backup metadata as evidence of recovery for a recreated Cluster', () => {
701
+ const current = { ...cluster, metadata: { ...cluster.metadata, uid: 'current', creationTimestamp: '2026-09-29T00:00:00Z' } }
702
+ const old = { ...pluginBackup, status: { ...pluginBackup.status, pluginMetadata: { clusterUID: 'previous' } } }
703
+ const data = { coverage: { backups: { state: 'full' }, objectStores: { state: 'full' } }, objects: { backups: [old], objectStores: [{ ...store, status: {} }] } } as any
704
+ const assessment = assessRestoreSources(data, 'db', current)
705
+ expect(assessment.recoveryState).toBe('unknown')
706
+ expect(assessment.unreadReason).toContain('previous Cluster incarnation')
707
+ expect(assessment.unreadReason).not.toContain('no longer names an ObjectStore')
708
+ expect(recoveryEvidenceFor(assessment.sources[0], { sourceCluster: current, stores: [], backups: [old], namespace: 'db' }).lastBackup).toBeUndefined()
709
+ })