@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,510 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGClusterHA } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import type { CNPGClusterFacts, CNPGRuntimeResponse } from '../../../api/cnpg'
|
|
4
|
+
import { CNPG_OP_FIRST_READ_MS, CNPG_OP_STALL_MS, CNPG_OP_UNOBSERVABLE_MS, advanceCNPGOperation, restartedSince, summarizeSteps, supersedeFor, type CNPGObservation, type CNPGTrackedOperation } from './model'
|
|
5
|
+
|
|
6
|
+
const T0 = Date.parse('2026-09-30T12:00:00Z')
|
|
7
|
+
|
|
8
|
+
function op(over: Partial<CNPGTrackedOperation>): CNPGTrackedOperation {
|
|
9
|
+
return {
|
|
10
|
+
id: 'op',
|
|
11
|
+
kind: 'switchover',
|
|
12
|
+
label: 'Switchover to pg-2',
|
|
13
|
+
apiBase: '/api',
|
|
14
|
+
context: 'kind',
|
|
15
|
+
namespace: 'db',
|
|
16
|
+
cluster: 'pg',
|
|
17
|
+
clusterUID: 'uid-1',
|
|
18
|
+
target: { name: 'pg-2' },
|
|
19
|
+
startedAt: T0,
|
|
20
|
+
baseline: { currentPrimary: 'pg-1' },
|
|
21
|
+
state: 'requested',
|
|
22
|
+
lastProgressAt: T0,
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function facts(over: Partial<CNPGClusterFacts>): CNPGClusterFacts {
|
|
28
|
+
return {
|
|
29
|
+
generation: 0,
|
|
30
|
+
currentPrimary: 'pg-1',
|
|
31
|
+
targetPrimary: 'pg-1',
|
|
32
|
+
phase: 'Cluster in healthy state',
|
|
33
|
+
hibernation: '',
|
|
34
|
+
hibernated: false,
|
|
35
|
+
archivingFailing: false,
|
|
36
|
+
fencedInstances: { raw: '', all: false, instances: [] },
|
|
37
|
+
instances: [],
|
|
38
|
+
backupMethods: [],
|
|
39
|
+
isReplicaCluster: false,
|
|
40
|
+
terminating: false,
|
|
41
|
+
maintenance: { declared: false, inProgress: false, reusePVC: true },
|
|
42
|
+
...over,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runtime(primary: string, standbys: { pod: string; state: string }[]): CNPGRuntimeResponse {
|
|
47
|
+
return {
|
|
48
|
+
cluster: { namespace: 'db', name: 'pg', uid: 'uid-1' },
|
|
49
|
+
sampledAt: '',
|
|
50
|
+
permission: { proxy: 'allowed' },
|
|
51
|
+
instances: [
|
|
52
|
+
{ pod: primary, role: 'primary', status: { state: 'ok', replication: standbys.map((s) => ({ applicationName: s.pod, state: s.state })) }, metrics: { state: 'ok' } },
|
|
53
|
+
...standbys.map((s) => ({ pod: s.pod, role: 'replica' as const, status: { state: 'ok' as const }, metrics: { state: 'ok' as const } })),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ha(over: Partial<CNPGClusterHA>): CNPGClusterHA {
|
|
59
|
+
return {
|
|
60
|
+
cluster: { namespace: 'db', name: 'pg', uid: 'uid-1' },
|
|
61
|
+
sampledAt: '',
|
|
62
|
+
instances: [],
|
|
63
|
+
pods: { state: 'ok' },
|
|
64
|
+
nodes: { state: 'ok' },
|
|
65
|
+
quorum: { enabled: false, object: { state: 'notFound' } },
|
|
66
|
+
pdbs: { state: 'ok', enabled: true, items: [] },
|
|
67
|
+
primaryLease: { state: 'notFound' },
|
|
68
|
+
operatorLease: { state: 'notFound' },
|
|
69
|
+
jobs: { state: 'ok', items: [] },
|
|
70
|
+
rwEndpoints: { state: 'ok', service: 'pg-rw', pods: [] },
|
|
71
|
+
certificates: [],
|
|
72
|
+
maintenance: { declared: false, inProgress: false, reusePVC: true },
|
|
73
|
+
...over,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fresh = { updatedAt: T0 + 30_000, failed: false, clusterUID: 'uid-1' }
|
|
78
|
+
const obs = (o: Partial<CNPGObservation>): CNPGObservation => ({
|
|
79
|
+
now: T0 + 30_000,
|
|
80
|
+
clusterUID: 'uid-1',
|
|
81
|
+
freshness: { clusterUID: fresh, facts: fresh, cluster: fresh, ha: fresh, runtime: fresh, backups: fresh },
|
|
82
|
+
...o,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('switchover observer', () => {
|
|
86
|
+
it('walks from observed to completed only when the old primary streams and rw points at the target', () => {
|
|
87
|
+
let o = advanceCNPGOperation(op({}), obs({ facts: facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' }) }))
|
|
88
|
+
expect(o.state).toBe('observed')
|
|
89
|
+
|
|
90
|
+
o = advanceCNPGOperation(o, obs({ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'startup' }]), ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }) }))
|
|
91
|
+
expect(o.state).toBe('progressing')
|
|
92
|
+
|
|
93
|
+
o = advanceCNPGOperation(o, obs({ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]), ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }) }))
|
|
94
|
+
expect(o.state).toBe('completed')
|
|
95
|
+
expect(o.steps?.every((s) => s.done)).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is unobservable, not completed or stalled, when the rejoin cannot be read', () => {
|
|
99
|
+
const denied = { ...runtime('pg-2', []), permission: { proxy: 'denied' as const } }
|
|
100
|
+
const o = advanceCNPGOperation(op({}), obs({ now: T0 + CNPG_OP_STALL_MS * 3, facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: denied }))
|
|
101
|
+
expect(o.state).toBe('unobservable')
|
|
102
|
+
expect(o.detail).toContain('runtime access')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('fails when a different instance became primary', () => {
|
|
106
|
+
const o = advanceCNPGOperation(op({}), obs({ facts: facts({ currentPrimary: 'pg-3', targetPrimary: 'pg-3' }) }))
|
|
107
|
+
expect(o.state).toBe('failed')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('stalls only with telemetry showing no movement', () => {
|
|
111
|
+
const f = facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' })
|
|
112
|
+
let o = advanceCNPGOperation(op({}), obs({ facts: f }))
|
|
113
|
+
o = advanceCNPGOperation(o, obs({ now: T0 + CNPG_OP_STALL_MS + 60_000, facts: f }))
|
|
114
|
+
expect(o.state).toBe('stalled')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('is superseded when the Cluster was recreated', () => {
|
|
118
|
+
const o = advanceCNPGOperation(op({}), obs({ clusterUID: 'uid-2', facts: facts({}) }))
|
|
119
|
+
expect(o.state).toBe('superseded')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it.each([
|
|
123
|
+
undefined,
|
|
124
|
+
{ updatedAt: T0 - 1, failed: false },
|
|
125
|
+
{ updatedAt: T0 + 30_000, failed: true },
|
|
126
|
+
])('withholds an unverified replacement UID (%j)', (clusterUID) => {
|
|
127
|
+
const o = advanceCNPGOperation(op({}), obs({ clusterUID: 'uid-2', freshness: { clusterUID } }))
|
|
128
|
+
expect(o.state).not.toBe('superseded')
|
|
129
|
+
expect(o.finishedAt).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('restart evidence', () => {
|
|
134
|
+
it('counts a recreated Pod, a restarted container or a moved postmaster start', () => {
|
|
135
|
+
const since = T0
|
|
136
|
+
const h = ha({ instances: [{ pod: 'pg-1', podUID: 'new', role: 'primary', ready: true, restartCount: 0, podCreatedAt: '2026-09-30T11:00:00Z', postgresStartedAt: '2026-09-30T11:00:00Z' }] })
|
|
137
|
+
expect(restartedSince(obs({ ha: h }), 'pg-1', since, 'old')).toBe(true)
|
|
138
|
+
expect(restartedSince(obs({ ha: h }), 'pg-1', since, 'new')).toBe(false)
|
|
139
|
+
const rt = runtime('pg-1', [])
|
|
140
|
+
rt.instances[0].metrics.postmasterStartTime = (T0 + 5_000) / 1000
|
|
141
|
+
expect(restartedSince(obs({ ha: h, runtime: rt }), 'pg-1', since, 'new')).toBe(true)
|
|
142
|
+
expect(restartedSince(obs({}), 'pg-1', since)).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('completes a rolling restart when every instance restarted and is ready', () => {
|
|
146
|
+
const h = ha({
|
|
147
|
+
instances: [
|
|
148
|
+
{ pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, restartCount: 0, postgresStartedAt: '2026-09-30T12:01:00Z' },
|
|
149
|
+
{ pod: 'pg-2', podUID: 'b2', role: 'replica', ready: true, restartCount: 0 },
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
const o = advanceCNPGOperation(op({ kind: 'restart', baseline: { instances: ['pg-1', 'pg-2'], podUIDs: { 'pg-1': 'a', 'pg-2': 'b' } } }), obs({ ha: h, facts: facts({}) }))
|
|
153
|
+
expect(o.state).toBe('completed')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('backup observer', () => {
|
|
158
|
+
const b = (phase?: string) => [{ metadata: { name: 'pg-1', namespace: 'db' }, status: phase ? { phase } : {} }]
|
|
159
|
+
it('follows the Backup phase and never guesses without access', () => {
|
|
160
|
+
const base = op({ kind: 'backup', target: { name: 'pg-1' } })
|
|
161
|
+
expect(advanceCNPGOperation(base, obs({ backups: b('running') })).state).toBe('progressing')
|
|
162
|
+
expect(advanceCNPGOperation(base, obs({ backups: b('completed') })).state).toBe('completed')
|
|
163
|
+
expect(advanceCNPGOperation(base, obs({ backups: b('failed') })).state).toBe('failed')
|
|
164
|
+
expect(advanceCNPGOperation(base, obs({ backups: undefined })).state).toBe('unobservable')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('never calls a long backup stalled: its phase holds while the data copies', () => {
|
|
168
|
+
const base = op({ kind: 'backup', target: { name: 'pg-1' } })
|
|
169
|
+
for (const phase of ['pending', 'started', 'running', 'finalizing']) {
|
|
170
|
+
let o = advanceCNPGOperation(base, obs({ backups: b(phase) }))
|
|
171
|
+
o = advanceCNPGOperation(o, obs({ now: T0 + 2 * 3_600_000, backups: b(phase) }))
|
|
172
|
+
expect(o.state).toBe('progressing')
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('still stalls a Backup the operator never picked up', () => {
|
|
177
|
+
const base = op({ kind: 'backup', target: { name: 'pg-1' } })
|
|
178
|
+
let o = advanceCNPGOperation(base, obs({ backups: b() }))
|
|
179
|
+
o = advanceCNPGOperation(o, obs({ now: T0 + CNPG_OP_STALL_MS + 60_000, backups: b() }))
|
|
180
|
+
expect(o.state).toBe('stalled')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('reload', () => {
|
|
185
|
+
it('says there is no completion signal', () => {
|
|
186
|
+
const o = advanceCNPGOperation(op({ kind: 'reload' }), obs({}))
|
|
187
|
+
expect(o.state).toBe('unobservable')
|
|
188
|
+
expect(o.detail).toContain('Nothing in the cluster reports')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('supersedeFor', () => {
|
|
193
|
+
it('marks a conflicting unfinished operation on the same cluster', () => {
|
|
194
|
+
const older = op({ id: 'a' })
|
|
195
|
+
const next = op({ id: 'b', kind: 'hibernate', label: 'Hibernate' })
|
|
196
|
+
expect(supersedeFor([older], next)[0].state).toBe('superseded')
|
|
197
|
+
expect(supersedeFor([op({ id: 'c', cluster: 'other' })], next)[0].state).toBe('requested')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('never reaches a record issued against another Radar', () => {
|
|
201
|
+
const next = op({ id: 'b', kind: 'hibernate', label: 'Hibernate' })
|
|
202
|
+
expect(supersedeFor([op({ id: 'a', apiBase: '/c/other/api' })], next)[0].state).toBe('requested')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('summarizeSteps', () => {
|
|
207
|
+
it('never completes past an unobservable step', () => {
|
|
208
|
+
expect(summarizeSteps([{ label: 'a', done: true }, { label: 'b', done: null }])).toBe('unobservable')
|
|
209
|
+
expect(summarizeSteps([{ label: 'a', done: true }, { label: 'b', done: false }])).toBe('progressing')
|
|
210
|
+
expect(summarizeSteps([{ label: 'a', done: false }])).toBe('requested')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('destroyInstance observer', () => {
|
|
215
|
+
const inst = (pod: string, ready: boolean) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready, healthy: ready, fenced: false, podReadable: true, podExists: true })
|
|
216
|
+
const destroy = op({ kind: 'destroyInstance', target: { name: 'pg-3', uid: 'uid-pg-3' }, baseline: { instances: ['pg-1', 'pg-2', 'pg-3'] } })
|
|
217
|
+
|
|
218
|
+
it('stays open until a replacement instance exists and is ready', () => {
|
|
219
|
+
const next = advanceCNPGOperation(destroy, obs({ now: T0 + 60_000, facts: facts({ phase: 'Creating a new replica', instances: [inst('pg-1', true), inst('pg-2', true), inst('pg-4', false)] }) }))
|
|
220
|
+
expect(next.state).toBe('progressing')
|
|
221
|
+
expect(next.steps?.[0].done).toBe(true)
|
|
222
|
+
expect(next.steps?.[1].label).toContain('pg-4')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('never completes while the destroyed instance is still there', () => {
|
|
226
|
+
expect(advanceCNPGOperation(destroy, obs({ now: T0 + 60_000, facts: facts({ instances: [inst('pg-1', true), inst('pg-2', true), inst('pg-3', true)] }) })).state).not.toBe('completed')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('operations Radar cannot observe', () => {
|
|
231
|
+
it('a reload finishes at once instead of being followed all session', () => {
|
|
232
|
+
const next = advanceCNPGOperation(op({ kind: 'reload', target: undefined }), obs({ now: T0 + 1000 }))
|
|
233
|
+
expect(next.state).toBe('unobservable')
|
|
234
|
+
expect(next.finishedAt).toBe(T0 + 1000)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('anything else unobservable is left alone after the follow window', () => {
|
|
238
|
+
const early = advanceCNPGOperation(op({ kind: 'switchover' }), { now: T0 + 60_000 })
|
|
239
|
+
expect(early.finishedAt).toBeUndefined()
|
|
240
|
+
const late = advanceCNPGOperation(op({ kind: 'switchover' }), { now: T0 + 16 * 60_000 })
|
|
241
|
+
expect(late.state).toBe('unobservable')
|
|
242
|
+
expect(late.finishedAt).toBe(T0 + 16 * 60_000)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it.each(['reload', 'switchover'])('keeps an old %s operation open while its identity read is in flight', (kind) => {
|
|
246
|
+
const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
|
|
247
|
+
const pending = advanceCNPGOperation(op({ kind }), { now, identityPending: true })
|
|
248
|
+
expect(pending.state).toBe('unobservable')
|
|
249
|
+
expect(pending.detail).toContain('identity')
|
|
250
|
+
expect(pending.finishedAt).toBeUndefined()
|
|
251
|
+
const replaced = advanceCNPGOperation(pending, obs({ now: now + 1, clusterUID: 'uid-2' }))
|
|
252
|
+
expect(replaced.state).toBe('superseded')
|
|
253
|
+
expect(replaced.finishedAt).toBe(now + 1)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('bounds tracking after an unavailable identity read has settled', () => {
|
|
257
|
+
const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
|
|
258
|
+
const unknown = advanceCNPGOperation(op({ kind: 'reload' }), { now, identityPending: false })
|
|
259
|
+
expect(unknown.state).toBe('unobservable')
|
|
260
|
+
expect(unknown.detail).toContain('identity')
|
|
261
|
+
expect(unknown.finishedAt).toBe(now)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('fence observer', () => {
|
|
266
|
+
const inst = (pod: string, ready: boolean) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready, healthy: ready, fenced: true, podReadable: true, podExists: true })
|
|
267
|
+
const fenced = facts({ fencedInstances: { raw: '["pg-2"]', all: false, instances: ['pg-2'] }, instances: [inst('pg-2', false)] })
|
|
268
|
+
const fence = () => op({ kind: 'fence', label: 'Fence pg-2', target: undefined, baseline: { instances: ['pg-2'] } })
|
|
269
|
+
const withStatus = (status: Record<string, unknown>) => {
|
|
270
|
+
const rt = runtime('pg-1', [])
|
|
271
|
+
rt.instances.push({ pod: 'pg-2', role: 'replica', status, metrics: { state: 'ok' } } as never)
|
|
272
|
+
return rt
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
it('never claims PostgreSQL stopped: CloudNativePG reports no shutdown signal', () => {
|
|
276
|
+
for (const status of [{ state: 'ok' }, { state: 'ok', mightBeUnavailable: true }, { state: 'partial' }]) {
|
|
277
|
+
const o = advanceCNPGOperation(fence(), obs({ facts: fenced, runtime: withStatus(status) }))
|
|
278
|
+
expect(o.state).not.toBe('completed')
|
|
279
|
+
expect(o.steps?.find((x) => x.label.includes('PostgreSQL stopped'))?.done).toBeNull()
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
it('stops following once the fence is recorded, as unverified', () => {
|
|
283
|
+
const o = advanceCNPGOperation(fence(), obs({ facts: fenced }))
|
|
284
|
+
expect(o.state).toBe('unobservable')
|
|
285
|
+
expect(o.steps?.[0].done).toBe(true)
|
|
286
|
+
expect(o.finishedAt).toBeDefined()
|
|
287
|
+
})
|
|
288
|
+
it('keeps following until the fence annotation lands', () => {
|
|
289
|
+
const o = advanceCNPGOperation(fence(), obs({ facts: facts({ instances: [inst('pg-2', false)] }) }))
|
|
290
|
+
expect(o.steps?.[0].done).toBe(false)
|
|
291
|
+
expect(o.finishedAt).toBeUndefined()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('stale observation sources', () => {
|
|
296
|
+
const promoted = () => ({
|
|
297
|
+
facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }),
|
|
298
|
+
runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]),
|
|
299
|
+
ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }),
|
|
300
|
+
})
|
|
301
|
+
const now = T0 + 30_000
|
|
302
|
+
|
|
303
|
+
it('an hour-old endpoints answer whose refresh failed cannot certify a switchover', () => {
|
|
304
|
+
const o = advanceCNPGOperation(
|
|
305
|
+
op({}),
|
|
306
|
+
obs({
|
|
307
|
+
...promoted(),
|
|
308
|
+
freshness: {
|
|
309
|
+
clusterUID: fresh,
|
|
310
|
+
facts: { updatedAt: now, failed: false, clusterUID: 'uid-1' },
|
|
311
|
+
runtime: { updatedAt: now, failed: false, clusterUID: 'uid-1' },
|
|
312
|
+
ha: { updatedAt: T0 - 3_600_000, failed: true },
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
expect(o.state).not.toBe('completed')
|
|
317
|
+
expect(o.steps?.find((s) => s.label.startsWith('Read-write Service'))?.done).toBeNull()
|
|
318
|
+
})
|
|
319
|
+
it('an answer from before the action is withheld even when its refresh did not fail', () => {
|
|
320
|
+
const o = advanceCNPGOperation(
|
|
321
|
+
op({}),
|
|
322
|
+
obs({ ...promoted(), freshness: { clusterUID: fresh, facts: { updatedAt: now, failed: false, clusterUID: 'uid-1' }, runtime: { updatedAt: now, failed: false, clusterUID: 'uid-1' }, ha: { updatedAt: T0 - 1, failed: false } } }),
|
|
323
|
+
)
|
|
324
|
+
expect(o.state).not.toBe('completed')
|
|
325
|
+
})
|
|
326
|
+
it('fresh sources still complete it', () => {
|
|
327
|
+
const o = advanceCNPGOperation(op({}), obs({ ...promoted(), freshness: { clusterUID: fresh, facts: fresh, runtime: fresh, ha: fresh } }))
|
|
328
|
+
expect(o.state).toBe('completed')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it.each(['facts', 'ha', 'runtime'] as const)('a fresh %s response from another Cluster cannot certify completion', (source) => {
|
|
332
|
+
const observation = obs({ ...promoted() })
|
|
333
|
+
if (source === 'ha') observation.ha!.cluster.uid = 'another-uid'
|
|
334
|
+
if (source === 'runtime') observation.runtime!.cluster.uid = 'another-uid'
|
|
335
|
+
observation.freshness = { ...observation.freshness, [source]: { ...fresh, clusterUID: 'another-uid' } }
|
|
336
|
+
const result = advanceCNPGOperation(op({}), observation)
|
|
337
|
+
expect(result.state).not.toBe('completed')
|
|
338
|
+
expect(result.finishedAt).toBeUndefined()
|
|
339
|
+
})
|
|
340
|
+
it('a source with no freshness record is withheld', () => {
|
|
341
|
+
const o = advanceCNPGOperation(op({}), { ...obs(promoted()), freshness: undefined })
|
|
342
|
+
expect(o.state).not.toBe('completed')
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe('switchover without readable endpoints', () => {
|
|
347
|
+
it('never completes while the read-write Service is unverified', () => {
|
|
348
|
+
const o = advanceCNPGOperation(
|
|
349
|
+
op({}),
|
|
350
|
+
obs({
|
|
351
|
+
facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }),
|
|
352
|
+
runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]),
|
|
353
|
+
ha: ha({ rwEndpoints: { state: 'denied', grant: { verb: 'list', group: 'discovery.k8s.io', resource: 'endpointslices', namespace: 'db' }, service: 'pg-rw', pods: [] } }),
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
356
|
+
expect(o.state).toBe('unobservable')
|
|
357
|
+
const step = o.steps?.find((s) => s.label.startsWith('Read-write Service'))
|
|
358
|
+
expect(step?.done).toBeNull()
|
|
359
|
+
expect(o.detail).toContain('read-write Service is unverified')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('unread readiness after an observed restart', () => {
|
|
364
|
+
it.each(['restart', 'restartInstance'])('keeps %s unobservable rather than stalled', (kind) => {
|
|
365
|
+
const rt = runtime('pg-1', [])
|
|
366
|
+
rt.instances[0].metrics.postmasterStartTime = (T0 + 1000) / 1000
|
|
367
|
+
const operation = op({ kind, target: { name: 'pg-1' }, baseline: { instances: ['pg-1'] } })
|
|
368
|
+
const result = advanceCNPGOperation(operation, obs({ runtime: rt }))
|
|
369
|
+
expect(result.steps?.[0].done).toBeNull()
|
|
370
|
+
expect(result.state).toBe('unobservable')
|
|
371
|
+
expect(advanceCNPGOperation(result, obs({ now: T0 + CNPG_OP_STALL_MS + 1, runtime: rt })).state).toBe('unobservable')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('supersedes a scheduled run when its source Cluster is recreated', () => {
|
|
376
|
+
const operation = op({ kind: 'run', target: { name: 'manual-backup' } })
|
|
377
|
+
const result = advanceCNPGOperation(operation, obs({ clusterUID: 'replacement', backups: [{ metadata: { name: 'manual-backup', namespace: 'db' }, status: { phase: 'completed' } }] }))
|
|
378
|
+
expect(result.state).toBe('superseded')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('does not certify a backup whose source identity was never recorded', () => {
|
|
382
|
+
const result = advanceCNPGOperation(op({ kind: 'run', clusterUID: undefined, target: { name: 'manual-backup' } }), obs({ backups: [{ metadata: { name: 'manual-backup', namespace: 'db' }, status: { phase: 'completed' } }] }))
|
|
383
|
+
expect(result.state).toBe('unobservable')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('right after the request', () => {
|
|
387
|
+
const before = { updatedAt: T0 - 1_000, failed: false, clusterUID: 'uid-1' }
|
|
388
|
+
|
|
389
|
+
it.each([
|
|
390
|
+
['no read yet', undefined],
|
|
391
|
+
['only reads from before the request', { clusterUID: before, facts: before, cluster: before, ha: before, runtime: before, backups: before }],
|
|
392
|
+
])('waits for the first read instead of reading "cannot be followed" (%s)', (_, freshness) => {
|
|
393
|
+
const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', facts: facts({}), freshness })
|
|
394
|
+
expect(o.state).toBe('requested')
|
|
395
|
+
expect(o.detail).toBe('Waiting for the first read since the request')
|
|
396
|
+
expect(o.finishedAt).toBeUndefined()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('follows the observer once a read since the request lands', () => {
|
|
400
|
+
const waiting = advanceCNPGOperation(op({}), obs({ now: T0 + 1_000, freshness: { clusterUID: before, facts: before } }))
|
|
401
|
+
expect(waiting.state).toBe('requested')
|
|
402
|
+
const observed = advanceCNPGOperation(waiting, obs({ now: T0 + 5_000, facts: facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' }) }))
|
|
403
|
+
expect(observed.state).toBe('observed')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('waits while a source the observer needs has not been re-read since the request', () => {
|
|
407
|
+
const fresh = { updatedAt: T0 + 500, failed: false, clusterUID: 'uid-1' }
|
|
408
|
+
const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: before } })
|
|
409
|
+
expect(o.state).toBe('requested')
|
|
410
|
+
expect(o.detail).toBe('Waiting for the first read since the request')
|
|
411
|
+
const later = advanceCNPGOperation(op({}), { now: T0 + CNPG_OP_FIRST_READ_MS, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: before } })
|
|
412
|
+
expect(later.state).toBe('unobservable')
|
|
413
|
+
const failed = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: { ...before, failed: true } } })
|
|
414
|
+
expect(failed.state).toBe('unobservable')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('an identity from before the request does not hold "requested" while the reads fail', () => {
|
|
418
|
+
const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, identityReadFailed: true, freshness: { clusterUID: before } })
|
|
419
|
+
expect(o.state).toBe('unobservable')
|
|
420
|
+
expect(o.detail).toContain('identity')
|
|
421
|
+
expect(o.finishedAt).toBeUndefined()
|
|
422
|
+
const none = advanceCNPGOperation(op({}), { now: T0 + 1_000, identityReadFailed: true })
|
|
423
|
+
expect(none.state).toBe('unobservable')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('a failed read makes it unobservable without ending it', () => {
|
|
427
|
+
const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, freshness: { clusterUID: { updatedAt: T0 - 1_000, failed: true } } })
|
|
428
|
+
expect(o.state).toBe('unobservable')
|
|
429
|
+
expect(o.detail).toContain('identity')
|
|
430
|
+
expect(o.finishedAt).toBeUndefined()
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('still gives up after the follow window without a read', () => {
|
|
434
|
+
const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
|
|
435
|
+
const o = advanceCNPGOperation(op({}), { now, freshness: { clusterUID: before } })
|
|
436
|
+
expect(o.state).toBe('unobservable')
|
|
437
|
+
expect(o.finishedAt).toBe(now)
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
describe('rolling restart and fencing', () => {
|
|
442
|
+
const restart = (fenced?: string[]) =>
|
|
443
|
+
op({ kind: 'restart', label: 'Rolling restart', target: undefined, baseline: { instances: ['pg-1', 'pg-2'], podUIDs: { 'pg-1': 'a', 'pg-2': 'b' }, ...(fenced ? { fenced } : {}) } })
|
|
444
|
+
// pg-2 restarted and is ready; the primary pg-1 has not restarted.
|
|
445
|
+
const h = ha({
|
|
446
|
+
instances: [
|
|
447
|
+
{ pod: 'pg-1', podUID: 'a', role: 'primary', ready: false, restartCount: 0 },
|
|
448
|
+
{ pod: 'pg-2', podUID: 'b2', role: 'replica', ready: true, restartCount: 0 },
|
|
449
|
+
],
|
|
450
|
+
})
|
|
451
|
+
const primaryFenced = facts({ fencedInstances: { raw: '["pg-1"]', all: false, instances: ['pg-1'] } })
|
|
452
|
+
const allFenced = facts({ fencedInstances: { raw: '["*"]', all: true, instances: [] } })
|
|
453
|
+
|
|
454
|
+
it.each([
|
|
455
|
+
['the primary', primaryFenced, []],
|
|
456
|
+
['every instance', allFenced, []],
|
|
457
|
+
['the primary, on an operation recorded without the fenced set', primaryFenced, undefined],
|
|
458
|
+
])('never completes when %s was fenced mid-restart before restarting', (_, current, fenced) => {
|
|
459
|
+
const o = advanceCNPGOperation(restart(fenced), obs({ ha: h, facts: current }))
|
|
460
|
+
expect(o.state).not.toBe('completed')
|
|
461
|
+
expect(o.steps?.find((s) => s.label === 'pg-1 restarted and ready')?.done).toBe(false)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('leaves out only the instances fenced when it was requested', () => {
|
|
465
|
+
const o = advanceCNPGOperation(restart(['pg-1']), obs({ ha: h, facts: primaryFenced }))
|
|
466
|
+
expect(o.state).toBe('completed')
|
|
467
|
+
expect(o.steps?.map((s) => s.label)).toEqual(['pg-2 restarted and ready'])
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('is superseded by a fence requested in Radar', () => {
|
|
471
|
+
const fence = op({ id: 'f', kind: 'fence', label: 'Fence pg-1', target: undefined, baseline: { instances: ['pg-1'] }, startedAt: T0 + 1_000 })
|
|
472
|
+
expect(supersedeFor([restart([])], fence)[0].state).toBe('superseded')
|
|
473
|
+
expect(supersedeFor([op({ kind: 'restartInstance', label: 'Restart pg-1', target: { name: 'pg-1' } })], fence)[0].state).toBe('superseded')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('an instance restart is superseded only by a fence that covers its instance; a rolling restart by any fence', () => {
|
|
477
|
+
const restartPg2 = op({ id: 'r', kind: 'restartInstance', label: 'Restart pg-2', target: { name: 'pg-2' }, baseline: {} })
|
|
478
|
+
const fence = (label: string, instances: string[]) => op({ id: 'f', kind: 'fence', label, target: undefined, baseline: { instances }, startedAt: T0 + 1_000 })
|
|
479
|
+
expect(supersedeFor([restartPg2], fence('Fence pg-3', ['pg-3']))[0].state).toBe('requested')
|
|
480
|
+
expect(supersedeFor([restartPg2], fence('Fence pg-2', ['pg-2']))[0].state).toBe('superseded')
|
|
481
|
+
expect(supersedeFor([restartPg2], fence('Fence all instances', ['pg-1', 'pg-2', 'pg-3']))[0].state).toBe('superseded')
|
|
482
|
+
expect(supersedeFor([restart([])], fence('Fence pg-3', ['pg-3']))[0].state).toBe('superseded')
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('unreadable readiness keeps a step unknown, not stalled', () => {
|
|
487
|
+
const unreadable = (pod: string) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready: false, healthy: false, fenced: false, podReadable: false, podExists: true })
|
|
488
|
+
const readable = (pod: string) => ({ ...unreadable(pod), ready: true, healthy: true, podReadable: true })
|
|
489
|
+
const later = T0 + CNPG_OP_STALL_MS + 60_000
|
|
490
|
+
|
|
491
|
+
it('lifting a fence', () => {
|
|
492
|
+
const unfence = op({ kind: 'unfence', label: 'Lift fence on pg-2', target: undefined, baseline: { instances: ['pg-2'] } })
|
|
493
|
+
const f = facts({ instances: [readable('pg-1'), unreadable('pg-2')] })
|
|
494
|
+
let o = advanceCNPGOperation(unfence, obs({ facts: f }))
|
|
495
|
+
expect(o.steps?.map((s) => s.done)).toEqual([null, null])
|
|
496
|
+
expect(o.state).toBe('unobservable')
|
|
497
|
+
o = advanceCNPGOperation(o, obs({ now: later, facts: f }))
|
|
498
|
+
expect(o.state).toBe('unobservable')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('destroying an instance', () => {
|
|
502
|
+
const destroy = op({ kind: 'destroyInstance', label: 'Destroy pg-3', target: { name: 'pg-3', uid: 'uid-pg-3' }, baseline: { instances: ['pg-1', 'pg-2', 'pg-3'] } })
|
|
503
|
+
const f = facts({ instances: [readable('pg-1'), readable('pg-2'), unreadable('pg-4')] })
|
|
504
|
+
let o = advanceCNPGOperation(destroy, obs({ facts: f }))
|
|
505
|
+
expect(o.steps?.map((s) => s.done)).toEqual([true, null, null, true])
|
|
506
|
+
expect(o.state).toBe('unobservable')
|
|
507
|
+
o = advanceCNPGOperation(o, obs({ now: later, facts: f }))
|
|
508
|
+
expect(o.state).toBe('unobservable')
|
|
509
|
+
})
|
|
510
|
+
})
|