@skyhook-io/k8s-ui 1.12.0 → 1.13.3
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/package.json +4 -4
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +419 -9
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +13 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +5 -2
- package/src/types/core.ts +36 -4
- package/src/types/policy.ts +153 -2
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getCNPGObjectStoreRecoveryWindows,
|
|
4
|
+
getCNPGObjectStoreStatus,
|
|
5
|
+
getCNPGObjectStoreProvider,
|
|
6
|
+
isCNPGObjectStore,
|
|
7
|
+
} from './resource-utils-cnpg'
|
|
8
|
+
|
|
9
|
+
// Field names verified against the real CRD (plugin-barman-cloud v0.14.0,
|
|
10
|
+
// barmancloud.cnpg.io/v1) on a kind cluster — `lastSuccessfulBackupTime`, not
|
|
11
|
+
// `lastSuccessfulBackup` as the Cluster status uses.
|
|
12
|
+
const store = (status: any, spec: any = {}) => ({
|
|
13
|
+
apiVersion: 'barmancloud.cnpg.io/v1',
|
|
14
|
+
kind: 'ObjectStore',
|
|
15
|
+
metadata: { name: 'pg-store', namespace: 'pg' },
|
|
16
|
+
spec: { configuration: { destinationPath: 's3://b/pg' }, ...spec },
|
|
17
|
+
status,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('getCNPGObjectStoreRecoveryWindows', () => {
|
|
21
|
+
it('reads the per-server map and sorts by server', () => {
|
|
22
|
+
const w = getCNPGObjectStoreRecoveryWindows(
|
|
23
|
+
store({
|
|
24
|
+
serverRecoveryWindow: {
|
|
25
|
+
zeta: { firstRecoverabilityPoint: '2026-08-01T00:00:00Z', lastSuccessfulBackupTime: '2026-08-10T00:00:00Z' },
|
|
26
|
+
alpha: { firstRecoverabilityPoint: '2026-08-02T00:00:00Z', lastSuccessfulBackupTime: '2026-08-11T00:00:00Z' },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
expect(w.map((x) => x.server)).toEqual(['alpha', 'zeta'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('flags a failure newer than the last success', () => {
|
|
34
|
+
const [w] = getCNPGObjectStoreRecoveryWindows(
|
|
35
|
+
store({
|
|
36
|
+
serverRecoveryWindow: {
|
|
37
|
+
pg: {
|
|
38
|
+
lastSuccessfulBackupTime: '2026-08-05T00:00:00Z',
|
|
39
|
+
lastFailedBackupTime: '2026-08-12T00:00:00Z',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
expect(w.failingSinceLastSuccess).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not flag a failure that predates the last success', () => {
|
|
48
|
+
const [w] = getCNPGObjectStoreRecoveryWindows(
|
|
49
|
+
store({
|
|
50
|
+
serverRecoveryWindow: {
|
|
51
|
+
pg: {
|
|
52
|
+
lastFailedBackupTime: '2026-08-05T00:00:00Z',
|
|
53
|
+
lastSuccessfulBackupTime: '2026-08-12T00:00:00Z',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
expect(w.failingSinceLastSuccess).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('treats a failure with no success at all as failing', () => {
|
|
62
|
+
const [w] = getCNPGObjectStoreRecoveryWindows(
|
|
63
|
+
store({ serverRecoveryWindow: { pg: { lastFailedBackupTime: '2026-08-12T00:00:00Z' } } }),
|
|
64
|
+
)
|
|
65
|
+
expect(w.failingSinceLastSuccess).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns nothing for a store that has never reported', () => {
|
|
69
|
+
expect(getCNPGObjectStoreRecoveryWindows(store({}))).toEqual([])
|
|
70
|
+
expect(getCNPGObjectStoreRecoveryWindows(store(undefined))).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('getCNPGObjectStoreStatus', () => {
|
|
75
|
+
// The load-bearing one: an empty status means nothing is restorable, and a
|
|
76
|
+
// green badge there would assert a recovery point that does not exist.
|
|
77
|
+
it('is unknown, not healthy, when no server has reported', () => {
|
|
78
|
+
const s = getCNPGObjectStoreStatus(store({}))
|
|
79
|
+
expect(s.level).toBe('unknown')
|
|
80
|
+
expect(s.text).toBe('No backups yet')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('is unhealthy when any server is failing since its last success', () => {
|
|
84
|
+
const s = getCNPGObjectStoreStatus(
|
|
85
|
+
store({
|
|
86
|
+
serverRecoveryWindow: {
|
|
87
|
+
ok: { lastSuccessfulBackupTime: '2026-08-12T00:00:00Z' },
|
|
88
|
+
bad: { lastSuccessfulBackupTime: '2026-08-01T00:00:00Z', lastFailedBackupTime: '2026-08-12T00:00:00Z' },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
expect(s.level).toBe('unhealthy')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is healthy when every server has a clean latest backup', () => {
|
|
96
|
+
const s = getCNPGObjectStoreStatus(
|
|
97
|
+
store({ serverRecoveryWindow: { pg: { lastSuccessfulBackupTime: '2026-08-12T00:00:00Z' } } }),
|
|
98
|
+
)
|
|
99
|
+
expect(s.level).toBe('healthy')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('provider + group guards', () => {
|
|
104
|
+
it('names the provider without exposing credentials', () => {
|
|
105
|
+
expect(
|
|
106
|
+
getCNPGObjectStoreProvider(
|
|
107
|
+
store({}, { configuration: { destinationPath: 's3://b', s3Credentials: { accessKeyId: { name: 'x' } } } }),
|
|
108
|
+
),
|
|
109
|
+
).toBe('S3')
|
|
110
|
+
expect(
|
|
111
|
+
getCNPGObjectStoreProvider(store({}, { configuration: { azureCredentials: {} } })),
|
|
112
|
+
).toBe('Azure Blob Storage')
|
|
113
|
+
expect(getCNPGObjectStoreProvider(store({}, { configuration: {} }))).toBeUndefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// `objectstores` is generic enough for another operator to claim.
|
|
117
|
+
it('does not claim a foreign objectstores CR', () => {
|
|
118
|
+
expect(isCNPGObjectStore(store({}))).toBe(true)
|
|
119
|
+
expect(isCNPGObjectStore({ apiVersion: 'minio.example.io/v1', kind: 'ObjectStore' })).toBe(false)
|
|
120
|
+
expect(isCNPGObjectStore({})).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
getCNPGScheduledBackupStatus,
|
|
4
|
+
getCNPGScheduledBackupNextSchedule,
|
|
3
5
|
getCNPGClusterCertificateExpirations,
|
|
4
6
|
getCNPGClusterStatus,
|
|
5
7
|
getCNPGBackupStatus,
|
|
@@ -662,3 +664,55 @@ describe('volume health', () => {
|
|
|
662
664
|
expect(h?.healthy).toEqual(['ok'])
|
|
663
665
|
})
|
|
664
666
|
})
|
|
667
|
+
|
|
668
|
+
describe('CNPG scheduled backup lateness', () => {
|
|
669
|
+
const at = (ms: number) => new Date(Date.now() + ms).toISOString()
|
|
670
|
+
const MIN = 60 * 1000
|
|
671
|
+
|
|
672
|
+
const sb = (suspend: boolean, next?: string, lastScheduleTime?: string) => ({
|
|
673
|
+
spec: { schedule: '0 0 0 * * *', suspend },
|
|
674
|
+
status: { ...(next ? { nextScheduleTime: next } : {}), ...(lastScheduleTime ? { lastScheduleTime } : {}) },
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('reports a schedule that missed its own next-run time', () => {
|
|
678
|
+
// Ran once, then stopped. Both lastScheduleTime and nextScheduleTime are
|
|
679
|
+
// present, which is exactly the shape that used to read healthy.
|
|
680
|
+
const badge = getCNPGScheduledBackupStatus(sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN)))
|
|
681
|
+
expect(badge.level).toBe('degraded')
|
|
682
|
+
expect(badge.text).toBe('Overdue')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('stays healthy inside the operator reconcile window', () => {
|
|
686
|
+
const badge = getCNPGScheduledBackupStatus(sb(false, at(-1 * MIN), at(-24 * 60 * MIN)))
|
|
687
|
+
expect(badge.level).toBe('healthy')
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('keeps suspended as a deliberate state, not a missed backup', () => {
|
|
691
|
+
// The operator stops maintaining nextScheduleTime once suspended, so the
|
|
692
|
+
// stale value must not be read as a missed run.
|
|
693
|
+
const badge = getCNPGScheduledBackupStatus(sb(true, at(-200 * 24 * 60 * MIN)))
|
|
694
|
+
expect(badge.text).toBe('Suspended')
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('never says overdue while the badge still says healthy', () => {
|
|
698
|
+
// The two read from one test. Before this they disagreed: the badge said
|
|
699
|
+
// Active while this field said overdue on the same row.
|
|
700
|
+
const justPastDue = sb(false, at(-1 * MIN), at(-24 * 60 * MIN))
|
|
701
|
+
expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('healthy')
|
|
702
|
+
expect(getCNPGScheduledBackupNextSchedule(justPastDue)).toBe('due now')
|
|
703
|
+
|
|
704
|
+
const longPastDue = sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN))
|
|
705
|
+
expect(getCNPGScheduledBackupStatus(longPastDue).level).toBe('degraded')
|
|
706
|
+
expect(getCNPGScheduledBackupNextSchedule(longPastDue)).toContain('overdue by')
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('does not count down to a run that is not coming', () => {
|
|
710
|
+
expect(getCNPGScheduledBackupNextSchedule(sb(true, at(60 * MIN)))).toBe('suspended')
|
|
711
|
+
expect(getCNPGScheduledBackupNextSchedule(sb(true, at(-60 * MIN)))).toBe('suspended')
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it('says nothing when the operator published no next-run time', () => {
|
|
715
|
+
expect(getCNPGScheduledBackupNextSchedule(sb(false))).toBe('-')
|
|
716
|
+
expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('healthy')
|
|
717
|
+
})
|
|
718
|
+
})
|
|
@@ -252,12 +252,19 @@ export function getCNPGClusterPhase(resource: any): string {
|
|
|
252
252
|
return resource.status?.phase || '-'
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// A cluster pinned to an ImageCatalog carries no `spec.imageName` at all — the
|
|
256
|
+
// operator resolves the image from the catalog and records it in `status.image`.
|
|
257
|
+
// Reading spec alone leaves those clusters showing a dash where an image runs.
|
|
258
|
+
function cnpgImage(resource: any): string {
|
|
259
|
+
return resource.spec?.imageName || resource.status?.image || ''
|
|
260
|
+
}
|
|
261
|
+
|
|
255
262
|
export function getCNPGClusterImage(resource: any): string {
|
|
256
|
-
return resource
|
|
263
|
+
return cnpgImage(resource) || '-'
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
export function getCNPGClusterImageTag(resource: any): string {
|
|
260
|
-
const image = resource
|
|
267
|
+
const image = cnpgImage(resource)
|
|
261
268
|
if (!image) return '-'
|
|
262
269
|
const parts = image.split(':')
|
|
263
270
|
if (parts.length > 1) return parts[parts.length - 1]
|
|
@@ -293,6 +300,12 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
|
|
|
293
300
|
|
|
294
301
|
export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
|
|
295
302
|
|
|
303
|
+
/** The API group ObjectStore is served under. `objectstores` is a generic
|
|
304
|
+
* plural several operators ship, so every link to one has to be qualified —
|
|
305
|
+
* an unqualified link resolves by plural alone and can land on a foreign CRD's
|
|
306
|
+
* object of the same name. */
|
|
307
|
+
export const CNPG_BARMAN_OBJECTSTORE_GROUP = 'barmancloud.cnpg.io'
|
|
308
|
+
|
|
296
309
|
export interface CNPGBarmanPlugin {
|
|
297
310
|
name: string
|
|
298
311
|
/** ObjectStore CR (barmancloud.cnpg.io/v1) holding the real destination + recovery window. */
|
|
@@ -354,6 +367,180 @@ export function getCNPGClusterBackupConfig(resource: any): {
|
|
|
354
367
|
}
|
|
355
368
|
}
|
|
356
369
|
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// DECLARATIVE OBJECTS — Database, Publication, Subscription
|
|
372
|
+
// ============================================================================
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The three declarative kinds share one status shape: `applied` plus a
|
|
376
|
+
* `message`. The field has three meanings, not two — true, false, and absent —
|
|
377
|
+
* and absent is "the operator has not reconciled this yet", which is a
|
|
378
|
+
* different thing from failed. Collapsing them would report a freshly created
|
|
379
|
+
* object as broken.
|
|
380
|
+
*/
|
|
381
|
+
export function getCNPGDeclarativeStatus(resource: any): StatusBadge {
|
|
382
|
+
const applied = resource?.status?.applied
|
|
383
|
+
if (applied === true) return { text: 'Applied', color: healthColors.healthy, level: 'healthy' }
|
|
384
|
+
if (applied === false) return { text: 'Not Applied', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
385
|
+
return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The operator's own explanation for a failure. Never paraphrased. */
|
|
389
|
+
export function getCNPGDeclarativeMessage(resource: any): string | undefined {
|
|
390
|
+
const m = resource?.status?.message
|
|
391
|
+
return typeof m === 'string' && m !== '' ? m : undefined
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What happens in PostgreSQL when this CR is deleted.
|
|
396
|
+
*
|
|
397
|
+
* `delete` drops the real database, publication or subscription; `retain`
|
|
398
|
+
* leaves it. This is the difference between removing a manifest and losing
|
|
399
|
+
* data, so it is surfaced rather than left in the YAML. CNPG defaults to
|
|
400
|
+
* `retain` when unset.
|
|
401
|
+
*/
|
|
402
|
+
export function getCNPGReclaimPolicy(resource: any): { value: string; destructive: boolean } {
|
|
403
|
+
const spec = resource?.spec ?? {}
|
|
404
|
+
const raw =
|
|
405
|
+
spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? 'retain'
|
|
406
|
+
return { value: raw, destructive: String(raw).toLowerCase() === 'delete' }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The Cluster this object is declared against. */
|
|
410
|
+
export function getCNPGDeclarativeCluster(resource: any): string | undefined {
|
|
411
|
+
const n = resource?.spec?.cluster?.name
|
|
412
|
+
return typeof n === 'string' && n !== '' ? n : undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface CNPGImageCatalogEntry {
|
|
416
|
+
major: number
|
|
417
|
+
image: string
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Major-version pins, sorted so the list does not reshuffle between reads. */
|
|
421
|
+
export function getCNPGImageCatalogEntries(resource: any): CNPGImageCatalogEntry[] {
|
|
422
|
+
const raw = resource?.spec?.images
|
|
423
|
+
if (!Array.isArray(raw)) return []
|
|
424
|
+
return raw
|
|
425
|
+
.filter((e: any) => typeof e?.image === 'string')
|
|
426
|
+
.map((e: any) => ({ major: Number(e.major), image: e.image as string }))
|
|
427
|
+
.sort((a, b) => a.major - b.major)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// OBJECTSTORE (barmancloud.cnpg.io/v1) — the plugin-path backup destination
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
export const CNPG_BARMAN_GROUP = 'barmancloud.cnpg.io'
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The plugin that took (or will take) a Backup / ScheduledBackup, from
|
|
438
|
+
* `spec.pluginConfiguration`. Present only when `spec.method` is `plugin`; the
|
|
439
|
+
* in-tree path leaves it unset, so absence is meaningful rather than missing.
|
|
440
|
+
*/
|
|
441
|
+
export function getCNPGBackupPlugin(resource: any): { name: string; parameters?: Record<string, any> } | null {
|
|
442
|
+
if (resource?.spec?.method !== 'plugin') return null
|
|
443
|
+
const cfg = resource?.spec?.pluginConfiguration
|
|
444
|
+
if (!cfg?.name) return null
|
|
445
|
+
return { name: cfg.name, parameters: cfg.parameters }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** True for a barman-cloud ObjectStore. The `objectstores` plural is generic
|
|
449
|
+
* enough to belong to another operator, so kind alone is not enough. */
|
|
450
|
+
export function isCNPGObjectStore(resource: any): boolean {
|
|
451
|
+
return typeof resource?.apiVersion === 'string' && resource.apiVersion.startsWith(`${CNPG_BARMAN_GROUP}/`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Which cloud the credentials point at. Only the provider is reported — the
|
|
455
|
+
* values are Secret references and never belong on screen. */
|
|
456
|
+
export function getCNPGObjectStoreProvider(resource: any): string | undefined {
|
|
457
|
+
const c = resource?.spec?.configuration
|
|
458
|
+
if (!c) return undefined
|
|
459
|
+
if (c.s3Credentials) return 'S3'
|
|
460
|
+
if (c.azureCredentials) return 'Azure Blob Storage'
|
|
461
|
+
if (c.googleCredentials) return 'Google Cloud Storage'
|
|
462
|
+
return undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The Secret holding the destination's credentials, by name only.
|
|
467
|
+
*
|
|
468
|
+
* Named rather than read: the values never belong on screen, but "which Secret"
|
|
469
|
+
* is the first question when a backup fails on authentication, and a page that
|
|
470
|
+
* says only "S3" leaves nowhere to go.
|
|
471
|
+
*/
|
|
472
|
+
export function getCNPGObjectStoreCredentialSecret(resource: any): string | undefined {
|
|
473
|
+
const c = resource?.spec?.configuration
|
|
474
|
+
const ref =
|
|
475
|
+
c?.s3Credentials?.accessKeyId ??
|
|
476
|
+
c?.azureCredentials?.storageAccount ??
|
|
477
|
+
c?.azureCredentials?.connectionString ??
|
|
478
|
+
c?.googleCredentials?.applicationCredentials
|
|
479
|
+
return typeof ref?.name === 'string' && ref.name !== '' ? ref.name : undefined
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export interface CNPGObjectStoreRecoveryWindow {
|
|
483
|
+
server: string
|
|
484
|
+
firstRecoverabilityPoint?: string
|
|
485
|
+
lastSuccessfulBackupTime?: string
|
|
486
|
+
lastFailedBackupTime?: string
|
|
487
|
+
/** A failure newer than the last success — the window has stopped advancing. */
|
|
488
|
+
failingSinceLastSuccess: boolean
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The recovery window per PostgreSQL server, from
|
|
493
|
+
* `status.serverRecoveryWindow` — a map keyed by server name.
|
|
494
|
+
*
|
|
495
|
+
* This is the payload the Cluster page promises and cannot show: a
|
|
496
|
+
* plugin-backed Cluster publishes no recovery-point fields of its own, so this
|
|
497
|
+
* is the only place the restorable range exists.
|
|
498
|
+
*/
|
|
499
|
+
export function getCNPGObjectStoreRecoveryWindows(resource: any): CNPGObjectStoreRecoveryWindow[] {
|
|
500
|
+
const raw = resource?.status?.serverRecoveryWindow
|
|
501
|
+
if (!raw || typeof raw !== 'object') return []
|
|
502
|
+
return Object.entries(raw)
|
|
503
|
+
.map(([server, w]: [string, any]) => {
|
|
504
|
+
const lastSuccess = w?.lastSuccessfulBackupTime
|
|
505
|
+
const lastFailed = w?.lastFailedBackupTime
|
|
506
|
+
return {
|
|
507
|
+
server,
|
|
508
|
+
firstRecoverabilityPoint: w?.firstRecoverabilityPoint,
|
|
509
|
+
lastSuccessfulBackupTime: lastSuccess,
|
|
510
|
+
lastFailedBackupTime: lastFailed,
|
|
511
|
+
failingSinceLastSuccess:
|
|
512
|
+
!!lastFailed && (!lastSuccess || new Date(lastFailed) > new Date(lastSuccess)),
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
.sort((a, b) => a.server.localeCompare(b.server))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Health for the list badge.
|
|
520
|
+
*
|
|
521
|
+
* `unknown` rather than healthy when no server has reported: an ObjectStore
|
|
522
|
+
* with an empty status is configured but holds nothing restorable, and green
|
|
523
|
+
* there would assert a recovery point that does not exist.
|
|
524
|
+
*/
|
|
525
|
+
export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
|
|
526
|
+
const windows = getCNPGObjectStoreRecoveryWindows(resource)
|
|
527
|
+
if (windows.length === 0) {
|
|
528
|
+
return { text: 'No backups yet', color: healthColors.unknown, level: 'unknown' }
|
|
529
|
+
}
|
|
530
|
+
if (windows.some((w) => w.failingSinceLastSuccess)) {
|
|
531
|
+
return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
532
|
+
}
|
|
533
|
+
return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function getCNPGObjectStoreDestination(resource: any): string {
|
|
537
|
+
return resource?.spec?.configuration?.destinationPath || '-'
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function getCNPGObjectStoreRetention(resource: any): string | undefined {
|
|
541
|
+
return resource?.spec?.retentionPolicy
|
|
542
|
+
}
|
|
543
|
+
|
|
357
544
|
export function getCNPGClusterMonitoring(resource: any): {
|
|
358
545
|
podMonitorEnabled: boolean
|
|
359
546
|
customQueriesConfigMap?: string[]
|
|
@@ -576,6 +763,33 @@ export function getCNPGBackupTarget(resource: any): string {
|
|
|
576
763
|
// CNPG SCHEDULED BACKUP UTILITIES
|
|
577
764
|
// ============================================================================
|
|
578
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Reconcile latency between a backup coming due and the operator starting it.
|
|
768
|
+
* Not a backup policy — nothing here decides how often backups should run.
|
|
769
|
+
* Kept in step with the detector that raises the matching issue.
|
|
770
|
+
*/
|
|
771
|
+
const CNPG_SCHEDULED_BACKUP_GRACE_MS = 10 * 60 * 1000
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* How far past its own next-run time this schedule is, or null when it is not
|
|
775
|
+
* meaningfully overdue.
|
|
776
|
+
*
|
|
777
|
+
* The expectation belongs to the operator: CNPG publishes `nextScheduleTime`
|
|
778
|
+
* and updates it whenever it runs a backup. A value still in the past therefore
|
|
779
|
+
* means no backup happened. Suspended schedules are excluded because the
|
|
780
|
+
* operator stops maintaining the field, so the stale value would read as missed
|
|
781
|
+
* on every deliberately paused schedule.
|
|
782
|
+
*/
|
|
783
|
+
export function getCNPGScheduledBackupOverdueMs(resource: any): number | null {
|
|
784
|
+
if (resource?.spec?.suspend === true) return null
|
|
785
|
+
const next = resource?.status?.nextScheduleTime
|
|
786
|
+
if (!next) return null
|
|
787
|
+
const dueAt = new Date(next).getTime()
|
|
788
|
+
if (Number.isNaN(dueAt)) return null
|
|
789
|
+
const overdue = Date.now() - dueAt
|
|
790
|
+
return overdue > CNPG_SCHEDULED_BACKUP_GRACE_MS ? overdue : null
|
|
791
|
+
}
|
|
792
|
+
|
|
579
793
|
export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
|
|
580
794
|
const isSuspended = resource.spec?.suspend === true
|
|
581
795
|
|
|
@@ -583,6 +797,14 @@ export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
|
|
|
583
797
|
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
584
798
|
}
|
|
585
799
|
|
|
800
|
+
// The operator said when the next backup was due and it has not happened.
|
|
801
|
+
// Checked before lastScheduleTime: a schedule that ran once and then stopped
|
|
802
|
+
// has both, and reporting it healthy contradicted the next-run figure sitting
|
|
803
|
+
// on the same row.
|
|
804
|
+
if (getCNPGScheduledBackupOverdueMs(resource) !== null) {
|
|
805
|
+
return { text: 'Overdue', color: healthColors.degraded, level: 'degraded' }
|
|
806
|
+
}
|
|
807
|
+
|
|
586
808
|
// If we have a last schedule time, it's active
|
|
587
809
|
if (resource.status?.lastScheduleTime) {
|
|
588
810
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
@@ -615,10 +837,20 @@ export function getCNPGScheduledBackupLastSchedule(resource: any): string {
|
|
|
615
837
|
export function getCNPGScheduledBackupNextSchedule(resource: any): string {
|
|
616
838
|
const next = resource.status?.nextScheduleTime
|
|
617
839
|
if (!next) return '-'
|
|
840
|
+
// A suspended schedule keeps whatever next-run time it had when it was
|
|
841
|
+
// paused, and the operator stops maintaining it. Counting down to it, or
|
|
842
|
+
// calling it due, would both describe a run that is not coming.
|
|
843
|
+
if (resource.spec?.suspend === true) return 'suspended'
|
|
618
844
|
const nextDate = new Date(next)
|
|
619
845
|
const now = new Date()
|
|
620
846
|
const diffMs = nextDate.getTime() - now.getTime()
|
|
621
|
-
|
|
847
|
+
// Past due, but inside the window where the operator has simply not picked it
|
|
848
|
+
// up yet. Calling that "overdue" while the badge still reads healthy is the
|
|
849
|
+
// contradiction this pair is meant to avoid.
|
|
850
|
+
if (diffMs <= 0) {
|
|
851
|
+
const overdue = getCNPGScheduledBackupOverdueMs(resource)
|
|
852
|
+
return overdue === null ? 'due now' : `overdue by ${formatDuration(overdue)}`
|
|
853
|
+
}
|
|
622
854
|
return `in ${formatDuration(diffMs)}`
|
|
623
855
|
}
|
|
624
856
|
|
|
@@ -767,3 +999,32 @@ export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
|
|
|
767
999
|
health.initializing.length > 0
|
|
768
1000
|
return reportedAnything ? health : null
|
|
769
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Splits declarative objects by what the operator has actually said about them.
|
|
1005
|
+
*
|
|
1006
|
+
* Three buckets, not two. `applied: true` is live. `applied: false` is a failure
|
|
1007
|
+
* the operator can explain. ABSENT is not yet reconciled — and folding that in
|
|
1008
|
+
* with the failures tells the reader an object "exists in Kubernetes and not in
|
|
1009
|
+
* PostgreSQL" during the seconds before anything has looked at it.
|
|
1010
|
+
*/
|
|
1011
|
+
export function splitCNPGDeclarativeByApplied<T>(objects: T[]): {
|
|
1012
|
+
applied: T[]
|
|
1013
|
+
notApplied: T[]
|
|
1014
|
+
pending: T[]
|
|
1015
|
+
} {
|
|
1016
|
+
const out = { applied: [] as T[], notApplied: [] as T[], pending: [] as T[] }
|
|
1017
|
+
for (const o of objects) {
|
|
1018
|
+
switch (getCNPGDeclarativeStatus(o).level) {
|
|
1019
|
+
case 'healthy':
|
|
1020
|
+
out.applied.push(o)
|
|
1021
|
+
break
|
|
1022
|
+
case 'unhealthy':
|
|
1023
|
+
out.notApplied.push(o)
|
|
1024
|
+
break
|
|
1025
|
+
default:
|
|
1026
|
+
out.pending.push(o)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return out
|
|
1030
|
+
}
|
|
@@ -143,6 +143,30 @@ export function getKyvernoFailurePolicy(resource: any): string {
|
|
|
143
143
|
return resource?.spec?.failurePolicy || ''
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* `spec.failurePolicy` with the CRD's own default applied.
|
|
148
|
+
*
|
|
149
|
+
* An ABSENT failurePolicy means Fail — "Allowed values are Ignore or Fail.
|
|
150
|
+
* Defaults to Fail", per the published schema, and most policies never set it.
|
|
151
|
+
* Reading absent as anything else inverts the consequence of an engine error:
|
|
152
|
+
* the screen would say those admissions are "allowed through unchecked" when the
|
|
153
|
+
* cluster is in fact rejecting them. Same defaulting trap as validationActions
|
|
154
|
+
* above, in the other direction.
|
|
155
|
+
*
|
|
156
|
+
* Kept separate from the raw reader so the Evaluation section can still show
|
|
157
|
+
* only what the policy actually declares.
|
|
158
|
+
*/
|
|
159
|
+
export function getKyvernoEffectiveFailurePolicy(resource: any): string {
|
|
160
|
+
return (
|
|
161
|
+
resource?.spec?.failurePolicy ||
|
|
162
|
+
// Where the legacy family keeps it since 1.13. Reading only the modern
|
|
163
|
+
// location would default a policy that explicitly set Ignore to Fail, and
|
|
164
|
+
// announce a rejection the cluster is not making.
|
|
165
|
+
resource?.spec?.webhookConfiguration?.failurePolicy ||
|
|
166
|
+
'Fail'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
/**
|
|
147
171
|
* Computes what the policy actually does, rather than echoing a single field.
|
|
148
172
|
*
|