@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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { CNPG_BARMAN_PLUGIN_NAME, cnpgBackupDeclaration, cnpgBarmanPlugin, getCNPGClusterBarmanPlugin, getCNPGPostgresMajor, cnpgBackupMatchesCluster, cnpgArchiveMatchesCluster, getCNPGBackupPlugin, objectStoreForBackup, getCNPGObjectStoreRecoveryWindows, isApiGroup, type HealthLevel, type CNPGWorkspaceResponse, coverageReadable, formatGrant } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { CNPGRuntimeResponse } from '../../../api/cnpg'
|
|
3
|
+
import type { ActionCapability } from '../../../api/actions'
|
|
4
|
+
import type { CNPGRecoveryResponse, CNPGRecoveryPod } from '../../../api/cnpg-recovery'
|
|
5
|
+
|
|
6
|
+
export const RESTORE_VALIDATION_ANNOTATION = 'radar.skyhook.io/restore-validation'
|
|
7
|
+
const HEALTHY_PHASE = 'Cluster in healthy state'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where a restore reads from. Plugin backups are restored through the
|
|
11
|
+
* ObjectStore with the backup's ID pinned: CloudNativePG restores a
|
|
12
|
+
* `bootstrap.recovery.backup` reference only from in-tree or snapshot backups.
|
|
13
|
+
* A `snapshot` Backup restores its own consistent point; replaying WAL past it
|
|
14
|
+
* needs a WAL archive `source`, which this restore does not configure.
|
|
15
|
+
*/
|
|
16
|
+
export type RestoreSource = (
|
|
17
|
+
| { kind: 'objectStore'; objectStore: string; serverName: string; backupID?: string; backupName?: string; backupEnd?: string }
|
|
18
|
+
| { kind: 'inTree'; barmanObjectStore: Record<string, unknown>; serverName: string }
|
|
19
|
+
| { kind: 'backup'; backup: string; backupEnd?: string; snapshot?: boolean }
|
|
20
|
+
) & { majorVersion?: number }
|
|
21
|
+
|
|
22
|
+
export type RestoreTarget = { kind: 'latest' } | { kind: 'time'; iso: string } | { kind: 'backupEnd' }
|
|
23
|
+
|
|
24
|
+
type InTreeArchive = Extract<RestoreSource, { kind: 'inTree' }>
|
|
25
|
+
|
|
26
|
+
function isPluginBackup(b: any): boolean {
|
|
27
|
+
return b?.spec?.method === 'plugin' || b?.status?.method === 'plugin'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSnapshotBackup(b: any): boolean {
|
|
31
|
+
return b?.spec?.method === 'volumeSnapshot' || b?.status?.method === 'volumeSnapshot'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function backupEnd(b: any): string | undefined {
|
|
35
|
+
return b?.status?.stoppedAt || b?.status?.startedAt
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The in-tree archive a Backup recorded writing to. A restore of it reads WAL
|
|
40
|
+
* from there too, whatever the Cluster declares today.
|
|
41
|
+
*/
|
|
42
|
+
function recordedInTreeArchive(b: any): InTreeArchive | null {
|
|
43
|
+
const s = b?.status
|
|
44
|
+
const serverName = s?.serverName || b?.spec?.cluster?.name
|
|
45
|
+
if (isPluginBackup(b) || isSnapshotBackup(b) || typeof s?.destinationPath !== 'string' || !s.destinationPath || !serverName) return null
|
|
46
|
+
return { kind: 'inTree', barmanObjectStore: { destinationPath: s.destinationPath, endpointURL: s.endpointURL }, serverName }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sameInTreeArchive(a: InTreeArchive, b: InTreeArchive): boolean {
|
|
50
|
+
return cnpgArchiveMatchesCluster({ spec: { backup: { barmanObjectStore: { ...a.barmanObjectStore, serverName: a.serverName } } } }, b)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether the Cluster's WAL archiving writes the WAL a restore from this source replays. */
|
|
54
|
+
function clusterArchivesInto(source: RestoreSource, cluster: any, namespace: string, completed: any[]): boolean {
|
|
55
|
+
if (!cluster) return false
|
|
56
|
+
switch (source.kind) {
|
|
57
|
+
case 'inTree':
|
|
58
|
+
return true
|
|
59
|
+
case 'objectStore':
|
|
60
|
+
return cluster.metadata?.namespace === namespace && cnpgArchiveMatchesCluster(cluster, source) && getCNPGClusterBarmanPlugin(cluster)?.isWALArchiver === true
|
|
61
|
+
default: {
|
|
62
|
+
const archive = source.snapshot ? null : recordedInTreeArchive(completed.find((b) => b?.metadata?.name === source.backup))
|
|
63
|
+
return !!archive && cnpgArchiveMatchesCluster(cluster, archive)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The source a Backup restores from, given the cluster that took it (if it still exists). */
|
|
69
|
+
export function restoreSourceForBackup(backup: any, sourceCluster: any | null): RestoreSource | null {
|
|
70
|
+
if (backup?.status?.phase !== 'completed') return null
|
|
71
|
+
const name = backup.metadata?.name
|
|
72
|
+
const majorVersion = backup.status?.majorVersion > 0 ? backup.status.majorVersion : undefined
|
|
73
|
+
const version = majorVersion ? { majorVersion } : {}
|
|
74
|
+
if (!isPluginBackup(backup)) return { kind: 'backup', backup: name, backupEnd: backupEnd(backup), ...(isSnapshotBackup(backup) ? { snapshot: true } : {}), ...version }
|
|
75
|
+
if (getCNPGBackupPlugin(backup)?.name !== CNPG_BARMAN_PLUGIN_NAME) return null
|
|
76
|
+
if (!cnpgBackupMatchesCluster(backup, sourceCluster)) return null
|
|
77
|
+
const plugin = sourceCluster ? getCNPGClusterBarmanPlugin(sourceCluster) : null
|
|
78
|
+
const objectStore = plugin?.barmanObjectName
|
|
79
|
+
const serverName = plugin?.serverName
|
|
80
|
+
const backupID = backup.status?.backupId
|
|
81
|
+
if (!objectStore || !serverName || !backupID) return null
|
|
82
|
+
return { kind: 'objectStore', objectStore, serverName, backupID, backupName: name, backupEnd: backupEnd(backup), ...version }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Where a Cluster's backups can be restored from, in order of preference. */
|
|
86
|
+
export function restoreSourcesFor(cluster: any, backups: any[]): RestoreSource[] {
|
|
87
|
+
const name = cluster?.metadata?.name
|
|
88
|
+
const out: RestoreSource[] = []
|
|
89
|
+
const plugin = cluster ? getCNPGClusterBarmanPlugin(cluster) : null
|
|
90
|
+
if (plugin?.barmanObjectName) {
|
|
91
|
+
out.push({ kind: 'objectStore', objectStore: plugin.barmanObjectName, serverName: plugin.serverName || name })
|
|
92
|
+
}
|
|
93
|
+
const inTree = cluster?.spec?.backup?.barmanObjectStore
|
|
94
|
+
if (inTree) out.push({ kind: 'inTree', barmanObjectStore: inTree, serverName: inTree.serverName || name })
|
|
95
|
+
const completed = backups
|
|
96
|
+
.filter((b) => b?.spec?.cluster?.name === name && b?.metadata?.namespace === cluster?.metadata?.namespace && isApiGroup(b?.apiVersion ?? 'postgresql.cnpg.io/v1', 'postgresql.cnpg.io'))
|
|
97
|
+
.sort((a, b) => Date.parse(backupEnd(b) ?? '') - Date.parse(backupEnd(a) ?? ''))
|
|
98
|
+
for (const b of completed) {
|
|
99
|
+
const s = restoreSourceForBackup(b, cluster)
|
|
100
|
+
if (s) out.push(s)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type CNPGRestoreSourceState = 'available' | 'none' | 'unknown'
|
|
106
|
+
|
|
107
|
+
export function assessRestoreSources(data: CNPGWorkspaceResponse | undefined, namespace: string, cluster: any): { sources: RestoreSource[]; recoveryState: CNPGRestoreSourceState; disabledReason?: string; unreadReason?: string } {
|
|
108
|
+
const readable = !!data && coverageReadable(data.coverage.backups ?? { state: 'notInstalled' }, namespace)
|
|
109
|
+
const backups = readable ? data!.objects.backups ?? [] : []
|
|
110
|
+
const sources = cluster ? restoreSourcesFor(cluster, backups) : []
|
|
111
|
+
const unread: string[] = []
|
|
112
|
+
if (!readable) unread.push(`Backup sources could not be read in ${namespace}.`)
|
|
113
|
+
if (!cluster) unread.push('The source Cluster could not be read.')
|
|
114
|
+
const storesReadable = !!data && coverageReadable(data.coverage.objectStores ?? { state: 'notInstalled' }, namespace)
|
|
115
|
+
const stores = storesReadable ? data!.objects.objectStores ?? [] : []
|
|
116
|
+
const recordedRecovery = sources.some((source) => {
|
|
117
|
+
if (source.kind === 'backup') return true
|
|
118
|
+
const evidence = recoveryEvidenceFor(source, { sourceCluster: cluster, stores, backups, namespace })
|
|
119
|
+
return !!(evidence.lastBackup || evidence.firstPoint)
|
|
120
|
+
})
|
|
121
|
+
for (const source of sources) {
|
|
122
|
+
if (source.kind !== 'objectStore') continue
|
|
123
|
+
if (!stores.some((store) => store.metadata?.namespace === namespace && store.metadata?.name === source.objectStore)) {
|
|
124
|
+
unread.push(`ObjectStore ${source.objectStore} could not be read in ${namespace}, so its recovery evidence is unknown.`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const plugin of cluster?.spec?.plugins ?? []) {
|
|
128
|
+
if (plugin.enabled !== false && plugin.name !== CNPG_BARMAN_PLUGIN_NAME) unread.push(`Recovery sources for plugin ${plugin.name} cannot be assessed.`)
|
|
129
|
+
}
|
|
130
|
+
// Schedules are not consulted: barman-cloud takes the destination from the
|
|
131
|
+
// Cluster, so a schedule neither adds a source nor hides one.
|
|
132
|
+
for (const backup of backups) {
|
|
133
|
+
if (backup.spec?.cluster?.name !== cluster?.metadata?.name || backup.metadata?.namespace !== namespace || !isApiGroup(backup.apiVersion, 'postgresql.cnpg.io') || backup.spec?.method !== 'plugin') continue
|
|
134
|
+
const pluginName = getCNPGBackupPlugin(backup)?.name
|
|
135
|
+
if (pluginName !== CNPG_BARMAN_PLUGIN_NAME) {
|
|
136
|
+
unread.push(`Recovery sources for Backup ${backup.metadata?.name} using plugin ${pluginName ?? '(not named)'} cannot be assessed.`)
|
|
137
|
+
} else if (backup.status?.phase === 'completed' && !cnpgBackupMatchesCluster(backup, cluster)) {
|
|
138
|
+
unread.push(`Backup ${backup.metadata?.name} belongs to a previous Cluster incarnation, so its archive cannot be inferred from this Cluster.`)
|
|
139
|
+
} else if (backup.status?.phase === 'completed' && !objectStoreForBackup(backup, [cluster])) {
|
|
140
|
+
unread.push(`Backup ${backup.metadata?.name} completed with the barman-cloud plugin, but the Cluster no longer names an ObjectStore, so where it is stored is unknown.`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const recoveryState: CNPGRestoreSourceState = recordedRecovery ? 'available' : unread.length ? 'unknown' : 'none'
|
|
144
|
+
return {
|
|
145
|
+
sources,
|
|
146
|
+
recoveryState,
|
|
147
|
+
unreadReason: unread.length ? unread.join(' ') : undefined,
|
|
148
|
+
disabledReason: recoveryState === 'none' && sources.length === 0 ? 'Nothing to restore from yet: no backup destination and no completed Backup.' : undefined,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function restoreImage(cluster: any, source?: RestoreSource): { imageCatalogRef?: any; imageName?: string } {
|
|
153
|
+
const spec = cluster?.spec
|
|
154
|
+
if (source && (source.kind === 'backup' || sourcePinsBackup(source))) {
|
|
155
|
+
if (!source.majorVersion) return {}
|
|
156
|
+
if (spec?.imageCatalogRef) return { imageCatalogRef: { ...spec.imageCatalogRef, major: source.majorVersion } }
|
|
157
|
+
const running = cluster?.status?.pgDataImageInfo
|
|
158
|
+
return getCNPGPostgresMajor(cluster) === source.majorVersion && running?.image ? { imageName: running.image } : {}
|
|
159
|
+
}
|
|
160
|
+
if (spec?.imageCatalogRef) return { imageCatalogRef: spec.imageCatalogRef }
|
|
161
|
+
const imageName = spec?.imageName || cluster?.status?.image
|
|
162
|
+
return imageName ? { imageName } : {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One ObjectStore source per server its status reports, newest evidence first. */
|
|
166
|
+
export function restoreSourcesForStore(store: any): RestoreSource[] {
|
|
167
|
+
const name = store?.metadata?.name
|
|
168
|
+
return getCNPGObjectStoreRecoveryWindows(store)
|
|
169
|
+
.sort((a, b) => Date.parse(b.lastSuccessfulBackupTime ?? '') - Date.parse(a.lastSuccessfulBackupTime ?? ''))
|
|
170
|
+
.map((w) => ({ kind: 'objectStore' as const, objectStore: name, serverName: w.server }))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The live Cluster with this archive declaration, when there is one. */
|
|
174
|
+
export function sourceClusterFor(source: RestoreSource, clusters: any[], namespace: string): any | null {
|
|
175
|
+
if (source.kind === 'backup') return null
|
|
176
|
+
return (
|
|
177
|
+
clusters.find((c) => {
|
|
178
|
+
if (c?.metadata?.namespace !== namespace) return false
|
|
179
|
+
return cnpgArchiveMatchesCluster(c, source)
|
|
180
|
+
}) ?? null
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function describeSource(s: RestoreSource): string {
|
|
185
|
+
switch (s.kind) {
|
|
186
|
+
case 'objectStore':
|
|
187
|
+
return s.backupName ? `Backup ${s.backupName} (ID ${s.backupID}) in ObjectStore ${s.objectStore}` : `ObjectStore ${s.objectStore} · server ${s.serverName}`
|
|
188
|
+
case 'inTree':
|
|
189
|
+
return `Barman object store (in-tree) · server ${s.serverName}`
|
|
190
|
+
default:
|
|
191
|
+
return s.snapshot ? `Backup ${s.backup} (volume snapshot)` : `Backup ${s.backup}`
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A source pinned to one base backup can also stop at that backup's end. */
|
|
196
|
+
export function sourcePinsBackup(s: RestoreSource | undefined): boolean {
|
|
197
|
+
return s?.kind === 'objectStore' && !!s.backupID
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The recovery targets the manifest can deliver from a source, the default first. */
|
|
201
|
+
export function restoreTargetKinds(s: RestoreSource | undefined): RestoreTarget['kind'][] {
|
|
202
|
+
if (s?.kind === 'backup' && s.snapshot) return ['backupEnd']
|
|
203
|
+
return sourcePinsBackup(s) ? ['backupEnd', 'latest', 'time'] : ['latest', 'time']
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface EvidencePoint {
|
|
207
|
+
at: string
|
|
208
|
+
source: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What Radar can show about how far back and how far forward a restore can
|
|
213
|
+
* reach. Each point names where it came from; a missing point is unknown,
|
|
214
|
+
* never "none".
|
|
215
|
+
*/
|
|
216
|
+
export interface RecoveryEvidence {
|
|
217
|
+
firstPoint?: EvidencePoint
|
|
218
|
+
lastBackup?: EvidencePoint
|
|
219
|
+
lastArchived?: EvidencePoint & { wal?: string }
|
|
220
|
+
lastArchiveFailure?: EvidencePoint & { wal?: string }
|
|
221
|
+
archiving: { text: string; tone: HealthLevel; source: string; detail?: string }
|
|
222
|
+
gaps: string[]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function newest(points: (EvidencePoint | undefined)[]): EvidencePoint | undefined {
|
|
226
|
+
return points.filter((p): p is EvidencePoint => !!p && Number.isFinite(Date.parse(p.at))).sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function recoveryEvidenceFor(
|
|
230
|
+
source: RestoreSource | undefined,
|
|
231
|
+
ctx: { sourceCluster: any | null; stores: any[]; backups: any[]; runtime?: CNPGRuntimeResponse; namespace: string },
|
|
232
|
+
): RecoveryEvidence {
|
|
233
|
+
const gaps: string[] = []
|
|
234
|
+
const out: RecoveryEvidence = { archiving: { text: 'Not reported', tone: 'unknown', source: 'No source cluster to read' }, gaps }
|
|
235
|
+
if (!source) return out
|
|
236
|
+
const cluster = ctx.sourceCluster
|
|
237
|
+
const clusterName = cluster?.metadata?.name
|
|
238
|
+
|
|
239
|
+
const completed = ctx.backups.filter((b) => b?.metadata?.namespace === ctx.namespace && b?.status?.phase === 'completed')
|
|
240
|
+
const point = (b: any): EvidencePoint | undefined => (backupEnd(b) ? { at: backupEnd(b)!, source: `Backup ${b.metadata?.name}` } : undefined)
|
|
241
|
+
const sameNamespace = cluster?.metadata?.namespace === ctx.namespace
|
|
242
|
+
|
|
243
|
+
if (source.kind === 'objectStore') {
|
|
244
|
+
const store = ctx.stores.find((s) => s?.metadata?.namespace === ctx.namespace && s?.metadata?.name === source.objectStore)
|
|
245
|
+
if (!store) gaps.push(`ObjectStore ${source.objectStore} is not readable, so its recovery window is unknown`)
|
|
246
|
+
const w = store ? getCNPGObjectStoreRecoveryWindows(store).find((x) => x.server === source.serverName) : undefined
|
|
247
|
+
if (store && !w) gaps.push(`ObjectStore ${source.objectStore} reports no recovery window for server ${source.serverName}`)
|
|
248
|
+
const where = `ObjectStore ${source.objectStore} status, server ${source.serverName}`
|
|
249
|
+
if (w?.firstRecoverabilityPoint) out.firstPoint = { at: w.firstRecoverabilityPoint, source: where }
|
|
250
|
+
if (w?.lastSuccessfulBackupTime) out.lastBackup = { at: w.lastSuccessfulBackupTime, source: where }
|
|
251
|
+
// barman-cloud writes a Backup where its Cluster's plugin points, so a
|
|
252
|
+
// Backup counts only while its Cluster still points at this archive.
|
|
253
|
+
const intoStore = cluster && sameNamespace && cnpgArchiveMatchesCluster(cluster, source)
|
|
254
|
+
? completed.filter((b) => isPluginBackup(b) && getCNPGBackupPlugin(b)?.name === CNPG_BARMAN_PLUGIN_NAME && cnpgBackupMatchesCluster(b, cluster))
|
|
255
|
+
: []
|
|
256
|
+
out.lastBackup = newest([out.lastBackup, ...intoStore.map(point)])
|
|
257
|
+
} else if (source.kind === 'inTree') {
|
|
258
|
+
if (cluster) {
|
|
259
|
+
// An operator that reports per-method times folds volume snapshots into
|
|
260
|
+
// the plain fields; one that does not predates counting them there.
|
|
261
|
+
const st = cluster.status ?? {}
|
|
262
|
+
const first = st.firstRecoverabilityPointByMethod ? st.firstRecoverabilityPointByMethod.barmanObjectStore : st.firstRecoverabilityPoint
|
|
263
|
+
const last = st.lastSuccessfulBackupByMethod ? st.lastSuccessfulBackupByMethod.barmanObjectStore : st.lastSuccessfulBackup
|
|
264
|
+
if (first) out.firstPoint = { at: first, source: `Cluster ${clusterName} status` }
|
|
265
|
+
if (last) out.lastBackup = { at: last, source: `Cluster ${clusterName} status` }
|
|
266
|
+
}
|
|
267
|
+
const intoArchive = completed.filter((b) => {
|
|
268
|
+
const archive = recordedInTreeArchive(b)
|
|
269
|
+
return !!archive && sameInTreeArchive(archive, source)
|
|
270
|
+
})
|
|
271
|
+
out.lastBackup = newest([out.lastBackup, ...intoArchive.map(point)])
|
|
272
|
+
} else if (source.backupEnd) {
|
|
273
|
+
out.lastBackup = { at: source.backupEnd, source: `Backup ${source.backup}` }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (source.kind === 'backup' && source.snapshot) {
|
|
277
|
+
out.archiving = { text: 'Not replayed', tone: 'neutral', source: 'A volume snapshot restores to its own end; no archived WAL is applied' }
|
|
278
|
+
} else if (clusterArchivesInto(source, cluster, ctx.namespace, completed)) {
|
|
279
|
+
const cond = (cluster.status?.conditions ?? []).find((c: any) => c?.type === 'ContinuousArchiving')
|
|
280
|
+
if (!cond) out.archiving = { text: 'Not reported', tone: 'unknown', source: `Cluster ${clusterName} has no ContinuousArchiving condition` }
|
|
281
|
+
else if (cond.status === 'True') out.archiving = { text: 'Archiving', tone: 'healthy', source: `ContinuousArchiving condition on ${clusterName}` }
|
|
282
|
+
else if (cond.status === 'False') out.archiving = { text: 'Failing', tone: 'unhealthy', source: `ContinuousArchiving condition on ${clusterName}`, ...(cond.message ? { detail: cond.message } : {}) }
|
|
283
|
+
else out.archiving = { text: 'Unknown', tone: 'unknown', source: `ContinuousArchiving condition on ${clusterName}` }
|
|
284
|
+
|
|
285
|
+
const primary = ctx.runtime?.instances.find((i) => i.role === 'primary')
|
|
286
|
+
const arch = primary?.status.state === 'ok' ? primary.status.archiving : undefined
|
|
287
|
+
if (arch?.lastArchivedAt) out.lastArchived = { at: arch.lastArchivedAt, wal: arch.lastArchivedWal, source: `instance manager on ${primary!.pod}` }
|
|
288
|
+
if (arch?.lastFailedAt) out.lastArchiveFailure = { at: arch.lastFailedAt, wal: arch.lastFailedWal, source: `instance manager on ${primary!.pod}` }
|
|
289
|
+
if (!arch) {
|
|
290
|
+
gaps.push(
|
|
291
|
+
!ctx.runtime
|
|
292
|
+
? 'The last archived WAL time is not loaded'
|
|
293
|
+
: ctx.runtime.permission.proxy === 'denied'
|
|
294
|
+
? `The last archived WAL time needs ${formatGrant(ctx.runtime.permission.grant) ?? 'get pods/proxy'}`
|
|
295
|
+
: !primary
|
|
296
|
+
? 'No primary is reported, so the last archived WAL time is unknown'
|
|
297
|
+
: primary.status.state !== 'ok'
|
|
298
|
+
? `The primary’s instance manager could not be read${primary.status.error ? ` (${primary.status.error})` : ''}, so the last archived WAL time is unknown`
|
|
299
|
+
: 'The primary’s instance manager did not report archiving',
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
out.archiving = { text: 'Unknown', tone: 'unknown', source: 'No live cluster archives into this source' }
|
|
304
|
+
gaps.push('No live cluster archives into this source, so WAL archived after the last backup is unknown')
|
|
305
|
+
}
|
|
306
|
+
if (!out.firstPoint && source.kind !== 'backup') gaps.push('The first recoverability point is not reported')
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The newest moment any evidence says the archive reaches. */
|
|
311
|
+
export function latestEvidence(e: RecoveryEvidence): EvidencePoint | undefined {
|
|
312
|
+
return newest([e.lastBackup, e.lastArchived])
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Warnings for a point-in-time target. Never a block: the evidence can be
|
|
317
|
+
* stale or partial, and the operator is the authority on what the archive
|
|
318
|
+
* holds.
|
|
319
|
+
*/
|
|
320
|
+
export function pitrWarnings(targetIso: string, e: RecoveryEvidence, now = Date.now()): string[] {
|
|
321
|
+
const t = Date.parse(targetIso)
|
|
322
|
+
if (!Number.isFinite(t)) return ['The target time is not a valid date.']
|
|
323
|
+
const out: string[] = []
|
|
324
|
+
if (t > now) out.push('The target is in the future; recovery cannot reach it and will fail.')
|
|
325
|
+
if (e.firstPoint && t < Date.parse(e.firstPoint.at)) {
|
|
326
|
+
out.push(`The target is before the first recoverability point (${formatUTC(e.firstPoint.at)}, from ${e.firstPoint.source}); no base backup is old enough and recovery will fail.`)
|
|
327
|
+
}
|
|
328
|
+
const latest = latestEvidence(e)
|
|
329
|
+
if (latest && t > Date.parse(latest.at) && t <= now) {
|
|
330
|
+
out.push(`The target is after the newest evidence Radar has (${formatUTC(latest.at)}, from ${latest.source}). If WAL up to the target was not archived, PostgreSQL stops recovery with an error.`)
|
|
331
|
+
}
|
|
332
|
+
if (!latest && !e.firstPoint) out.push('Radar has no evidence of what this source holds, so the target cannot be checked.')
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function formatUTC(iso: string): string {
|
|
337
|
+
const d = new Date(iso)
|
|
338
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
339
|
+
return `${d.toISOString().slice(0, 19).replace('T', ' ')} UTC`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function formatLocal(iso: string): string {
|
|
343
|
+
const d = new Date(iso)
|
|
344
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
345
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
346
|
+
return `${d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })} ${tz}`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A datetime-local value read in UTC or the browser's zone, as RFC 3339 without milliseconds. */
|
|
350
|
+
export function targetIsoFrom(value: string, zone: 'utc' | 'local'): string | null {
|
|
351
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(value)) return null
|
|
352
|
+
const withSeconds = value.length === 16 ? `${value}:00` : value
|
|
353
|
+
const d = zone === 'utc' ? new Date(`${withSeconds}Z`) : new Date(withSeconds)
|
|
354
|
+
if (Number.isNaN(d.getTime())) return null
|
|
355
|
+
return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface PreflightFact {
|
|
359
|
+
label: string
|
|
360
|
+
value: string
|
|
361
|
+
path: string
|
|
362
|
+
copied: boolean
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What the manifest copies from the source cluster, to review before creating. */
|
|
366
|
+
export function preflightFacts(cluster: any | null): PreflightFact[] {
|
|
367
|
+
const spec = cluster?.spec ?? {}
|
|
368
|
+
const facts: PreflightFact[] = []
|
|
369
|
+
const add = (label: string, path: string, value: string | undefined, fallback: string) =>
|
|
370
|
+
facts.push({ label, path, value: value ?? fallback, copied: value !== undefined })
|
|
371
|
+
add('Instances', 'spec.instances', spec.instances !== undefined ? String(spec.instances) : undefined, '1 (default; no source cluster to copy from)')
|
|
372
|
+
const image = restoreImage(cluster)
|
|
373
|
+
if (image.imageCatalogRef) {
|
|
374
|
+
const ref = image.imageCatalogRef
|
|
375
|
+
add('Image', 'spec.imageCatalogRef', `${ref.kind ?? 'ImageCatalog'} ${ref.name}, major ${ref.major}`, '')
|
|
376
|
+
} else {
|
|
377
|
+
add('Image', 'spec.imageName', image.imageName ? `${image.imageName}${spec.imageName ? '' : ' (from status.image)'}` : undefined, 'operator default — set it to the source’s PostgreSQL major')
|
|
378
|
+
}
|
|
379
|
+
add('Data storage', 'spec.storage', spec.storage ? storageText(spec.storage) : undefined, 'not set — set spec.storage.size to at least the source’s data size')
|
|
380
|
+
if (spec.walStorage) add('WAL storage', 'spec.walStorage', storageText(spec.walStorage), '')
|
|
381
|
+
if (Array.isArray(spec.tablespaces) && spec.tablespaces.length > 0) {
|
|
382
|
+
add('Tablespaces', 'spec.tablespaces', spec.tablespaces.map((t: any) => `${t.name} (${storageText(t.storage ?? {})})`).join(', '), '')
|
|
383
|
+
}
|
|
384
|
+
const params = spec.postgresql?.parameters
|
|
385
|
+
if (params && Object.keys(params).length > 0) {
|
|
386
|
+
const keys = Object.keys(params).sort()
|
|
387
|
+
add('PostgreSQL parameters', 'spec.postgresql.parameters', `${keys.length}: ${keys.slice(0, 6).join(', ')}${keys.length > 6 ? ', …' : ''}`, '')
|
|
388
|
+
}
|
|
389
|
+
if (spec.resources && Object.keys(spec.resources).length > 0) add('Resources', 'spec.resources', resourcesText(spec.resources), '')
|
|
390
|
+
return facts
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** "requests: cpu 50m, memory 128Mi · limits: memory 256Mi" */
|
|
394
|
+
export function resourcesText(r: any): string {
|
|
395
|
+
const part = (label: string, m: Record<string, unknown> | undefined) =>
|
|
396
|
+
m && Object.keys(m).length > 0 ? `${label}: ${Object.entries(m).map(([k, v]) => `${k} ${String(v)}`).join(', ')}` : null
|
|
397
|
+
return [part('requests', r?.requests), part('limits', r?.limits)].filter(Boolean).join(' · ') || 'set'
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function storageText(s: any): string {
|
|
401
|
+
const parts = [s.size ?? s.pvcTemplate?.resources?.requests?.storage ?? 'size from pvcTemplate', s.storageClass ? `class ${s.storageClass}` : null]
|
|
402
|
+
return parts.filter(Boolean).join(', ')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* A new Cluster that bootstraps from `source`. It copies what the restored
|
|
407
|
+
* data needs from the source cluster and nothing that would make it write to
|
|
408
|
+
* the source's backup location: no plugins, no backup section. A target the
|
|
409
|
+
* source cannot deliver (a time past a volume snapshot) is not written.
|
|
410
|
+
*/
|
|
411
|
+
export function buildRestoreManifest(args: {
|
|
412
|
+
sourceCluster: any | null
|
|
413
|
+
source: RestoreSource
|
|
414
|
+
namespace: string
|
|
415
|
+
newName: string
|
|
416
|
+
target: RestoreTarget
|
|
417
|
+
}): Record<string, any> {
|
|
418
|
+
const { sourceCluster, source, namespace, newName, target } = args
|
|
419
|
+
const spec = sourceCluster?.spec ?? {}
|
|
420
|
+
const recovery: Record<string, any> = source.kind === 'backup' ? { backup: { name: source.backup } } : { source: 'origin' }
|
|
421
|
+
const pinned = source.kind === 'objectStore' && source.backupID ? { backupID: source.backupID } : {}
|
|
422
|
+
if (target.kind === 'backupEnd' && 'backupID' in pinned) recovery.recoveryTarget = { ...pinned, targetImmediate: true }
|
|
423
|
+
else if (target.kind === 'time' && restoreTargetKinds(source).includes('time')) recovery.recoveryTarget = { ...pinned, targetTime: target.iso }
|
|
424
|
+
else if ('backupID' in pinned) recovery.recoveryTarget = pinned
|
|
425
|
+
const out: Record<string, any> = {
|
|
426
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
427
|
+
kind: 'Cluster',
|
|
428
|
+
metadata: { name: newName, namespace },
|
|
429
|
+
spec: {
|
|
430
|
+
instances: spec.instances ?? 1,
|
|
431
|
+
...restoreImage(sourceCluster, source),
|
|
432
|
+
...(spec.postgresql?.parameters ? { postgresql: { parameters: spec.postgresql.parameters } } : {}),
|
|
433
|
+
...(spec.resources ? { resources: spec.resources } : {}),
|
|
434
|
+
storage: spec.storage ?? {},
|
|
435
|
+
...(spec.walStorage ? { walStorage: spec.walStorage } : {}),
|
|
436
|
+
...(spec.tablespaces ? { tablespaces: spec.tablespaces } : {}),
|
|
437
|
+
bootstrap: { recovery },
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
if (source.kind === 'objectStore') {
|
|
441
|
+
out.spec.externalClusters = [
|
|
442
|
+
{ name: 'origin', plugin: { name: CNPG_BARMAN_PLUGIN_NAME, parameters: { barmanObjectName: source.objectStore, serverName: source.serverName } } },
|
|
443
|
+
]
|
|
444
|
+
} else if (source.kind === 'inTree') {
|
|
445
|
+
out.spec.externalClusters = [{ name: 'origin', barmanObjectStore: { ...source.barmanObjectStore, serverName: source.serverName } }]
|
|
446
|
+
}
|
|
447
|
+
return out
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function restoreManifestHeader(sourceLabel: string, sourceServer: string | undefined, copiedFrom: string | null): string {
|
|
451
|
+
const lines = [
|
|
452
|
+
`# Restores ${sourceLabel} into a new cluster. Review before creating:`,
|
|
453
|
+
copiedFrom
|
|
454
|
+
? `# - instances, image, storage, WAL storage, tablespaces, PostgreSQL parameters and resources are copied from ${copiedFrom}; edit them here.`
|
|
455
|
+
: '# - no source cluster was found: set the image and storage for the source before creating.',
|
|
456
|
+
'# - the new cluster has no WAL archiving or backups until you configure them.',
|
|
457
|
+
sourceServer
|
|
458
|
+
? `# When you do, use a new serverName: archiving under "${sourceServer}" would write into the archive this restores from.`
|
|
459
|
+
: '# When you do, use a new destination: never the location this restores from.',
|
|
460
|
+
'# - the dry-run checks the manifest, not whether the backups are readable.',
|
|
461
|
+
]
|
|
462
|
+
return `${lines.join('\n')}\n`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- restore follow-through ----------
|
|
466
|
+
|
|
467
|
+
export type RestoreState = 'progressing' | 'completed' | 'failed' | 'unobservable'
|
|
468
|
+
|
|
469
|
+
export interface RestoreObservation {
|
|
470
|
+
state: RestoreState
|
|
471
|
+
title: string
|
|
472
|
+
detail?: string
|
|
473
|
+
tone: HealthLevel
|
|
474
|
+
/** The Pod whose logs explain the current step. */
|
|
475
|
+
logsPod?: { name: string; container?: string }
|
|
476
|
+
recoveryPods: CNPGRecoveryPod[]
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const FAILING_PHASE = /unrecoverable|unable to|cannot|error|failed/i
|
|
480
|
+
const CRASH_REASONS = new Set(['CrashLoopBackOff', 'Error', 'ImagePullBackOff', 'ErrImagePull', 'CreateContainerConfigError', 'CreateContainerError'])
|
|
481
|
+
|
|
482
|
+
function failingContainer(p: CNPGRecoveryPod) {
|
|
483
|
+
return [...p.initContainers, ...p.containers].find(
|
|
484
|
+
(c) => (c.state === 'waiting' && c.reason && CRASH_REASONS.has(c.reason)) || (c.state === 'terminated' && c.exitCode !== undefined && c.exitCode !== 0),
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A running init container is a restartable sidecar (the plugin's), not the
|
|
489
|
+
// step in progress; an init container still waiting is.
|
|
490
|
+
function activeContainer(p: CNPGRecoveryPod) {
|
|
491
|
+
const init = p.initContainers.find((c) => c.state === 'waiting' || c.state === 'unknown' || (c.state === 'terminated' && (c.exitCode ?? 0) !== 0))
|
|
492
|
+
return init ?? p.containers.find((c) => c.state === 'running') ?? p.containers[0]
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const RECOVERY_JOB_ROLES = ['full-recovery', 'snapshot-recovery']
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Whether a Cluster's Job restores data: by its cnpg.io/jobRole, or, read
|
|
499
|
+
* without the role, by the `<instance>-<role>` name CloudNativePG gives it.
|
|
500
|
+
* The Cluster's other Jobs (a replica's join, initdb, a major upgrade) run
|
|
501
|
+
* long after a restore and say nothing about it.
|
|
502
|
+
*/
|
|
503
|
+
export function isCNPGRecoveryJob(job: { name: string; role?: string }): boolean {
|
|
504
|
+
return job.role ? RECOVERY_JOB_ROLES.includes(job.role) : RECOVERY_JOB_ROLES.some((role) => job.name.endsWith(`-${role}`))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether the restored primary is up: an instance Pod is ready. CloudNativePG
|
|
509
|
+
* starts the first instance only after the recovery Job succeeded, so this
|
|
510
|
+
* needs no look at Jobs (a replica's join Job later is not recovery). Unlike
|
|
511
|
+
* a completed restore, it stays true when the cluster later loses a replica
|
|
512
|
+
* or is mid-switchover.
|
|
513
|
+
*/
|
|
514
|
+
export function cnpgRestoredPrimaryUp(snap: CNPGRecoveryResponse): boolean {
|
|
515
|
+
return snap.coverage.pods?.state === 'ok' && snap.pods.some((p) => p.kind === 'instance' && p.ready)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Where a restore stands, from the recovery snapshot alone. The panel stays
|
|
520
|
+
* mounted for the Cluster's whole life, so only recovery Jobs and their Pods
|
|
521
|
+
* count. Completion is a completed recovery Job, which stays recorded while
|
|
522
|
+
* every instance is down (hibernated, or restarting), or a restored instance
|
|
523
|
+
* being ready (a ready instance Pod, or the Cluster's readyInstances, which
|
|
524
|
+
* needs no Pod access): CloudNativePG starts one only after recovery
|
|
525
|
+
* succeeded, so a later switchover, scale-up or failed join never reopens the
|
|
526
|
+
* restore. Failure is a failed recovery Job, or a failing phase before any
|
|
527
|
+
* instance was ready. Missing Pod access never reads as progress or failure.
|
|
528
|
+
*/
|
|
529
|
+
export function observeRestore(snap: CNPGRecoveryResponse): RestoreObservation {
|
|
530
|
+
const recoveryJobs = snap.jobs.filter(isCNPGRecoveryJob)
|
|
531
|
+
const recoveryPods = snap.pods.filter((p) => p.kind === 'job' && !!p.job && isCNPGRecoveryJob(snap.jobs.find((j) => j.name === p.job) ?? { name: p.job }))
|
|
532
|
+
const instances = snap.pods.filter((p) => p.kind === 'instance')
|
|
533
|
+
const c = snap.cluster
|
|
534
|
+
const base = { recoveryPods }
|
|
535
|
+
const podsVisible = snap.coverage.pods?.state === 'ok'
|
|
536
|
+
|
|
537
|
+
const failedJob = recoveryJobs.find((j) => j.failedWith)
|
|
538
|
+
if (failedJob) {
|
|
539
|
+
const pod = recoveryPods.find((p) => p.job === failedJob.name)
|
|
540
|
+
return { ...base, state: 'failed', tone: 'unhealthy', title: `Recovery Job ${failedJob.name} failed`, detail: failedJob.failedWith, logsPod: pod ? { name: pod.name, container: failingContainer(pod)?.name } : undefined }
|
|
541
|
+
}
|
|
542
|
+
// A full recovery Job runs PostgreSQL until recovery ends. A snapshot
|
|
543
|
+
// recovery Job without a WAL source only puts the files in place; PostgreSQL
|
|
544
|
+
// first starts on the instance, so that Job proves nothing.
|
|
545
|
+
const provesRestore = (j: { name: string; role?: string }) =>
|
|
546
|
+
(j.role ?? (j.name.endsWith('-full-recovery') ? 'full-recovery' : '')) === 'full-recovery' || !!snap.recovery?.source
|
|
547
|
+
const finishedJob = recoveryJobs.find((j) => (j.complete || j.succeeded > 0) && provesRestore(j))
|
|
548
|
+
const primaryUp = cnpgRestoredPrimaryUp(snap) || (c.readyInstances ?? 0) >= 1
|
|
549
|
+
if (finishedJob || primaryUp) {
|
|
550
|
+
return {
|
|
551
|
+
...base,
|
|
552
|
+
state: 'completed',
|
|
553
|
+
tone: 'healthy',
|
|
554
|
+
title: 'Restore completed',
|
|
555
|
+
detail: [
|
|
556
|
+
!primaryUp && finishedJob ? `Recovery Job ${finishedJob.name} completed` : null,
|
|
557
|
+
c.readyInstances !== null && c.instances !== null ? `${c.readyInstances}/${c.instances} instances ready` : null,
|
|
558
|
+
c.phase,
|
|
559
|
+
].filter(Boolean).join(' · '),
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (c.phase && c.phase !== HEALTHY_PHASE && FAILING_PHASE.test(c.phase)) {
|
|
563
|
+
return { ...base, state: 'failed', tone: 'unhealthy', title: c.phase, detail: c.phaseReason || c.ready?.message }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const crashing = recoveryPods.map((p) => ({ p, c: failingContainer(p) })).find((x) => x.c)
|
|
567
|
+
if (crashing) {
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
state: 'progressing',
|
|
571
|
+
tone: 'alert',
|
|
572
|
+
title: `Recovery attempt failing in ${crashing.p.name}`,
|
|
573
|
+
detail: `${crashing.c!.name}: ${[crashing.c!.reason, crashing.c!.message].filter(Boolean).join(' · ') || 'exited with an error'}. The Job retries; its logs say why.`,
|
|
574
|
+
logsPod: { name: crashing.p.name, container: crashing.c!.name },
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const running = recoveryPods.find((p) => p.phase === 'Running' || p.phase === 'Pending')
|
|
578
|
+
if (running) {
|
|
579
|
+
const active = activeContainer(running)
|
|
580
|
+
return {
|
|
581
|
+
...base,
|
|
582
|
+
state: 'progressing',
|
|
583
|
+
tone: 'neutral',
|
|
584
|
+
title: 'Restoring the base backup and replaying WAL',
|
|
585
|
+
detail: `${running.name}${active ? ` · ${active.name} ${active.state}` : ''}${c.phase ? ` · ${c.phase}` : ''}`,
|
|
586
|
+
logsPod: { name: running.name, container: active?.name },
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!podsVisible && !c.phase) {
|
|
590
|
+
return { ...base, state: 'unobservable', tone: 'unknown', title: 'Progress not visible', detail: snap.coverage.pods?.grant ? `Following the restore needs ${formatGrant(snap.coverage.pods.grant)}` : 'The Cluster has not reported a phase yet' }
|
|
591
|
+
}
|
|
592
|
+
const starting = instances.find((p) => !p.ready)
|
|
593
|
+
return {
|
|
594
|
+
...base,
|
|
595
|
+
state: 'progressing',
|
|
596
|
+
tone: 'neutral',
|
|
597
|
+
title: recoveryPods.some((p) => p.phase === 'Succeeded') ? 'Recovery finished; starting the restored instances' : 'Waiting for the recovery to start',
|
|
598
|
+
detail: [c.phase, c.readyInstances !== null && c.instances !== null ? `${c.readyInstances}/${c.instances} ready` : null, !podsVisible ? `Pods not visible${snap.coverage.pods?.grant ? ` (needs ${formatGrant(snap.coverage.pods.grant)})` : ''}` : null]
|
|
599
|
+
.filter(Boolean)
|
|
600
|
+
.join(' · '),
|
|
601
|
+
logsPod: starting ? { name: starting.name, container: 'postgres' } : undefined,
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* What the restore capability means for the dialog: `blocked` disables Review
|
|
607
|
+
* with an alert (the grant, or the operator's webhook refusing writes);
|
|
608
|
+
* `pending` only while it is being checked. A failed check blocks nothing: the
|
|
609
|
+
* server dry-run at review still has the final say, and `unchecked` says so.
|
|
610
|
+
*/
|
|
611
|
+
export function restorePermission(
|
|
612
|
+
namespace: string,
|
|
613
|
+
cap: ActionCapability | undefined,
|
|
614
|
+
error: unknown,
|
|
615
|
+
): { blocked?: string; pending?: string; unchecked?: string } {
|
|
616
|
+
if (cap) {
|
|
617
|
+
if (cap.allowed) return {}
|
|
618
|
+
if (cap.permission === 'denied') return { blocked: `Restoring creates a Cluster in ${namespace}, which needs ${formatGrant(cap.grant) ?? 'create clusters (postgresql.cnpg.io)'}.` }
|
|
619
|
+
return { blocked: cap.reason ?? 'Restoring is not available right now.' }
|
|
620
|
+
}
|
|
621
|
+
if (error) return { unchecked: `Whether you may create a Cluster in ${namespace} could not be checked (${error instanceof Error ? error.message : 'unknown error'}); the review step will tell.` }
|
|
622
|
+
return { pending: `Checking whether you may create a Cluster in ${namespace}…` }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export type RestoreNextStepId = 'connect' | 'validate' | 'backup'
|
|
626
|
+
|
|
627
|
+
export interface RestoreNextStep {
|
|
628
|
+
id: RestoreNextStepId
|
|
629
|
+
label: string
|
|
630
|
+
/** Set only where Radar can tell; `unknown` otherwise (whether applications moved is never known). */
|
|
631
|
+
state: 'done' | 'partial' | 'todo' | 'unknown'
|
|
632
|
+
note?: string
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* What the Cluster spec declares for protection: WAL archiving (the Barman
|
|
637
|
+
* plugin as WAL archiver with its ObjectStore named, or barmanObjectStore),
|
|
638
|
+
* the plugin marked as WAL archiver but naming no ObjectStore to archive to,
|
|
639
|
+
* base backups without WAL archiving (the plugin not marked isWALArchiver),
|
|
640
|
+
* volume snapshots only, or nothing.
|
|
641
|
+
*/
|
|
642
|
+
export type RestoreBackupDeclared = 'walArchiving' | 'archiverWithoutDestination' | 'backupsNoArchiving' | 'snapshotsOnly' | 'none'
|
|
643
|
+
|
|
644
|
+
const BACKUP_STEP: Record<RestoreBackupDeclared | 'unread', Pick<RestoreNextStep, 'state' | 'note'>> = {
|
|
645
|
+
unread: { state: 'unknown', note: 'Its backup configuration was not read' },
|
|
646
|
+
walArchiving: { state: 'partial', note: 'WAL archiving is configured. Verify uploads and complete a new base backup; configuration alone does not prove protection.' },
|
|
647
|
+
archiverWithoutDestination: {
|
|
648
|
+
state: 'partial',
|
|
649
|
+
note: 'The Barman plugin is set as WAL archiver but names no ObjectStore (parameters.barmanObjectName), so WAL has nowhere to go',
|
|
650
|
+
},
|
|
651
|
+
backupsNoArchiving: { state: 'partial', note: 'An ObjectStore is declared, but not as the WAL archiver, so no point-in-time recovery' },
|
|
652
|
+
snapshotsOnly: { state: 'partial', note: 'Volume snapshots declared, but no WAL archiving, so no point-in-time recovery' },
|
|
653
|
+
none: { state: 'todo', note: 'It has no backup destination or WAL archiving yet' },
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The checklist a restored cluster shows once it is healthy. Links only;
|
|
658
|
+
* Radar writes nothing. Validation reads the recorded note; backups read the
|
|
659
|
+
* Cluster spec (undefined when not read). Snapshots alone are partial: they
|
|
660
|
+
* give no point-in-time recovery.
|
|
661
|
+
*/
|
|
662
|
+
export function restoreNextSteps(input: { validationRecorded: boolean; backup: RestoreBackupDeclared | undefined }): RestoreNextStep[] {
|
|
663
|
+
const label = 'Set up and verify backups and WAL archiving'
|
|
664
|
+
const backup: RestoreNextStep = { id: 'backup', label, ...BACKUP_STEP[input.backup ?? 'unread'] }
|
|
665
|
+
return [
|
|
666
|
+
{ id: 'connect', label: 'Point applications at it', state: 'unknown', note: 'Radar cannot tell which applications use it' },
|
|
667
|
+
input.validationRecorded
|
|
668
|
+
? { id: 'validate', label: 'Record what you checked', state: 'done', note: 'A validation note is recorded' }
|
|
669
|
+
: { id: 'validate', label: 'Record what you checked', state: 'todo' },
|
|
670
|
+
backup,
|
|
671
|
+
]
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** What a Cluster spec declares for backups and WAL archiving. */
|
|
675
|
+
export function restoreBackupDeclared(cluster: any): RestoreBackupDeclared {
|
|
676
|
+
const declaration = cnpgBackupDeclaration(cluster)
|
|
677
|
+
const plugin = cnpgBarmanPlugin(declaration)
|
|
678
|
+
// plugin-barman-cloud reads the archive destination from the plugin's own
|
|
679
|
+
// parameters; the recovery source's ObjectStore is a separate setting.
|
|
680
|
+
if ((plugin?.isWALArchiver && plugin.barmanObjectName) || declaration.inTreeDestination) return 'walArchiving'
|
|
681
|
+
if (plugin?.isWALArchiver) return 'archiverWithoutDestination'
|
|
682
|
+
if (plugin?.barmanObjectName) return 'backupsNoArchiving'
|
|
683
|
+
if (declaration.snapshotsConfigured) return 'snapshotsOnly'
|
|
684
|
+
return 'none'
|
|
685
|
+
}
|