@skyhook-io/k8s-ui 1.16.3 → 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 +41 -0
- package/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +57 -17
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -24
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +99 -39
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +8 -74
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +21 -2
- package/src/types/core.ts +60 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -1,8 +1,29 @@
|
|
|
1
|
+
import { cnpgDimensions } from './ha'
|
|
1
2
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildCNPGFleet, type CNPGWorkspaceResponse, type CNPGWorkspaceKey, CNPG_WORKSPACE_KEYS } from './workspace'
|
|
3
|
+
import { buildCNPGFleet, cnpgReadyInstances, cnpgRecoveryMatchesCluster, getCNPGRestoreValidation, type CNPGWorkspaceResponse, type CNPGWorkspaceKey, CNPG_WORKSPACE_KEYS } from './workspace'
|
|
3
4
|
|
|
4
5
|
const G = 'postgresql.cnpg.io/v1'
|
|
5
6
|
|
|
7
|
+
it('does not promote an archiving condition into evidence of an archive destination', () => {
|
|
8
|
+
const c = cluster('payments', 'db', { status: { conditions: [{ type: 'ContinuousArchiving', status: 'True', lastTransitionTime: '2026-10-01T12:00:00Z' }] } })
|
|
9
|
+
const fact = buildCNPGFleet(resp({ clusters: [c] })).rows[0].protection.walArchiving
|
|
10
|
+
expect(fact).toMatchObject({ text: 'Not archived: no destination configured', tone: 'neutral', source: 'Cluster spec' })
|
|
11
|
+
expect(fact.detail).toContain("WAL is not archived to recovery storage")
|
|
12
|
+
expect(fact.operatorCondition).toMatchObject({ type: 'ContinuousArchiving', status: 'True', lastTransitionTime: '2026-10-01T12:00:00Z' })
|
|
13
|
+
const snapshot = cluster('snapshot', 'db', { spec: { backup: { volumeSnapshot: {} } }, status: c.status })
|
|
14
|
+
expect(buildCNPGFleet(resp({ clusters: [snapshot] })).rows[0].protection.walArchiving.text).toBe('Not archived: no destination configured')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('preserves declared archiver failures and distinguishes backup-only and opaque archiver plugins', () => {
|
|
18
|
+
const make = (plugins: any[], status = 'True') => buildCNPGFleet(resp({ clusters: [cluster('pg', 'db', { spec: { plugins }, status: { conditions: [{ type: 'ContinuousArchiving', status, message: 'archive report' }] } })] })).rows[0].protection.walArchiving
|
|
19
|
+
const barman = { name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true }
|
|
20
|
+
expect(make([barman], 'False')).toMatchObject({ text: 'Failing', tone: 'unhealthy', detail: 'archive report' })
|
|
21
|
+
expect(make([barman])).toMatchObject({ text: 'Not archived: no destination configured', tone: 'neutral' })
|
|
22
|
+
expect(make([{ ...barman, isWALArchiver: false, parameters: { barmanObjectName: 'store' } }])).toMatchObject({ text: 'Not archived: no destination configured', tone: 'neutral' })
|
|
23
|
+
expect(make([{ name: 'third-party-archive', isWALArchiver: true }])).toMatchObject({ text: 'CNPG reports archiving', detail: 'Archive plugin declared; its destination is not assessed here' })
|
|
24
|
+
expect(make([{ ...barman, enabled: false, parameters: { barmanObjectName: 'store' } }])).toMatchObject({ text: 'Not archived: no destination configured', tone: 'neutral' })
|
|
25
|
+
})
|
|
26
|
+
|
|
6
27
|
function cluster(name: string, ns: string, extra: any = {}): any {
|
|
7
28
|
return {
|
|
8
29
|
apiVersion: G,
|
|
@@ -27,6 +48,10 @@ function pod(name: string, ns: string, clusterName: string, role: string, ready
|
|
|
27
48
|
}
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
function serverProblem(reason: string, message: string, kind = 'Cluster', name = 'pg-a', severity: 'critical' | 'warning' = 'warning') {
|
|
52
|
+
return { id: `server:${reason}`, reason, message, kind, name, namespace: 'db', group: 'postgresql.cnpg.io', severity }
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
function resp(objects: Partial<Record<CNPGWorkspaceKey, any[]>>, over: Partial<CNPGWorkspaceResponse> = {}): CNPGWorkspaceResponse {
|
|
31
56
|
const coverage: CNPGWorkspaceResponse['coverage'] = {}
|
|
32
57
|
for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
|
|
@@ -39,6 +64,7 @@ function resp(objects: Partial<Record<CNPGWorkspaceKey, any[]>>, over: Partial<C
|
|
|
39
64
|
issues: over.issues ?? [],
|
|
40
65
|
audit: over.audit ?? [],
|
|
41
66
|
backupsOmitted: 0,
|
|
67
|
+
...(over.scheduleReadings ? { scheduleReadings: over.scheduleReadings } : {}),
|
|
42
68
|
}
|
|
43
69
|
}
|
|
44
70
|
|
|
@@ -52,7 +78,7 @@ describe('buildCNPGFleet', () => {
|
|
|
52
78
|
)
|
|
53
79
|
const row = fleet.rows[0]
|
|
54
80
|
expect(row.replication.tone).toBe('unknown')
|
|
55
|
-
expect(row.replication.text).toContain('2/2
|
|
81
|
+
expect(row.replication.text).toContain('2/2 Pods ready')
|
|
56
82
|
expect(row.pods[0].role).toBe('primary')
|
|
57
83
|
})
|
|
58
84
|
|
|
@@ -81,6 +107,22 @@ describe('buildCNPGFleet', () => {
|
|
|
81
107
|
expect(fleet.rows[0].name).toBe('pg-b')
|
|
82
108
|
})
|
|
83
109
|
|
|
110
|
+
it('orders rows by worst problem, then problem count, then namespace/name', () => {
|
|
111
|
+
const issue = (id: string, severity: 'critical' | 'warning', name: string) => ({
|
|
112
|
+
id, severity, kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name, reason: 'CNPGClusterUnhealthy', message: id,
|
|
113
|
+
})
|
|
114
|
+
const fleet = buildCNPGFleet(
|
|
115
|
+
resp(
|
|
116
|
+
{ clusters: ['pg-a', 'pg-b', 'pg-c', 'pg-d', 'pg-e'].map((n) => cluster(n, 'db')) },
|
|
117
|
+
{
|
|
118
|
+
issues: [issue('w1', 'warning', 'pg-a'), issue('c1', 'critical', 'pg-b'), issue('w2', 'warning', 'pg-c'), issue('w3', 'warning', 'pg-c')],
|
|
119
|
+
audit: [{ checkId: 'cnpgNoDeclarativeBackup', severity: 'warning', kind: 'Cluster', namespace: 'db', name: 'pg-e', message: 'no ScheduledBackup' }],
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
expect(fleet.rows.map((r) => r.name)).toEqual(['pg-b', 'pg-c', 'pg-a', 'pg-e', 'pg-d'])
|
|
124
|
+
})
|
|
125
|
+
|
|
84
126
|
it('treats the no-schedule audit finding as posture, not attention, and words it narrowly', () => {
|
|
85
127
|
const fleet = buildCNPGFleet(
|
|
86
128
|
resp({ clusters: [cluster('pg-a', 'db')] }, {
|
|
@@ -131,7 +173,7 @@ describe('buildCNPGFleet', () => {
|
|
|
131
173
|
})],
|
|
132
174
|
}),
|
|
133
175
|
)
|
|
134
|
-
expect(fleet.rows[0].protection.lastSuccessfulBackup.text).toBe('
|
|
176
|
+
expect(fleet.rows[0].protection.lastSuccessfulBackup.text).toBe('No successful backup yet')
|
|
135
177
|
})
|
|
136
178
|
|
|
137
179
|
it('never marks restore validation healthy', () => {
|
|
@@ -144,15 +186,49 @@ describe('buildCNPGFleet', () => {
|
|
|
144
186
|
})
|
|
145
187
|
const fleet = buildCNPGFleet(resp({ clusters: [src, restored] }))
|
|
146
188
|
const a = fleet.rows.find((r) => r.name === 'pg-a')!
|
|
147
|
-
expect(a.protection.restoreValidation.text).toBe('
|
|
189
|
+
expect(a.protection.restoreValidation.text).toBe('Archive restored into pg-a-restore')
|
|
148
190
|
expect(a.protection.restoreValidation.tone).toBe('neutral')
|
|
149
191
|
const r = fleet.rows.find((x) => x.name === 'pg-a-restore')!
|
|
150
192
|
expect(r.protection.restoreValidation.text).toBe('None recorded')
|
|
151
193
|
expect(r.protection.restoreValidation.tone).toBe('unknown')
|
|
152
194
|
})
|
|
153
195
|
|
|
154
|
-
it('
|
|
196
|
+
it('matches a restored Cluster’s external source despite conflicting Backup parameters', () => {
|
|
197
|
+
const src = cluster('pg-a', 'db', { spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'cluster-store', serverName: 'cluster-server' } }] } })
|
|
198
|
+
const conflicting = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Backup', metadata: { name: 'b', namespace: 'db' }, spec: { cluster: { name: 'pg-a' }, method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'backup-store', serverName: 'backup-server' } } }, status: { phase: 'completed' } }
|
|
199
|
+
const restored = cluster('pg-a-restore', 'db', { spec: { bootstrap: { recovery: { source: 'origin' } }, externalClusters: [{ name: 'origin', plugin: { name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'cluster-store', serverName: 'cluster-server' } } }] } })
|
|
200
|
+
const fact = buildCNPGFleet(resp({ clusters: [src, restored], backups: [conflicting] })).rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation
|
|
201
|
+
expect(fact).toMatchObject({ text: 'Archive restored into pg-a-restore', tone: 'neutral' })
|
|
202
|
+
const wrongSource = { ...restored, spec: { ...restored.spec, externalClusters: [{ name: 'origin', plugin: { name: 'barman-cloud.cloudnative-pg.io', parameters: conflicting.spec.pluginConfiguration.parameters } }] } }
|
|
203
|
+
expect(buildCNPGFleet(resp({ clusters: [src, wrongSource], backups: [conflicting] })).rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation).toMatchObject({ text: 'None recorded', tone: 'unknown' })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('reads a recorded validation note on the restored cluster, still never healthy', () => {
|
|
155
207
|
const plugin = { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store' } }] }
|
|
208
|
+
const src = cluster('pg-a', 'db', { metadata: { uid: 'uid-a' }, spec: plugin })
|
|
209
|
+
const restoredSpec = {
|
|
210
|
+
bootstrap: { recovery: { source: 'origin' } },
|
|
211
|
+
externalClusters: [{ name: 'origin', plugin: { name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store', serverName: 'pg-a' } } }],
|
|
212
|
+
}
|
|
213
|
+
const note = (uid: string) =>
|
|
214
|
+
JSON.stringify({ version: 1, recordedAt: '2026-09-29T10:00:00Z', recordedBy: 'alice', checked: 'row counts on orders', source: { namespace: 'db', name: 'pg-a', uid, verified: true } })
|
|
215
|
+
const noted = cluster('pg-a-restore', 'db', { metadata: { annotations: { 'radar.skyhook.io/restore-validation': note('uid-a') } }, spec: restoredSpec })
|
|
216
|
+
const fleet = buildCNPGFleet(resp({ clusters: [src, noted] }))
|
|
217
|
+
const fact = fleet.rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation
|
|
218
|
+
expect(fact.text).toBe('Validation recorded')
|
|
219
|
+
expect(fact.tone).toBe('neutral')
|
|
220
|
+
expect(fact.at).toBe('2026-09-29T10:00:00Z')
|
|
221
|
+
expect(fact.source).toContain('by alice on pg-a-restore')
|
|
222
|
+
|
|
223
|
+
const otherUID = cluster('pg-a-restore', 'db', { metadata: { annotations: { 'radar.skyhook.io/restore-validation': note('uid-previous-incarnation') } }, spec: restoredSpec })
|
|
224
|
+
expect(buildCNPGFleet(resp({ clusters: [src, otherUID] })).rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation.text).toBe('None recorded')
|
|
225
|
+
|
|
226
|
+
const malformed = cluster('pg-a-restore', 'db', { metadata: { annotations: { 'radar.skyhook.io/restore-validation': '{not json' } }, spec: restoredSpec })
|
|
227
|
+
expect(buildCNPGFleet(resp({ clusters: [src, malformed] })).rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation.text).toBe('Archive restored into pg-a-restore')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('ends the recovery window at WAL archiving, not at the last base backup', () => {
|
|
231
|
+
const plugin = { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'store' } }] }
|
|
156
232
|
const store = {
|
|
157
233
|
apiVersion: 'barmancloud.cnpg.io/v1', kind: 'ObjectStore', metadata: { name: 'store', namespace: 'db' },
|
|
158
234
|
status: { serverRecoveryWindow: { 'pg-a': { firstRecoverabilityPoint: '2026-09-01T00:00:00Z', lastSuccessfulBackupTime: '2026-09-02T00:00:00Z', lastFailedBackupTime: '2026-09-03T00:00:00Z' } } },
|
|
@@ -165,9 +241,17 @@ describe('buildCNPGFleet', () => {
|
|
|
165
241
|
expect(buildCNPGFleet(resp({ clusters: [failing], objectStores: [store] })).rows[0].protection.recoveryWindow.tone).toBe('degraded')
|
|
166
242
|
})
|
|
167
243
|
|
|
244
|
+
it('qualifies archiving as the operator report with its transition time', () => {
|
|
245
|
+
const hourAgo = new Date(Date.now() - 3_600_000).toISOString()
|
|
246
|
+
const resumed = cluster('pg-a', 'db', { spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } }, metadata: { creationTimestamp: '2026-01-01T00:00:00Z' }, status: { conditions: [{ type: 'ContinuousArchiving', status: 'True', lastTransitionTime: hourAgo }] } })
|
|
247
|
+
expect(buildCNPGFleet(resp({ clusters: [resumed] })).rows[0].protection.walArchiving).toMatchObject({ text: 'CNPG reports archiving', source: 'Cluster status · ContinuousArchiving=True', at: hourAgo, atMeaning: 'since' })
|
|
248
|
+
const old = cluster('pg-a', 'db', { spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } }, metadata: { creationTimestamp: '2026-01-01T00:00:00Z' }, status: { conditions: [{ type: 'ContinuousArchiving', status: 'True', lastTransitionTime: '2026-01-01T00:02:00Z' }] } })
|
|
249
|
+
expect(buildCNPGFleet(resp({ clusters: [old] })).rows[0].protection.walArchiving.at).toBe('2026-01-01T00:02:00Z')
|
|
250
|
+
})
|
|
251
|
+
|
|
168
252
|
it('reports WAL archiving from the condition and unknown when absent', () => {
|
|
169
|
-
const failing = cluster('pg-a', 'db', { status: { conditions: [{ type: 'ContinuousArchiving', status: 'False', message: 'exit status 1' }] } })
|
|
170
|
-
const silent = cluster('pg-b', 'db')
|
|
253
|
+
const failing = cluster('pg-a', 'db', { spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } }, status: { conditions: [{ type: 'ContinuousArchiving', status: 'False', message: 'exit status 1' }] } })
|
|
254
|
+
const silent = cluster('pg-b', 'db', { spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } } })
|
|
171
255
|
const fleet = buildCNPGFleet(resp({ clusters: [failing, silent] }))
|
|
172
256
|
expect(fleet.rows.find((r) => r.name === 'pg-a')!.protection.walArchiving.tone).toBe('unhealthy')
|
|
173
257
|
expect(fleet.rows.find((r) => r.name === 'pg-a')!.protection.summary.text).toBe('WAL archiving failing')
|
|
@@ -245,11 +329,45 @@ describe('buildCNPGFleet', () => {
|
|
|
245
329
|
expect(fleet.rows[0].categories.has('availability')).toBe(true)
|
|
246
330
|
})
|
|
247
331
|
|
|
332
|
+
it("names a Cluster's own Job Pod problem by what the Job is for, as the cause, never as an instance", () => {
|
|
333
|
+
const joinPod = {
|
|
334
|
+
apiVersion: 'v1',
|
|
335
|
+
kind: 'Pod',
|
|
336
|
+
metadata: { name: 'pg-a-2-join-x1', namespace: 'db', labels: { 'cnpg.io/cluster': 'pg-a', 'cnpg.io/jobRole': 'join', 'cnpg.io/instanceName': 'pg-a-2' } },
|
|
337
|
+
status: { phase: 'Pending' },
|
|
338
|
+
}
|
|
339
|
+
const base = resp({ clusters: [cluster('pg-a', 'db', { status: { readyInstances: 1 } })], pods: [pod('pg-a-1', 'db', 'pg-a', 'primary')] }, {
|
|
340
|
+
issues: [
|
|
341
|
+
{ id: 'c1', severity: 'warning', category: 'operator_condition_failed', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg-a', reason: 'Ready: ClusterIsNotReady', message: 'Cluster Is Not Ready' },
|
|
342
|
+
{ id: 'j1', severity: 'critical', category: 'unschedulable', kind: 'Pod', namespace: 'db', name: 'pg-a-2-join-x1', reason: 'Unschedulable', message: '2 node(s) insufficient pods (0/2 nodes available)' },
|
|
343
|
+
],
|
|
344
|
+
})
|
|
345
|
+
const row = buildCNPGFleet({ ...base, jobPods: [joinPod] }).rows[0]
|
|
346
|
+
expect(row.problems[0].title).toBe("New standby pg-a-2: Can't be scheduled")
|
|
347
|
+
expect(row.problems[0].severity).toBe('warning')
|
|
348
|
+
expect(row.problems[0].detail).toBe('both nodes have reached their Pod limit')
|
|
349
|
+
expect(row.problems[0].rawDetail).toBe('2 node(s) insufficient pods (0/2 nodes available)')
|
|
350
|
+
expect(row.problems[0].origin?.label).toBe('Kubernetes scheduler')
|
|
351
|
+
expect(row.pods.map((p) => p.name)).toEqual(['pg-a-1'])
|
|
352
|
+
expect(buildCNPGFleet(base).rows[0].problems.some((p) => p.subject.name === 'pg-a-2-join-x1')).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
248
355
|
it('reads partial coverage by allowed namespaces and treats unnamed partial coverage as unknown', () => {
|
|
249
356
|
const named = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial', allowedNamespaces: ['db'] } } }))
|
|
250
|
-
expect(named.rows[0].protection.lastSuccessfulBackup.text).toBe('
|
|
357
|
+
expect(named.rows[0].protection.lastSuccessfulBackup.text).toBe('No successful backup yet')
|
|
251
358
|
const unnamed = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial' } } }))
|
|
252
|
-
expect(unnamed.rows[0].protection.lastSuccessfulBackup.text).toBe('
|
|
359
|
+
expect(unnamed.rows[0].protection.lastSuccessfulBackup.text).toBe('Backups not read in db')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('names the cause of a partial read only when the server names the namespace', () => {
|
|
363
|
+
const denied = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { backups: { state: 'partial', allowedNamespaces: ['other'], deniedNamespaces: ['db'] } } }))
|
|
364
|
+
expect(denied.rows[0].protection.lastSuccessfulBackup.text).toBe('No access to Backups')
|
|
365
|
+
const uncached = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'partial', allowedNamespaces: ['other'], uncachedNamespaces: ['db'] } } }))
|
|
366
|
+
expect(uncached.rows[0].replication.text).toBe('Radar does not cache Pods in db')
|
|
367
|
+
const none = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'uncached' } } }))
|
|
368
|
+
expect(none.rows[0].replication.text).toBe('Radar does not cache Pods')
|
|
369
|
+
const mixed = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'uncached', uncachedNamespaces: ['other'], deniedNamespaces: ['db'] } } }))
|
|
370
|
+
expect(mixed.rows[0].replication.text).toBe('No access to Pods')
|
|
253
371
|
})
|
|
254
372
|
|
|
255
373
|
it('says no access instead of "no replica pods" when Pods are unreadable', () => {
|
|
@@ -282,4 +400,257 @@ describe('buildCNPGFleet', () => {
|
|
|
282
400
|
expect(p.lastSuccessfulBackup.text).toBe('Loading…')
|
|
283
401
|
expect(p.recoveryWindow.text).toBe('Loading…')
|
|
284
402
|
})
|
|
403
|
+
|
|
404
|
+
it('shows the Pods’ ready count and raises an availability problem when status claims more', () => {
|
|
405
|
+
const fleet = buildCNPGFleet(
|
|
406
|
+
resp({
|
|
407
|
+
clusters: [cluster('pg-a', 'db')],
|
|
408
|
+
pods: [pod('pg-a-1', 'db', 'pg-a', 'primary', false), pod('pg-a-2', 'db', 'pg-a', 'replica'), pod('pg-a-3', 'db', 'pg-a', 'replica', false)],
|
|
409
|
+
}, { issues: [serverProblem('CNPGInstanceReadinessMismatch', '2 of 3 instance Pods not ready, including the primary', 'Cluster', 'pg-a', 'critical')] }),
|
|
410
|
+
)
|
|
411
|
+
const row = fleet.rows[0]
|
|
412
|
+
expect(row.podReadiness).toEqual({ ready: 1, total: 3 })
|
|
413
|
+
expect(row.readinessContradicted).toBe(true)
|
|
414
|
+
expect(cnpgReadyInstances(row)).toMatchObject({ text: '1/3', tone: 'degraded' })
|
|
415
|
+
expect(cnpgReadyInstances(row).note).toContain('CNPG status reports 3 ready')
|
|
416
|
+
const problem = row.problems[0]
|
|
417
|
+
expect(problem.severity).toBe('critical')
|
|
418
|
+
expect(problem.category).toBe('availability')
|
|
419
|
+
expect(problem.title).toBe('2 of 3 instance Pods not ready, including the primary')
|
|
420
|
+
expect(row.attention).toBe(true)
|
|
421
|
+
expect(fleet.attentionCount).toBe(1)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('agrees with status when the Pods do, and never judges unreadable Pods', () => {
|
|
425
|
+
const agreeing = buildCNPGFleet(
|
|
426
|
+
resp({
|
|
427
|
+
clusters: [cluster('pg-a', 'db', { status: { readyInstances: 2 } })],
|
|
428
|
+
pods: [pod('pg-a-1', 'db', 'pg-a', 'primary'), pod('pg-a-2', 'db', 'pg-a', 'replica'), pod('pg-a-3', 'db', 'pg-a', 'replica', false)],
|
|
429
|
+
}),
|
|
430
|
+
).rows[0]
|
|
431
|
+
expect(agreeing.readinessContradicted).toBeUndefined()
|
|
432
|
+
expect(cnpgReadyInstances(agreeing)).toEqual({ text: '2/3' })
|
|
433
|
+
const denied = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')] }, { coverage: { pods: { state: 'denied' } } })).rows[0]
|
|
434
|
+
expect(denied.podReadiness).toBeUndefined()
|
|
435
|
+
expect(denied.problems).toEqual([])
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('separates missing operator readiness from an observed Pod count', () => {
|
|
439
|
+
const instances = { ready: null, desired: 1 }
|
|
440
|
+
expect(cnpgReadyInstances({ instances, podReadiness: { ready: 0, total: 0 } })).toEqual({
|
|
441
|
+
text: 'Not reported by the operator', podText: '0 of 1 instance Pods ready',
|
|
442
|
+
})
|
|
443
|
+
expect(cnpgReadyInstances({ instances })).toEqual({ text: 'Not reported by the operator', podText: undefined })
|
|
444
|
+
expect(cnpgReadyInstances({ instances: { ready: null, desired: null }, podReadiness: { ready: 1, total: 1 } }).podText).toBe('1 instance Pod ready')
|
|
445
|
+
expect(cnpgReadyInstances({ instances: { ready: 0, desired: 1 } })).toEqual({ text: '0/1' })
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('names both primaries when status and the role label disagree', () => {
|
|
449
|
+
const row = buildCNPGFleet(
|
|
450
|
+
resp({
|
|
451
|
+
clusters: [cluster('pg-a', 'db', { status: { currentPrimary: 'pg-a-2', readyInstances: 1 } })],
|
|
452
|
+
pods: [pod('pg-a-1', 'db', 'pg-a', 'primary'), pod('pg-a-2', 'db', 'pg-a', 'replica', false)],
|
|
453
|
+
}, { issues: [serverProblem('CNPGPrimaryLabelMismatch', 'CNPG status names pg-a-2 primary; the Pod labelled primary is pg-a-1')] }),
|
|
454
|
+
).rows[0]
|
|
455
|
+
expect(row.primaryConflict).toEqual({ status: 'pg-a-2', labelled: 'pg-a-1' })
|
|
456
|
+
expect(row.problems.map((p) => p.title)).toContain('CNPG status names pg-a-2 primary; the Pod labelled primary is pg-a-1')
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('schedule fact', () => {
|
|
461
|
+
it('reads the schedule in words when the server supplied a reading, with the cron as its source', () => {
|
|
462
|
+
const sched = { metadata: { namespace: 'db', name: 'nightly' }, spec: { cluster: { name: 'pg-a' }, schedule: '0 0 2 * * *' } }
|
|
463
|
+
const withReading = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')], scheduledBackups: [sched] }, { scheduleReadings: { 'db/nightly': 'every day at 02:00 UTC' } }))
|
|
464
|
+
expect(withReading.rows[0].protection.schedule).toMatchObject({ text: 'Enabled · every day at 02:00 UTC · blocked: no backup destination', source: 'ScheduledBackup nightly · cron 0 0 2 * * *' })
|
|
465
|
+
const without = buildCNPGFleet(resp({ clusters: [cluster('pg-a', 'db')], scheduledBackups: [sched] }))
|
|
466
|
+
expect(without.rows[0].protection.schedule.text).toBe('Enabled · 0 0 2 * * * · blocked: no backup destination')
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
describe('CNPG certainty', () => {
|
|
471
|
+
const archiving = cluster('pg', 'db', { spec: { backup: { barmanObjectStore: { destinationPath: 's3://backups' } } }, status: { conditions: [{ type: 'ContinuousArchiving', status: 'True' }] } })
|
|
472
|
+
it('degrades an archiving cluster with no completed Backup and names the read', () => {
|
|
473
|
+
const p = buildCNPGFleet(resp({ clusters: [archiving] })).rows[0].protection
|
|
474
|
+
expect(p.lastSuccessfulBackup).toMatchObject({ tone: 'degraded', text: 'No successful backup yet', source: 'Backups read in this namespace; none completed' })
|
|
475
|
+
})
|
|
476
|
+
it('keeps unread Backups unknown', () => {
|
|
477
|
+
const p = buildCNPGFleet(resp({ clusters: [archiving] }, { coverage: { backups: { state: 'denied' } } })).rows[0].protection
|
|
478
|
+
expect(p.lastSuccessfulBackup).toMatchObject({ tone: 'unknown', source: 'Backups not read' })
|
|
479
|
+
})
|
|
480
|
+
const declaration = (applied: boolean, observed = 2) => ({ apiVersion: G, kind: 'Database', metadata: { name: 'app', namespace: 'db', generation: 2 }, spec: { cluster: { name: 'pg' } }, status: { applied, observedGeneration: observed } })
|
|
481
|
+
it('shows a qualified lower bound inline when a declaration kind was not read', () => {
|
|
482
|
+
const d = buildCNPGFleet(resp({ clusters: [archiving], databases: [declaration(true)] }, { coverage: { publications: { state: 'denied' } } })).rows[0].declarations
|
|
483
|
+
expect(d.summary).toEqual({ text: '≥1 reconciled; Publications not read', tone: 'unknown' })
|
|
484
|
+
})
|
|
485
|
+
it('does not use an exact denominator when some declarations were not read', () => {
|
|
486
|
+
const d = buildCNPGFleet(resp({ clusters: [archiving], databases: [declaration(false)] }, { coverage: { subscriptions: { state: 'denied' } } })).rows[0].declarations
|
|
487
|
+
expect(d.summary.text).toBe('≥1 not reconciled; Subscriptions not read')
|
|
488
|
+
expect(d.summary.tone).toBe('degraded')
|
|
489
|
+
})
|
|
490
|
+
it('counts stale success and failure as pending in the fleet', () => {
|
|
491
|
+
for (const applied of [true, false]) {
|
|
492
|
+
const d = buildCNPGFleet(resp({ clusters: [archiving], databases: [declaration(applied, 1)] })).rows[0].declarations
|
|
493
|
+
expect(d).toMatchObject({ total: 1, pending: 1, failed: 0, summary: { text: '1 of 1 pending', tone: 'unknown' } })
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('uses plain WAL evidence without turning an operator success into an archive', () => {
|
|
499
|
+
const condition = { type: 'ContinuousArchiving', status: 'True', message: 'Continuous archiving is working', lastTransitionTime: '2026-10-01T12:00:00Z' }
|
|
500
|
+
const c = cluster('payments', 'db', { status: { conditions: [condition] } })
|
|
501
|
+
const p = buildCNPGFleet(resp({ clusters: [c] })).rows[0].protection
|
|
502
|
+
expect(p.walArchiving).toMatchObject({ text: 'Not archived: no destination configured', operatorCondition: condition })
|
|
503
|
+
expect(p.walArchiving.detail).toBe("WAL is not archived to recovery storage, so point-in-time recovery is unavailable. CloudNativePG still reports archiving as working because, with no destination, it accepts each WAL file without keeping it.")
|
|
504
|
+
expect(p.recoveryWindow.text).toBe('None: no backup destination')
|
|
505
|
+
c.spec.backup = { barmanObjectStore: { destinationPath: 's3://backups' } }
|
|
506
|
+
const configured = buildCNPGFleet(resp({ clusters: [c] })).rows[0].protection.walArchiving
|
|
507
|
+
expect(configured).toMatchObject({ tone: 'healthy', source: 'Cluster status · ContinuousArchiving=True' })
|
|
508
|
+
expect(configured.text).toBe('CNPG reports archiving')
|
|
509
|
+
})
|
|
510
|
+
it('uses the schedule method blocker in plain recovery summaries, including a mismatched destination', () => {
|
|
511
|
+
const c = cluster('payments', 'db')
|
|
512
|
+
const schedule = { metadata: { name: 'nightly', namespace: 'db' }, spec: { method: 'barmanObjectStore', cluster: { name: 'payments' }, schedule: '0 0 2 * * *' } }
|
|
513
|
+
const data = resp({ clusters: [c], scheduledBackups: [schedule] }, { scheduleReadings: { 'db/nightly': 'every day at 02:00 UTC' } })
|
|
514
|
+
const fact = () => buildCNPGFleet(data).rows[0].protection.schedule
|
|
515
|
+
expect(fact()).toMatchObject({ text: 'Enabled · every day at 02:00 UTC · blocked: no backup destination', tone: 'degraded' })
|
|
516
|
+
c.spec.backup = { volumeSnapshot: {} }
|
|
517
|
+
expect(fact()).toMatchObject({ text: 'Enabled · every day at 02:00 UTC · blocked: no barmanObjectStore destination', tone: 'degraded' })
|
|
518
|
+
schedule.spec.method = 'volumeSnapshot'
|
|
519
|
+
expect(fact()).toMatchObject({ text: 'Enabled · not run yet', tone: 'neutral' })
|
|
520
|
+
delete data.scheduleReadings
|
|
521
|
+
expect(fact().text).toBe('Enabled · not run yet')
|
|
522
|
+
expect(fact().source).toBe('ScheduledBackup nightly · cron 0 0 2 * * *')
|
|
523
|
+
delete (schedule.spec as any).schedule
|
|
524
|
+
expect(fact().text).toBe('Enabled · not run yet')
|
|
525
|
+
data.objects.backups = [{ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Backup', metadata: { namespace: 'db', name: 'run', labels: { 'cnpg.io/scheduled-backup': 'nightly' } }, spec: { cluster: { name: 'payments' } }, status: { phase: 'completed' } }]
|
|
526
|
+
expect(fact().text).toBe('Enabled')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('keeps the plain scheduler cause and the complete scheduler message as separate evidence', () => {
|
|
530
|
+
const pending = { apiVersion: 'v1', kind: 'Pod', metadata: { name: 'orders-2-join-x', namespace: 'db', labels: { 'cnpg.io/cluster': 'orders', 'cnpg.io/jobRole': 'join', 'cnpg.io/instanceName': 'orders-2' } } }
|
|
531
|
+
const raw = '0/2 nodes are available: 2 Too many pods. preemption: no victims.'
|
|
532
|
+
const data = { ...resp({ clusters: [cluster('orders', 'db')] }, { issues: [{ id: 'j', severity: 'critical' as const, category: 'unschedulable', kind: 'Pod', namespace: 'db', name: 'orders-2-join-x', reason: 'Unschedulable', message: raw }] }), jobPods: [pending] }
|
|
533
|
+
const problem = buildCNPGFleet(data).rows[0].problems.find((p) => p.subject.name === 'orders-2-join-x')!
|
|
534
|
+
expect(problem.instance).toBe('orders-2')
|
|
535
|
+
expect(problem.detail).toBe('both nodes have reached their Pod limit')
|
|
536
|
+
expect(problem.rawDetail).toBe(raw)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it.each(['False', 'Unknown', undefined])('does not invent operator archiving success for %s', (status) => {
|
|
540
|
+
const c = cluster('analytics', 'db', { status: { conditions: status ? [{ type: 'ContinuousArchiving', status }] : [] } })
|
|
541
|
+
expect(buildCNPGFleet(resp({ clusters: [c] })).rows[0].protection.walArchiving.detail).toBe('WAL is not archived to recovery storage, so point-in-time recovery is unavailable.')
|
|
542
|
+
})
|
|
543
|
+
it('counts zero ready instance Pods only when the Pod inventory was read', () => {
|
|
544
|
+
const c = cluster('analytics', 'db', { spec: { instances: 1 }, status: { readyInstances: undefined } })
|
|
545
|
+
expect(buildCNPGFleet(resp({ clusters: [c], pods: [] })).rows[0].podReadiness).toEqual({ ready: 0, total: 0 })
|
|
546
|
+
expect(buildCNPGFleet(resp({ clusters: [c] }, { coverage: { pods: { state: 'denied' } } })).rows[0].podReadiness).toBeUndefined()
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('counts an enabled destination-blocked schedule as a Cluster protection problem', () => {
|
|
550
|
+
const c = cluster('payments', 'db', { spec: { instances: 1 }, status: { readyInstances: 1, phase: 'Cluster in healthy state' } })
|
|
551
|
+
const schedule = { apiVersion: G, kind: 'ScheduledBackup', metadata: { name: 'payments-nightly', namespace: 'db' }, spec: { cluster: { name: 'payments' } } }
|
|
552
|
+
const fleet = buildCNPGFleet(resp({ clusters: [c], scheduledBackups: [schedule] }, { issues: [serverProblem('CNPGScheduleDestinationMissing', 'Backup schedule payments-nightly cannot run: no backup destination', 'ScheduledBackup', 'payments-nightly')] }))
|
|
553
|
+
const row = fleet.rows[0]
|
|
554
|
+
expect(row.problems).toContainEqual(expect.objectContaining({ title: 'Backup schedule payments-nightly cannot run: no backup destination', severity: 'warning', category: 'protection', subject: expect.objectContaining({ kind: 'ScheduledBackup', name: 'payments-nightly' }) }))
|
|
555
|
+
expect(row.attention).toBe(true)
|
|
556
|
+
expect(fleet.attentionCount).toBe(1)
|
|
557
|
+
expect(fleet.categoryCounts.protection).toBe(1)
|
|
558
|
+
expect(row.categories.has('protection')).toBe(true)
|
|
559
|
+
expect(cnpgDimensions({ row }).find((d) => d.id === 'protection')?.tone).toBe('degraded')
|
|
560
|
+
for (const schedules of [[], [{ ...schedule, spec: { ...schedule.spec, suspend: true } }]]) {
|
|
561
|
+
expect(buildCNPGFleet(resp({ clusters: [c], scheduledBackups: schedules })).rows[0].attention).toBe(false)
|
|
562
|
+
}
|
|
563
|
+
expect(buildCNPGFleet(resp({ clusters: [c], scheduledBackups: [schedule] }, { coverage: { scheduledBackups: { state: 'denied' } } })).attentionCount).toBe(0)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('marks a schedule-method mismatch even when the Cluster has another working destination', () => {
|
|
567
|
+
const c = cluster('payments', 'db', { spec: { instances: 1, plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'store' } }] }, status: { readyInstances: 1, conditions: [{ type: 'ContinuousArchiving', status: 'True' }] } })
|
|
568
|
+
const schedule = { metadata: { name: 'payments-nightly', namespace: 'db' }, spec: { cluster: { name: 'payments' }, method: 'barmanObjectStore' } }
|
|
569
|
+
const row = buildCNPGFleet(resp({ clusters: [c], scheduledBackups: [schedule] }, { issues: [serverProblem('CNPGScheduleDestinationMissing', 'Backup schedule payments-nightly cannot run: no barmanObjectStore destination', 'ScheduledBackup', 'payments-nightly')] })).rows[0]
|
|
570
|
+
expect(row.attention).toBe(true)
|
|
571
|
+
expect(cnpgDimensions({ row }).find((d) => d.id === 'protection')).toMatchObject({ tone: 'degraded', text: 'Backup schedule payments-nightly cannot run: no barmanObjectStore destination' })
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
it('uses server findings without classifying cached-object problems again', () => {
|
|
576
|
+
const c = cluster('pg-a', 'db', { status: { currentPrimary: 'pg-a-2' } })
|
|
577
|
+
const schedule = { metadata: { name: 'nightly', namespace: 'db' }, spec: { cluster: { name: 'pg-a' } } }
|
|
578
|
+
const row = buildCNPGFleet(resp({ clusters: [c], pods: [pod('pg-a-1', 'db', 'pg-a', 'primary', false)], scheduledBackups: [schedule] })).rows[0]
|
|
579
|
+
expect(row.readinessContradicted).toBe(true)
|
|
580
|
+
expect(row.primaryConflict).toEqual({ status: 'pg-a-2', labelled: 'pg-a-1' })
|
|
581
|
+
expect(row.problems).toEqual([])
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
it('excludes predecessor backup success, failures and restores from a recreated Cluster', () => {
|
|
586
|
+
const c = cluster('pg-a', 'db', { metadata: { uid: 'current', creationTimestamp: '2026-10-01T00:00:00Z' }, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'store' } }] } })
|
|
587
|
+
const old = { apiVersion: G, kind: 'Backup', metadata: { name: 'old', namespace: 'db', creationTimestamp: '2026-10-01T01:00:00Z' }, spec: { cluster: { name: 'pg-a' } }, status: { phase: 'completed', startedAt: '2026-10-01T01:00:00Z', stoppedAt: '2026-10-01T01:01:00Z', pluginMetadata: { clusterUID: 'previous' } } }
|
|
588
|
+
const failed = { ...old, metadata: { ...old.metadata, name: 'old-failed' }, status: { ...old.status, phase: 'failed' } }
|
|
589
|
+
const restored = cluster('restored', 'db', { spec: { bootstrap: { recovery: { backup: { name: 'old' } } } } })
|
|
590
|
+
const store = { apiVersion: 'barmancloud.cnpg.io/v1', kind: 'ObjectStore', metadata: { name: 'store', namespace: 'db' }, status: { serverRecoveryWindow: { 'pg-a': { lastSuccessfulBackupTime: '2026-09-30T23:00:00Z' } } } }
|
|
591
|
+
const data = resp({ clusters: [c, restored], backups: [old, failed], objectStores: [store] }, { issues: [serverProblem('CNPGBackupFailed', 'Previous backup failed', 'Backup', 'old-failed')] })
|
|
592
|
+
const row = buildCNPGFleet(data).rows.find((r) => r.name === 'pg-a')!
|
|
593
|
+
expect(row.protection.lastSuccessfulBackup.text).toBe('No successful backup yet')
|
|
594
|
+
expect(row.protection.restoreValidation.text).toBe('None recorded')
|
|
595
|
+
expect(row.problems.some((p) => p.subject.name === 'old-failed')).toBe(false)
|
|
596
|
+
const current = { ...old, status: { ...old.status, pluginMetadata: { clusterUID: 'current' } } }
|
|
597
|
+
expect(buildCNPGFleet({ ...data, objects: { ...data.objects, backups: [current, failed] } }).rows.find((r) => r.name === 'pg-a')!.protection.lastSuccessfulBackup).toMatchObject({ text: 'Completed', source: 'Backup old' })
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('does not attribute predecessor plugin restores or notes to a recreated source', () => {
|
|
601
|
+
const source = cluster('pg-a', 'db', { metadata: { uid: 'current', creationTimestamp: '2026-10-01T00:00:00Z' }, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store', serverName: 'archive' } }] } })
|
|
602
|
+
const restored = cluster('restored', 'db', {
|
|
603
|
+
metadata: { uid: 'restored', creationTimestamp: '2026-10-02T00:00:00Z' },
|
|
604
|
+
spec: { bootstrap: { recovery: { source: 'origin' } }, externalClusters: [{ name: 'origin', plugin: { name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store', serverName: 'archive' } } }] },
|
|
605
|
+
})
|
|
606
|
+
const fact = (target: any, backups: any[] = []) => buildCNPGFleet(resp({ clusters: [source, target], backups })).rows.find((r) => r.name === 'pg-a')!.protection.restoreValidation
|
|
607
|
+
const beforeSource = { ...restored, metadata: { ...restored.metadata, creationTimestamp: '2026-09-30T00:00:00Z' } }
|
|
608
|
+
expect(cnpgRecoveryMatchesCluster(beforeSource, source, [])).toBe(false)
|
|
609
|
+
expect(fact(beforeSource).text).toBe('None recorded')
|
|
610
|
+
const pinned = { ...restored, spec: { ...restored.spec, bootstrap: { recovery: { source: 'origin', recoveryTarget: { backupID: 'old-id' } } } } }
|
|
611
|
+
const oldBackup = { apiVersion: G, kind: 'Backup', metadata: { name: 'old', namespace: 'db' }, spec: { cluster: { name: 'pg-a' } }, status: { backupId: 'old-id', pluginMetadata: { clusterUID: 'previous' } } }
|
|
612
|
+
expect(cnpgRecoveryMatchesCluster(pinned, source, [oldBackup])).toBe(false)
|
|
613
|
+
expect(fact(pinned, [oldBackup]).text).toBe('None recorded')
|
|
614
|
+
const pitr = { ...restored, spec: { ...restored.spec, bootstrap: { recovery: { source: 'origin', recoveryTarget: { targetTime: '2026-09-30T23:00:00Z' } } } } }
|
|
615
|
+
expect(fact(pitr).text).toBe('None recorded')
|
|
616
|
+
const withNote = (sourceUID?: string, targetUID = 'restored', version = 1) => ({ ...restored, metadata: { ...restored.metadata, annotations: { 'radar.skyhook.io/restore-validation': JSON.stringify({ version, recordedAt: '2026-10-02T02:00:00Z', checked: 'application data', source: { namespace: 'db', name: 'pg-a', uid: sourceUID, verified: !!sourceUID }, target: { namespace: 'db', name: 'restored', uid: targetUID, verified: true } }) } } })
|
|
617
|
+
expect(fact(withNote('previous')).text).toBe('None recorded')
|
|
618
|
+
expect(fact(withNote()).text).toBe('Archive restored into restored')
|
|
619
|
+
expect(fact(withNote('current')).text).toBe('Validation recorded')
|
|
620
|
+
expect(getCNPGRestoreValidation(withNote('current', 'restored', 2))).toBeNull()
|
|
621
|
+
expect(fact(withNote('current', 'restored', 2)).text).toBe('Archive restored into restored')
|
|
622
|
+
const copied = withNote('current', 'previous-target')
|
|
623
|
+
expect(getCNPGRestoreValidation(copied)).toBeNull()
|
|
624
|
+
expect(fact(copied).text).toBe('Archive restored into restored')
|
|
625
|
+
expect(fact(restored).source).toContain('same archive currently configured')
|
|
626
|
+
expect(fact({ ...restored, status: { readyInstances: 0 } }).text).toBe('Archive recovery declared in restored')
|
|
627
|
+
const currentBackup = { ...oldBackup, status: { ...oldBackup.status, pluginMetadata: { clusterUID: 'current' } } }
|
|
628
|
+
expect(cnpgRecoveryMatchesCluster(pinned, source, [currentBackup])).toBe(true)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('matches in-tree recovery by the actual archive and endpoint rather than server name alone', () => {
|
|
632
|
+
const archive = { destinationPath: 's3://bucket/prefix/', endpointURL: 'https://s3.example/', serverName: 'pg-a' }
|
|
633
|
+
const source = cluster('pg-a', 'db', { spec: { backup: { barmanObjectStore: archive } } })
|
|
634
|
+
const restored = cluster('restored', 'db', { spec: { bootstrap: { recovery: { source: 'origin' } }, externalClusters: [{ name: 'origin', barmanObjectStore: { ...archive, destinationPath: 's3://bucket/prefix' } }] } })
|
|
635
|
+
expect(cnpgRecoveryMatchesCluster(restored, source, [])).toBe(true)
|
|
636
|
+
const other = { ...source, spec: { backup: { barmanObjectStore: { ...archive, endpointURL: 'https://other.example' } } } }
|
|
637
|
+
expect(cnpgRecoveryMatchesCluster(restored, other, [])).toBe(false)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
it('keeps newly unreported kind coverage unknown in the shipped workspace envelope', () => {
|
|
642
|
+
const data = resp({ clusters: [cluster('pg-a', 'db')] })
|
|
643
|
+
delete data.coverage.databaseRoles
|
|
644
|
+
const fleet = buildCNPGFleet(data)
|
|
645
|
+
expect(fleet.rows[0].name).toBe('pg-a')
|
|
646
|
+
expect(fleet.rows[0].cluster.status.phase).toBe('Cluster in healthy state')
|
|
647
|
+
expect(fleet.incompleteKinds).toContain('databaseRoles')
|
|
648
|
+
data.coverage.databaseRoles = { state: 'notInstalled' }
|
|
649
|
+
expect(buildCNPGFleet(data).incompleteKinds).not.toContain('databaseRoles')
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('a single-instance replica cluster does not read its replication as calm', () => {
|
|
653
|
+
const solo = (replica?: any) => buildCNPGFleet(resp({ clusters: [cluster('pg', 'db', { spec: { instances: 1, ...(replica ? { replica } : {}) } })] })).rows[0].replication
|
|
654
|
+
expect(solo()).toMatchObject({ text: 'Single instance', tone: 'neutral' })
|
|
655
|
+
expect(solo({ enabled: true, source: 'origin' })).toMatchObject({ text: 'Single instance · upstream feed not assessed', tone: 'unknown' })
|
|
285
656
|
})
|