@skyhook-io/k8s-ui 1.13.4 → 1.14.0
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/resources/ResourcesView.tsx +2 -2
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
- package/src/components/resources/resource-utils-kueue.ts +4 -2
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/ui/SelectMenu.tsx +10 -1
|
@@ -176,6 +176,53 @@ describe('what a storage location holds', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Velero writes why a location is unavailable into `status.message`, and the
|
|
181
|
+
* BSL object carries no conditions, so that field is the only place the reason
|
|
182
|
+
* exists. A page opened from an unavailable-location problem has to show it -
|
|
183
|
+
* the same way the sibling BackupRepository renderer leads with its own reason.
|
|
184
|
+
*/
|
|
185
|
+
describe('an unavailable location Velero gave a reason for', () => {
|
|
186
|
+
const bslMsg = (phase: string, message?: string) => ({
|
|
187
|
+
metadata: { name: 'dr-replica', namespace: 'velero' },
|
|
188
|
+
spec: { provider: 'aws', objectStorage: { bucket: 'dr' } },
|
|
189
|
+
status: { phase, ...(message !== undefined ? { message } : {}) },
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('leads with the reason Velero gave', () => {
|
|
193
|
+
const html = renderToString(
|
|
194
|
+
<VeleroBSLRenderer
|
|
195
|
+
data={bslMsg('Unavailable', 'BackupStorageLocation is invalid: rpc error: code = Unknown desc = NoSuchBucket')}
|
|
196
|
+
/>,
|
|
197
|
+
)
|
|
198
|
+
expect(html).toContain('NoSuchBucket')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Velero can set Unavailable with no message. Saying nothing would leave a red
|
|
202
|
+
// badge and no consequence, so the page states what unavailable costs.
|
|
203
|
+
it('names the consequence when Velero gave no reason', () => {
|
|
204
|
+
const html = renderToString(<VeleroBSLRenderer data={bslMsg('Unavailable')} />)
|
|
205
|
+
expect(html).toContain('cannot be written')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('says nothing alarming when the location is available', () => {
|
|
209
|
+
const html = renderToString(
|
|
210
|
+
<VeleroBSLRenderer data={bslMsg('Available', 'stale message left on the object')} />,
|
|
211
|
+
)
|
|
212
|
+
expect(html).not.toContain('Storage Location Unavailable')
|
|
213
|
+
expect(html).not.toContain('stale message left on the object')
|
|
214
|
+
expect(html).not.toContain('cannot be written')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// A location Velero has not validated yet has no phase. It is not unavailable,
|
|
218
|
+
// so it gets no alarm even if a message lingers from a prior state.
|
|
219
|
+
it('raises no alarm on a location Velero has not reported on', () => {
|
|
220
|
+
const html = renderToString(<VeleroBSLRenderer data={bslMsg('', 'old message')} />)
|
|
221
|
+
expect(html).not.toContain('Storage Location Unavailable')
|
|
222
|
+
expect(html).not.toContain('old message')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
179
226
|
/**
|
|
180
227
|
* The list counts what is stored; the restorable point is derived from what
|
|
181
228
|
* completed. On a healthy location those are the only two numbers on the page,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HardDrive, Clock, Archive } from 'lucide-react'
|
|
2
|
-
import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup } from '../../ui/drawer-components'
|
|
2
|
+
import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup, AlertBanner } from '../../ui/drawer-components'
|
|
3
3
|
import { TimeValue } from '../../ui/ScheduleValue'
|
|
4
4
|
import {
|
|
5
5
|
getBSLStatus,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getBSLAccessMode,
|
|
12
12
|
getBSLLastValidation,
|
|
13
13
|
getBSLLastSynced,
|
|
14
|
+
getBSLMessage,
|
|
14
15
|
} from '../resource-utils-velero'
|
|
15
16
|
import { VeleroPhaseValue } from './velero-cells'
|
|
16
17
|
|
|
@@ -66,6 +67,7 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
|
|
|
66
67
|
const status = data.status || {}
|
|
67
68
|
const conditions = status.conditions || []
|
|
68
69
|
const bslStatus = getBSLStatus(data)
|
|
70
|
+
const message = getBSLMessage(data)
|
|
69
71
|
const config = data.spec?.config || {}
|
|
70
72
|
// Two things falsify "restorable", and the phase sees neither. A backup that
|
|
71
73
|
// did complete is still not something to go back to once its TTL has passed —
|
|
@@ -90,6 +92,19 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
|
|
|
90
92
|
|
|
91
93
|
return (
|
|
92
94
|
<>
|
|
95
|
+
{/* The reason, not just the state. An unavailable location means Velero's
|
|
96
|
+
last validation of the bucket failed, and status.message is the only
|
|
97
|
+
place that reason exists — the object carries no conditions. */}
|
|
98
|
+
{bslStatus.level === 'unhealthy' && (
|
|
99
|
+
<AlertBanner
|
|
100
|
+
variant="error"
|
|
101
|
+
title="Storage Location Unavailable"
|
|
102
|
+
message={message
|
|
103
|
+
? message
|
|
104
|
+
: 'Velero reported this location as unavailable and gave no reason. New backups cannot be written here, and backups already stored here cannot be restored from, while it stays that way.'}
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
|
|
93
108
|
{/* Status section */}
|
|
94
109
|
<Section title="Status" icon={Clock} defaultExpanded>
|
|
95
110
|
<PropertyList>
|
|
@@ -8,6 +8,12 @@ const restore = (status: Record<string, unknown>) => ({
|
|
|
8
8
|
status,
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
+
const scopedRestore = (spec: Record<string, unknown>) => ({
|
|
12
|
+
metadata: { name: 'live-restore', namespace: 'velero' },
|
|
13
|
+
spec: { backupName: 'live-completed', ...spec },
|
|
14
|
+
status: { phase: 'Completed' },
|
|
15
|
+
})
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* The restore side carries the same claims as the backup side and its own
|
|
13
19
|
* wording for them, so pinning one does not pin the other. Both banners below
|
|
@@ -53,3 +59,85 @@ describe('what a restore claims about itself', () => {
|
|
|
53
59
|
expect(html).toContain('Backup live-completed not found')
|
|
54
60
|
})
|
|
55
61
|
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scope is where an operator reads what a restore will touch and where it lands.
|
|
65
|
+
* namespaceMapping remaps source namespaces to different targets on restore, so
|
|
66
|
+
* omitting it hides where the data actually goes; a labelSelector narrows the
|
|
67
|
+
* restore to a subset of objects.
|
|
68
|
+
*/
|
|
69
|
+
// react-dom/server inserts <!-- --> markers between adjacent text nodes, which
|
|
70
|
+
// splits a badge like `app=nginx` in the raw markup; strip them so assertions
|
|
71
|
+
// read the visible text.
|
|
72
|
+
const renderScope = (data: unknown) =>
|
|
73
|
+
renderToString(<VeleroRestoreRenderer data={data} />).replace(/<!-- -->/g, '')
|
|
74
|
+
|
|
75
|
+
describe('what a restore says it will touch', () => {
|
|
76
|
+
it('shows namespaceMapping as source to target', () => {
|
|
77
|
+
const html = renderScope(
|
|
78
|
+
scopedRestore({ namespaceMapping: { prod: 'prod-clone', staging: 'staging-clone' } }),
|
|
79
|
+
)
|
|
80
|
+
expect(html).toContain('Scope')
|
|
81
|
+
expect(html).toContain('Namespace Mapping')
|
|
82
|
+
expect(html).toContain('prod')
|
|
83
|
+
expect(html).toContain('prod-clone')
|
|
84
|
+
expect(html).toContain('staging-clone')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('shows the label selector that narrows the restore', () => {
|
|
88
|
+
const html = renderScope(
|
|
89
|
+
scopedRestore({
|
|
90
|
+
labelSelector: {
|
|
91
|
+
matchLabels: { app: 'nginx' },
|
|
92
|
+
matchExpressions: [{ key: 'tier', operator: 'In', values: ['frontend'] }],
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
expect(html).toContain('Scope')
|
|
97
|
+
expect(html).toContain('app=nginx')
|
|
98
|
+
expect(html).toContain('tier In frontend')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders a Scope section for a restore filtered only by label selector', () => {
|
|
102
|
+
const html = renderScope(scopedRestore({ labelSelector: { matchLabels: { app: 'nginx' } } }))
|
|
103
|
+
expect(html).toContain('Scope')
|
|
104
|
+
expect(html).toContain('app=nginx')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('shows orLabelSelectors joined by OR', () => {
|
|
108
|
+
const html = renderScope(
|
|
109
|
+
scopedRestore({
|
|
110
|
+
orLabelSelectors: [{ matchLabels: { app: 'nginx' } }, { matchLabels: { app: 'redis' } }],
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
expect(html).toContain('Scope')
|
|
114
|
+
expect(html).toContain('app=nginx')
|
|
115
|
+
expect(html).toContain('app=redis')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shows included and excluded namespaces alongside mapping', () => {
|
|
119
|
+
const html = renderScope(
|
|
120
|
+
scopedRestore({
|
|
121
|
+
includedNamespaces: ['prod'],
|
|
122
|
+
excludedNamespaces: ['kube-system'],
|
|
123
|
+
namespaceMapping: { prod: 'prod-clone' },
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
expect(html).toContain('Included Namespaces')
|
|
127
|
+
expect(html).toContain('Excluded Namespaces')
|
|
128
|
+
expect(html).toContain('kube-system')
|
|
129
|
+
expect(html).toContain('prod-clone')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('renders no empty Scope section for an unscoped restore', () => {
|
|
133
|
+
const html = renderScope(scopedRestore({}))
|
|
134
|
+
expect(html).not.toContain('Scope')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// An empty selector matches everything, so it is not scope. Presenting it as
|
|
138
|
+
// scope would tell an operator a restore is filtered when it is not.
|
|
139
|
+
it('treats an empty label selector as unscoped', () => {
|
|
140
|
+
const html = renderScope(scopedRestore({ labelSelector: {}, orLabelSelectors: [{}] }))
|
|
141
|
+
expect(html).not.toContain('Scope')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ArchiveRestore, Filter } from 'lucide-react'
|
|
2
|
-
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
2
|
+
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, LabelSelectorDisplay } from '../../ui/drawer-components'
|
|
3
3
|
import {
|
|
4
4
|
getRestoreStatus,
|
|
5
5
|
getRestoreBackupName,
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
getRestoreExcludedNamespaces,
|
|
8
8
|
getRestoreIncludedResources,
|
|
9
9
|
getRestoreExcludedResources,
|
|
10
|
+
getRestoreNamespaceMapping,
|
|
11
|
+
getRestoreLabelSelector,
|
|
12
|
+
getRestoreOrLabelSelectors,
|
|
13
|
+
hasLabelSelectorTerms,
|
|
10
14
|
getRestoreDuration,
|
|
11
15
|
getRestoreErrors,
|
|
12
16
|
getRestoreWarnings,
|
|
@@ -41,6 +45,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
|
|
|
41
45
|
const excludedNamespaces = getRestoreExcludedNamespaces(data)
|
|
42
46
|
const includedResources = getRestoreIncludedResources(data)
|
|
43
47
|
const excludedResources = getRestoreExcludedResources(data)
|
|
48
|
+
const namespaceMapping = getRestoreNamespaceMapping(data)
|
|
49
|
+
const rawLabelSelector = getRestoreLabelSelector(data)
|
|
50
|
+
const labelSelector = hasLabelSelectorTerms(rawLabelSelector) ? rawLabelSelector : null
|
|
51
|
+
const orLabelSelectors = getRestoreOrLabelSelectors(data).filter(hasLabelSelectorTerms)
|
|
52
|
+
const namespaceMappingEntries = Object.entries(namespaceMapping)
|
|
53
|
+
const hasScope =
|
|
54
|
+
includedNamespaces.length > 0 ||
|
|
55
|
+
excludedNamespaces.length > 0 ||
|
|
56
|
+
includedResources.length > 0 ||
|
|
57
|
+
excludedResources.length > 0 ||
|
|
58
|
+
namespaceMappingEntries.length > 0 ||
|
|
59
|
+
Boolean(labelSelector) ||
|
|
60
|
+
orLabelSelectors.length > 0
|
|
44
61
|
|
|
45
62
|
const phase = status.phase || ''
|
|
46
63
|
const validationErrors = getRestoreValidationErrors(data)
|
|
@@ -148,7 +165,7 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
|
|
|
148
165
|
)}
|
|
149
166
|
|
|
150
167
|
{/* Scope section */}
|
|
151
|
-
{
|
|
168
|
+
{hasScope && (
|
|
152
169
|
<Section title="Scope" icon={Filter} defaultExpanded>
|
|
153
170
|
<PropertyList>
|
|
154
171
|
{includedNamespaces.length > 0 && (
|
|
@@ -172,6 +189,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
|
|
|
172
189
|
</div>
|
|
173
190
|
} />
|
|
174
191
|
)}
|
|
192
|
+
{namespaceMappingEntries.length > 0 && (
|
|
193
|
+
<Property label="Namespace Mapping" value={
|
|
194
|
+
<div className="flex flex-col gap-1">
|
|
195
|
+
{namespaceMappingEntries.map(([source, target]) => (
|
|
196
|
+
<div key={source} className="flex items-center gap-1.5">
|
|
197
|
+
<span className="badge-sm bg-theme-hover text-theme-text-secondary">{source}</span>
|
|
198
|
+
<span className="text-theme-text-tertiary" aria-hidden="true">→</span>
|
|
199
|
+
<span className="badge-sm bg-theme-hover text-theme-text-secondary">{String(target)}</span>
|
|
200
|
+
</div>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
} />
|
|
204
|
+
)}
|
|
175
205
|
{includedResources.length > 0 && (
|
|
176
206
|
<Property label="Included Resources" value={
|
|
177
207
|
<div className="flex flex-wrap gap-1">
|
|
@@ -190,6 +220,18 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
|
|
|
190
220
|
</div>
|
|
191
221
|
} />
|
|
192
222
|
)}
|
|
223
|
+
{labelSelector && (
|
|
224
|
+
<Property label="Label Selector" value={<LabelSelectorDisplay selector={labelSelector} />} />
|
|
225
|
+
)}
|
|
226
|
+
{orLabelSelectors.length > 0 && (
|
|
227
|
+
<Property label="Label Selectors (match any)" value={
|
|
228
|
+
<div className="flex flex-col gap-1">
|
|
229
|
+
{orLabelSelectors.map((sel: any, i: number) => (
|
|
230
|
+
<LabelSelectorDisplay key={i} selector={sel} />
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
} />
|
|
234
|
+
)}
|
|
193
235
|
</PropertyList>
|
|
194
236
|
</Section>
|
|
195
237
|
)}
|
|
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { Tooltip } from '../../ui/Tooltip'
|
|
5
5
|
import {
|
|
6
6
|
getCNPGClusterStatus,
|
|
7
|
+
getCNPGClusterAvailability,
|
|
7
8
|
classifyCNPGClusterPhase,
|
|
8
9
|
getCNPGClusterInstances,
|
|
9
10
|
getCNPGClusterPrimary,
|
|
@@ -66,8 +67,18 @@ export function CNPGClusterCell({ resource, column }: { resource: any; column: s
|
|
|
66
67
|
</Tooltip>
|
|
67
68
|
)
|
|
68
69
|
}
|
|
70
|
+
// Colour tracks the badge: a was-up total outage (readyInstances omitted,
|
|
71
|
+
// so readyKnown is false) reads red like its "Not Ready" badge rather than
|
|
72
|
+
// muted grey, and a partial shortfall stays yellow. Without the
|
|
73
|
+
// availability check the most severe count was the least emphasised.
|
|
74
|
+
const down = getCNPGClusterAvailability(resource) === 'down'
|
|
69
75
|
return (
|
|
70
|
-
<span
|
|
76
|
+
<span
|
|
77
|
+
className={clsx(
|
|
78
|
+
'text-sm',
|
|
79
|
+
down ? 'text-red-400' : readyKnown && ready < desired ? 'text-yellow-400' : 'text-theme-text-secondary',
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
71
82
|
{instances}
|
|
72
83
|
</span>
|
|
73
84
|
)
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getPolicyReportStatus,
|
|
7
7
|
getPolicyReportSummary,
|
|
8
8
|
getKyvernoPolicyStatus,
|
|
9
|
-
|
|
9
|
+
getKyvernoEnforcement,
|
|
10
10
|
getKyvernoPolicyRuleCount,
|
|
11
11
|
} from '../resource-utils-kyverno'
|
|
12
12
|
import {
|
|
@@ -79,13 +79,17 @@ export function KyvernoPolicyCell({ resource, column }: { resource: any; column:
|
|
|
79
79
|
)
|
|
80
80
|
}
|
|
81
81
|
case 'action': {
|
|
82
|
-
const
|
|
82
|
+
const { label, blocks, discrepancy } = getKyvernoEnforcement(resource)
|
|
83
83
|
return (
|
|
84
84
|
<span className={clsx(
|
|
85
85
|
'badge',
|
|
86
|
-
|
|
86
|
+
blocks
|
|
87
|
+
? 'bg-red-500/20 text-red-400'
|
|
88
|
+
: discrepancy
|
|
89
|
+
? 'bg-orange-500/20 text-orange-400'
|
|
90
|
+
: 'bg-yellow-500/20 text-yellow-400',
|
|
87
91
|
)}>
|
|
88
|
-
{
|
|
92
|
+
{label}
|
|
89
93
|
</span>
|
|
90
94
|
)
|
|
91
95
|
}
|
|
@@ -27,6 +27,7 @@ interface GoldenCase {
|
|
|
27
27
|
name: string
|
|
28
28
|
spec: Record<string, unknown>
|
|
29
29
|
status: Record<string, unknown>
|
|
30
|
+
metadata?: Record<string, unknown>
|
|
30
31
|
badge: { text: string; level: string }
|
|
31
32
|
issues: string[]
|
|
32
33
|
worst: string
|
|
@@ -40,7 +41,7 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, tc) => {
|
|
43
|
-
const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
|
|
44
|
+
const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
|
|
44
45
|
expect({ text: badge.text, level: badge.level }).toEqual(tc.badge)
|
|
45
46
|
})
|
|
46
47
|
|
|
@@ -50,7 +51,7 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
50
51
|
it.each(cases.map((c) => [c.name, c] as const))(
|
|
51
52
|
'badge and issues do not contradict — %s',
|
|
52
53
|
(_name, tc) => {
|
|
53
|
-
const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
|
|
54
|
+
const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
|
|
54
55
|
if (badge.level === 'healthy') {
|
|
55
56
|
expect(tc.issues, 'a healthy badge must not sit next to an issue').toEqual([])
|
|
56
57
|
}
|
|
@@ -69,16 +70,23 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
69
70
|
// This is the gap that let the bug through: the matrix already pinned
|
|
70
71
|
// "readyInstances absent — unknown, not zero; must not fabricate an outage"
|
|
71
72
|
// for the badge, and the cell rendered 0/N on that very case.
|
|
73
|
+
//
|
|
74
|
+
// The cell reads '-' exactly when the ready count is genuinely unknown: no
|
|
75
|
+
// explicit readyInstances AND no currentPrimary. Once a primary has been
|
|
76
|
+
// elected, an omitted readyInstances is a real 0 and the cell says so — which
|
|
77
|
+
// is what keeps "0/3" agreeing with the "Not Ready" badge on a was-up outage,
|
|
78
|
+
// while a still-bootstrapping cluster (no primary) keeps its dash.
|
|
72
79
|
it.each(cases.map((c) => [c.name, c] as const))(
|
|
73
80
|
'instances cell does not claim an outage the badge denies — %s',
|
|
74
81
|
(_name, tc) => {
|
|
75
82
|
const cell = getCNPGClusterInstances({ spec: tc.spec, status: tc.status })
|
|
76
83
|
const ready = cell.split('/')[0]
|
|
77
|
-
const
|
|
84
|
+
const readyResolvable =
|
|
85
|
+
typeof tc.status.readyInstances === 'number' || !!tc.status.currentPrimary
|
|
78
86
|
expect(
|
|
79
87
|
ready === '-',
|
|
80
|
-
`
|
|
81
|
-
).toBe(!
|
|
88
|
+
`ready is ${readyResolvable ? 'resolvable' : 'unknown'} but the cell reads ${cell}`,
|
|
89
|
+
).toBe(!readyResolvable)
|
|
82
90
|
},
|
|
83
91
|
)
|
|
84
92
|
})
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
getCNPGClusterDisplayState,
|
|
28
28
|
getCNPGClusterInstances,
|
|
29
29
|
getCNPGPoolerInstances,
|
|
30
|
+
getCNPGClusterIsReplica,
|
|
31
|
+
getCNPGClusterReplicaSource,
|
|
32
|
+
getCNPGClusterAvailability,
|
|
33
|
+
isCNPGClusterHibernated,
|
|
34
|
+
isCNPGClusterFencedAll,
|
|
35
|
+
CNPG_DOWN_GRACE_MS,
|
|
30
36
|
} from './resource-utils-cnpg'
|
|
31
37
|
import { getCellFilterValue } from './resource-utils'
|
|
32
38
|
|
|
@@ -176,8 +182,10 @@ describe('getCNPGClusterStatus', () => {
|
|
|
176
182
|
expect(getCNPGClusterStatus(cluster({ phase: 'Failing over', readyInstances: 2 }, { instances: 3 })).level).toBe('alert')
|
|
177
183
|
})
|
|
178
184
|
|
|
179
|
-
it('treats zero ready instances as down regardless of phase', () => {
|
|
180
|
-
|
|
185
|
+
it('treats a was-up cluster with zero ready instances as down regardless of phase', () => {
|
|
186
|
+
// currentPrimary proves it was serving; the omitted-then-zero count is a
|
|
187
|
+
// regression, not a first bootstrap. Even a "healthy" phase does not rescue it.
|
|
188
|
+
const s = getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', currentPrimary: 'pg-1', readyInstances: 0 }, { instances: 3 }))
|
|
181
189
|
expect(s.level).toBe('unhealthy')
|
|
182
190
|
expect(s.text).toBe('Not Ready')
|
|
183
191
|
})
|
|
@@ -193,6 +201,68 @@ describe('getCNPGClusterStatus', () => {
|
|
|
193
201
|
})
|
|
194
202
|
})
|
|
195
203
|
|
|
204
|
+
describe('getCNPGClusterAvailability — three states a bare zero-ready count collapses', () => {
|
|
205
|
+
// CNPG omits readyInstances when it is 0, so these fixtures OMIT it — the shape
|
|
206
|
+
// the operator actually emits for a down cluster.
|
|
207
|
+
const withPrimary = (extra: any = {}, meta: any = {}) => ({
|
|
208
|
+
metadata: meta,
|
|
209
|
+
spec: { instances: 3 },
|
|
210
|
+
status: { currentPrimary: 'pg-1', ...extra },
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('is null while still bootstrapping — reported, but no primary ever elected', () => {
|
|
214
|
+
// The truly-statusless case AND the reported-but-no-primary case both stay
|
|
215
|
+
// null: nothing was serving to lose.
|
|
216
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: {} })).toBeNull()
|
|
217
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: { phase: 'Setting up primary' } })).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('is null when a real ready count exists', () => {
|
|
221
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: { currentPrimary: 'pg-1', readyInstances: 2 } })).toBeNull()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('is down for a was-up cluster with no Ready condition — escalates immediately', () => {
|
|
225
|
+
expect(getCNPGClusterAvailability(withPrimary({ phase: 'Waiting for the instances to become active' }))).toBe('down')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('debounces a fresh Ready=False, then escalates once it ages past the grace', () => {
|
|
229
|
+
const recent = new Date(Date.now() - 60 * 1000).toISOString()
|
|
230
|
+
const old = new Date(Date.now() - CNPG_DOWN_GRACE_MS - 60 * 1000).toISOString()
|
|
231
|
+
const cond = (ltt: string) => withPrimary({
|
|
232
|
+
conditions: [{ type: 'Ready', status: 'False', reason: 'ClusterIsNotReady', lastTransitionTime: ltt }],
|
|
233
|
+
})
|
|
234
|
+
expect(getCNPGClusterAvailability(cond(recent))).toBeNull()
|
|
235
|
+
expect(getCNPGClusterAvailability(cond(old))).toBe('down')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('reads hibernation and all-fencing as neutral, ahead of the down verdict', () => {
|
|
239
|
+
expect(getCNPGClusterAvailability(withPrimary({}, { annotations: { 'cnpg.io/hibernation': 'on' } }))).toBe('hibernated')
|
|
240
|
+
expect(getCNPGClusterAvailability(withPrimary({}, { annotations: { 'cnpg.io/fencedInstances': '["*"]' } }))).toBe('fenced')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('a partial fence is not all-fenced — "*" is the only all-down token', () => {
|
|
244
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': '["pg-1-2"]' } } })).toBe(false)
|
|
245
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': '["*"]' } } })).toBe(true)
|
|
246
|
+
// A malformed annotation is treated as not-fenced rather than throwing.
|
|
247
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': 'not-json' } } })).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('hibernation is an explicit "on", nothing else', () => {
|
|
251
|
+
expect(isCNPGClusterHibernated({ metadata: { annotations: { 'cnpg.io/hibernation': 'on' } } })).toBe(true)
|
|
252
|
+
expect(isCNPGClusterHibernated({ metadata: { annotations: { 'cnpg.io/hibernation': 'off' } } })).toBe(false)
|
|
253
|
+
expect(isCNPGClusterHibernated({ metadata: {} })).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('renders the badge neutral for hibernation and fencing, red only for a real outage', () => {
|
|
257
|
+
expect(getCNPGClusterStatus(withPrimary({}, { annotations: { 'cnpg.io/hibernation': 'on' } })))
|
|
258
|
+
.toMatchObject({ text: 'Hibernated', level: 'neutral' })
|
|
259
|
+
expect(getCNPGClusterStatus(withPrimary({}, { annotations: { 'cnpg.io/fencedInstances': '["*"]' } })))
|
|
260
|
+
.toMatchObject({ text: 'Fenced', level: 'neutral' })
|
|
261
|
+
expect(getCNPGClusterStatus(withPrimary({ phase: 'Switchover in progress' })))
|
|
262
|
+
.toMatchObject({ text: 'Not Ready', level: 'unhealthy' })
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
196
266
|
describe('getCNPGBackupStatus', () => {
|
|
197
267
|
it('escalates walArchivingFailing instead of rendering it neutral', () => {
|
|
198
268
|
// The shipped bug: this phase hit the default branch and rendered grey,
|
|
@@ -716,3 +786,73 @@ describe('CNPG scheduled backup lateness', () => {
|
|
|
716
786
|
expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('healthy')
|
|
717
787
|
})
|
|
718
788
|
})
|
|
789
|
+
|
|
790
|
+
describe('getCNPGClusterIsReplica — the live replica-cluster role, per CNPG semantics', () => {
|
|
791
|
+
// CNPG carries replica-cluster state in `spec.replica` (enabled/primary/self/source),
|
|
792
|
+
// and its own Cluster.IsReplica() reads: enabled wins when set; otherwise the
|
|
793
|
+
// cluster is a replica only while (self || name) != primary. Promotion mutates
|
|
794
|
+
// those fields but leaves the stanza in place, so presence alone cannot be the
|
|
795
|
+
// signal.
|
|
796
|
+
|
|
797
|
+
it('standalone replica cluster (replica mode enabled) reads as a replica', () => {
|
|
798
|
+
const resource = {
|
|
799
|
+
metadata: { name: 'pg-eu' },
|
|
800
|
+
spec: { replica: { source: 'pg-us', enabled: true } },
|
|
801
|
+
}
|
|
802
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('a promoted standalone cluster (replica.enabled turned off) is no longer a replica', () => {
|
|
806
|
+
const resource = {
|
|
807
|
+
metadata: { name: 'pg-eu' },
|
|
808
|
+
spec: { replica: { source: 'pg-us', enabled: false } },
|
|
809
|
+
}
|
|
810
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(false)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('distributed topology: a designated replica (primary names another cluster) reads as a replica', () => {
|
|
814
|
+
const resource = {
|
|
815
|
+
metadata: { name: 'pg-eu' },
|
|
816
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-us', self: 'pg-eu' } },
|
|
817
|
+
}
|
|
818
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('distributed topology: after promotion (primary now names this cluster) it is the primary, stanza still present', () => {
|
|
822
|
+
const resource = {
|
|
823
|
+
metadata: { name: 'pg-eu' },
|
|
824
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-eu', self: 'pg-eu' } },
|
|
825
|
+
}
|
|
826
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(false)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('distributed topology without self falls back to the resource name', () => {
|
|
830
|
+
const promoted = {
|
|
831
|
+
metadata: { name: 'pg-eu' },
|
|
832
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-eu' } },
|
|
833
|
+
}
|
|
834
|
+
expect(getCNPGClusterIsReplica(promoted)).toBe(false)
|
|
835
|
+
|
|
836
|
+
const stillReplica = {
|
|
837
|
+
metadata: { name: 'pg-eu' },
|
|
838
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-us' } },
|
|
839
|
+
}
|
|
840
|
+
expect(getCNPGClusterIsReplica(stillReplica)).toBe(true)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('a bootstrapping standalone replica with only a source (no enabled, no primary) is a replica', () => {
|
|
844
|
+
// The stanza's source is required; before the operator writes enabled, the
|
|
845
|
+
// cluster is still a replica — (self || name) never equals an unset primary.
|
|
846
|
+
const resource = { metadata: { name: 'pg-eu' }, spec: { replica: { source: 'pg-us' } } }
|
|
847
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('an ordinary standalone cluster with no replica stanza is not a replica', () => {
|
|
851
|
+
expect(getCNPGClusterIsReplica({ metadata: { name: 'pg' }, spec: { instances: 3 } })).toBe(false)
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('getCNPGClusterReplicaSource reads the source from the replica stanza', () => {
|
|
855
|
+
const resource = { metadata: { name: 'pg-eu' }, spec: { replica: { source: 'pg-us', enabled: true } } }
|
|
856
|
+
expect(getCNPGClusterReplicaSource(resource)).toBe('pg-us')
|
|
857
|
+
})
|
|
858
|
+
})
|