@skyhook-io/k8s-ui 1.10.5 → 1.11.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 +1 -1
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +6 -0
- package/src/components/trace/ReachabilityGraph.tsx +20 -6
- package/src/components/trace/ReachabilityView.tsx +253 -52
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachGraphModel.test.ts +16 -0
- package/src/components/trace/reachInspector.test.ts +233 -10
- package/src/components/trace/reachInspector.ts +166 -36
- package/src/components/trace/reachMarks.test.ts +32 -1
- package/src/components/trace/reachMarks.ts +44 -0
- package/src/components/trace/reachOrigins.ts +12 -0
- package/src/components/trace/types.ts +25 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/theme/components.css +26 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getKyvernoEnforcementPosture,
|
|
4
|
+
getKyvernoPolicyFamily,
|
|
5
|
+
getKyvernoValidationActions,
|
|
6
|
+
getKyvernoMatchSummary,
|
|
7
|
+
getModernKyvernoPolicyStatus,
|
|
8
|
+
isKyvernoAdmissionEnabled,
|
|
9
|
+
isModernKyvernoPolicy,
|
|
10
|
+
} from './resource-utils-kyverno-modern'
|
|
11
|
+
|
|
12
|
+
function vpol(spec: any = {}, extra: any = {}) {
|
|
13
|
+
return {
|
|
14
|
+
apiVersion: 'policies.kyverno.io/v1',
|
|
15
|
+
kind: 'ValidatingPolicy',
|
|
16
|
+
metadata: { name: 'p' },
|
|
17
|
+
spec,
|
|
18
|
+
...extra,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('validationActions defaulting', () => {
|
|
23
|
+
// Verified against Kyverno 1.18.2: a ValidatingPolicy with no
|
|
24
|
+
// validationActions rejected a violating request. The legacy family
|
|
25
|
+
// defaults the analogous field to Audit, so this is the assumption most
|
|
26
|
+
// likely to be carried over wrongly — and it would under-report the
|
|
27
|
+
// strictest possible state.
|
|
28
|
+
it('treats an absent validationActions as Deny', () => {
|
|
29
|
+
expect(getKyvernoValidationActions(vpol({}))).toEqual(['Deny'])
|
|
30
|
+
expect(getKyvernoEnforcementPosture(vpol({}), 'validating').blocks).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('treats an empty validationActions array as Deny', () => {
|
|
34
|
+
expect(getKyvernoValidationActions(vpol({ validationActions: [] }))).toEqual(['Deny'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reads explicit actions verbatim', () => {
|
|
38
|
+
expect(getKyvernoValidationActions(vpol({ validationActions: ['Audit', 'Warn'] }))).toEqual(['Audit', 'Warn'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('effective enforcement posture', () => {
|
|
43
|
+
it('reports Deny as blocking when admission evaluation is on', () => {
|
|
44
|
+
const posture = getKyvernoEnforcementPosture(
|
|
45
|
+
vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: true } } }),
|
|
46
|
+
'validating'
|
|
47
|
+
)
|
|
48
|
+
expect(posture.blocks).toBe(true)
|
|
49
|
+
expect(posture.label).toBe('Deny')
|
|
50
|
+
expect(posture.level).toBe('unhealthy')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The case the whole posture computation exists for. Verified on Kyverno
|
|
54
|
+
// 1.18.2: this exact spec did NOT block a violating pod, while the same
|
|
55
|
+
// policy with admission enabled did. Rendering it as a red "Deny" would
|
|
56
|
+
// tell an operator the cluster is protected when it is not.
|
|
57
|
+
it('does not report blocking when admission evaluation is disabled, even for Deny', () => {
|
|
58
|
+
const posture = getKyvernoEnforcementPosture(
|
|
59
|
+
vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
|
|
60
|
+
'validating'
|
|
61
|
+
)
|
|
62
|
+
expect(posture.blocks).toBe(false)
|
|
63
|
+
expect(posture.label).toBe('Background only')
|
|
64
|
+
expect(posture.note).toMatch(/never blocked/i)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Tone marks the discrepancy between declared and effective, not the
|
|
68
|
+
// severity of the declared action. `alert` rather than `degraded` because
|
|
69
|
+
// `degraded` is already the deliberate Warn posture — "I meant to audit" and
|
|
70
|
+
// "I meant to block and don't" must not share a tone.
|
|
71
|
+
it('flags a Deny-declaring background-only policy as a discrepancy', () => {
|
|
72
|
+
const p = getKyvernoEnforcementPosture(
|
|
73
|
+
vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
|
|
74
|
+
'validating'
|
|
75
|
+
)
|
|
76
|
+
expect(p.label).toBe('Background only')
|
|
77
|
+
expect(p.level).toBe('alert')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// No Deny declared means no protection gap — reporting-only either way.
|
|
81
|
+
it('leaves a non-blocking background-only policy calm', () => {
|
|
82
|
+
const p = getKyvernoEnforcementPosture(
|
|
83
|
+
vpol({ validationActions: ['Audit'], evaluation: { admission: { enabled: false } } }),
|
|
84
|
+
'validating'
|
|
85
|
+
)
|
|
86
|
+
expect(p.label).toBe('Background only')
|
|
87
|
+
expect(p.level).toBe('neutral')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Deliberate postures keep their own tones and must stay distinct from the
|
|
91
|
+
// discrepancy tier above.
|
|
92
|
+
it('keeps deliberate Audit and Audit + Warn visually distinct from a discrepancy', () => {
|
|
93
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'] }), 'validating').level).toBe('neutral')
|
|
94
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit', 'Warn'] }), 'validating').level).toBe('degraded')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('flags JSON mode as a discrepancy only when the policy declares Deny', () => {
|
|
98
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], evaluation: { mode: 'JSON' } }), 'validating').level).toBe('alert')
|
|
99
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'], evaluation: { mode: 'JSON' } }), 'validating').level).toBe('neutral')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// A policy that evaluates nothing at all declared *something* and delivers
|
|
103
|
+
// none of it, whatever that something was.
|
|
104
|
+
it('flags a fully inactive policy as a discrepancy regardless of declared action', () => {
|
|
105
|
+
const ev = { admission: { enabled: false }, background: { enabled: false } }
|
|
106
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], evaluation: ev }), 'validating').level).toBe('alert')
|
|
107
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'], evaluation: ev }), 'validating').level).toBe('alert')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('flags an inactive mutating policy as a discrepancy', () => {
|
|
111
|
+
const p = getKyvernoEnforcementPosture(vpol({ evaluation: { admission: { enabled: false } } }), 'mutating')
|
|
112
|
+
expect(p.label).toBe('Inactive')
|
|
113
|
+
expect(p.level).toBe('alert')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('says the policy does nothing when both admission and background are off', () => {
|
|
117
|
+
const posture = getKyvernoEnforcementPosture(
|
|
118
|
+
vpol({
|
|
119
|
+
validationActions: ['Deny'],
|
|
120
|
+
evaluation: { admission: { enabled: false }, background: { enabled: false } },
|
|
121
|
+
}),
|
|
122
|
+
'validating'
|
|
123
|
+
)
|
|
124
|
+
expect(posture.blocks).toBe(false)
|
|
125
|
+
expect(posture.label).toBe('Inactive')
|
|
126
|
+
expect(posture.note).toMatch(/does nothing/i)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// JSON mode evaluates against payloads outside the cluster, so it never
|
|
130
|
+
// participates in admission regardless of declared actions.
|
|
131
|
+
it('does not report blocking in JSON evaluation mode', () => {
|
|
132
|
+
const posture = getKyvernoEnforcementPosture(
|
|
133
|
+
vpol({ validationActions: ['Deny'], evaluation: { mode: 'JSON' } }),
|
|
134
|
+
'validating'
|
|
135
|
+
)
|
|
136
|
+
expect(posture.blocks).toBe(false)
|
|
137
|
+
expect(posture.label).toBe('JSON mode')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('treats Audit and Warn as non-blocking', () => {
|
|
141
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'] }), 'validating').blocks).toBe(false)
|
|
142
|
+
expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Warn'] }), 'validating').blocks).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('reports Deny as blocking when combined with other actions', () => {
|
|
146
|
+
const posture = getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny', 'Audit'] }), 'validating')
|
|
147
|
+
expect(posture.blocks).toBe(true)
|
|
148
|
+
expect(posture.label).toBe('Deny + Audit')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// failurePolicy governs what happens when the ENGINE errors, not when the
|
|
152
|
+
// policy fails, so it must not move the blocking verdict either way.
|
|
153
|
+
it('ignores failurePolicy when deciding whether the policy blocks', () => {
|
|
154
|
+
const ignore = getKyvernoEnforcementPosture(
|
|
155
|
+
vpol({ validationActions: ['Deny'], failurePolicy: 'Ignore' }),
|
|
156
|
+
'validating'
|
|
157
|
+
)
|
|
158
|
+
const fail = getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], failurePolicy: 'Fail' }), 'validating')
|
|
159
|
+
expect(ignore.blocks).toBe(true)
|
|
160
|
+
expect(fail.blocks).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('non-validating families', () => {
|
|
165
|
+
it('describes mutating policies as mutating, never blocking', () => {
|
|
166
|
+
const posture = getKyvernoEnforcementPosture(vpol({ mutations: [] }), 'mutating')
|
|
167
|
+
expect(posture.blocks).toBe(false)
|
|
168
|
+
expect(posture.label).toBe('Mutates on admission')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('marks a mutating policy inactive when admission is disabled and it mutates nothing existing', () => {
|
|
172
|
+
const posture = getKyvernoEnforcementPosture(
|
|
173
|
+
vpol({ evaluation: { admission: { enabled: false } } }),
|
|
174
|
+
'mutating'
|
|
175
|
+
)
|
|
176
|
+
expect(posture.label).toBe('Inactive')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Codex #7: mutateExisting rewrites resources that already exist,
|
|
180
|
+
// independently of the admission path. Calling such a policy "Inactive"
|
|
181
|
+
// tells an operator nothing is changing while it actively rewrites their
|
|
182
|
+
// cluster. Verified against the upstream CRD: MutatingPolicy's
|
|
183
|
+
// spec.evaluation.mutateExisting.enabled defaults to false, the opposite of
|
|
184
|
+
// admission/background.
|
|
185
|
+
it('does not call a mutating policy inactive when mutateExisting is on', () => {
|
|
186
|
+
const posture = getKyvernoEnforcementPosture(
|
|
187
|
+
vpol({ evaluation: { admission: { enabled: false }, mutateExisting: { enabled: true } } }),
|
|
188
|
+
'mutating'
|
|
189
|
+
)
|
|
190
|
+
expect(posture.label).toBe('Mutates existing')
|
|
191
|
+
expect(posture.label).not.toBe('Inactive')
|
|
192
|
+
expect(posture.summary).toMatch(/rewritten/i)
|
|
193
|
+
expect(posture.note).toMatch(/mutateExisting is on/i)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('mentions existing-resource rewriting even when admission is enabled', () => {
|
|
197
|
+
const posture = getKyvernoEnforcementPosture(
|
|
198
|
+
vpol({ evaluation: { admission: { enabled: true }, mutateExisting: { enabled: true } } }),
|
|
199
|
+
'mutating'
|
|
200
|
+
)
|
|
201
|
+
expect(posture.label).toBe('Mutates on admission')
|
|
202
|
+
expect(posture.summary).toMatch(/existing/i)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// A deleting policy has no "violations" — the generic non-blocking sentence
|
|
206
|
+
// must not leak into a family it does not describe.
|
|
207
|
+
it('describes deletion in its own terms, not violation terms', () => {
|
|
208
|
+
const posture = getKyvernoEnforcementPosture(vpol({ schedule: '0 2 * * *' }), 'deleting')
|
|
209
|
+
expect(posture.summary).toMatch(/deleted/i)
|
|
210
|
+
expect(posture.summary).not.toMatch(/violation/i)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('describes generation in its own terms', () => {
|
|
214
|
+
const posture = getKyvernoEnforcementPosture(vpol({ generate: [] }), 'generating')
|
|
215
|
+
expect(posture.summary).not.toMatch(/violation/i)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('surfaces the cron in a deleting policy posture', () => {
|
|
219
|
+
const posture = getKyvernoEnforcementPosture(vpol({ schedule: '0 */6 * * *' }), 'deleting')
|
|
220
|
+
expect(posture.blocks).toBe(false)
|
|
221
|
+
expect(posture.label).toContain('0 */6 * * *')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('flags a deleting policy with no schedule as never running', () => {
|
|
225
|
+
const posture = getKyvernoEnforcementPosture(vpol({}), 'deleting')
|
|
226
|
+
expect(posture.note).toMatch(/will not run/i)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('status badge', () => {
|
|
231
|
+
// A policy that failed to compile enforces nothing whatever its spec says,
|
|
232
|
+
// so readiness has to outrank the declared posture.
|
|
233
|
+
it('reports Not Ready ahead of the enforcement posture', () => {
|
|
234
|
+
const badge = getModernKyvernoPolicyStatus(
|
|
235
|
+
vpol({ validationActions: ['Deny'] }, { status: { conditionStatus: { ready: false } } })
|
|
236
|
+
)
|
|
237
|
+
expect(badge.text).toBe('Not Ready')
|
|
238
|
+
expect(badge.level).toBe('unhealthy')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('falls back to the posture when ready', () => {
|
|
242
|
+
const badge = getModernKyvernoPolicyStatus(
|
|
243
|
+
vpol({ validationActions: ['Audit'] }, { status: { conditionStatus: { ready: true } } })
|
|
244
|
+
)
|
|
245
|
+
expect(badge.text).toBe('Audit')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('family + group detection', () => {
|
|
250
|
+
it('collapses namespaced twins onto their cluster-scoped family', () => {
|
|
251
|
+
expect(getKyvernoPolicyFamily('NamespacedValidatingPolicy')).toBe('validating')
|
|
252
|
+
expect(getKyvernoPolicyFamily('ValidatingPolicy')).toBe('validating')
|
|
253
|
+
expect(getKyvernoPolicyFamily('NamespacedImageValidatingPolicy')).toBe('imageValidating')
|
|
254
|
+
expect(getKyvernoPolicyFamily('DeletingPolicy')).toBe('deleting')
|
|
255
|
+
expect(getKyvernoPolicyFamily('SomethingElse')).toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// The guard that stops a foreign CRD sharing a generic plural like
|
|
259
|
+
// `validatingpolicies` from being described in Kyverno's vocabulary.
|
|
260
|
+
it('only matches the policies.kyverno.io group', () => {
|
|
261
|
+
expect(isModernKyvernoPolicy({ apiVersion: 'policies.kyverno.io/v1' })).toBe(true)
|
|
262
|
+
expect(isModernKyvernoPolicy({ apiVersion: 'kyverno.io/v1' })).toBe(false)
|
|
263
|
+
expect(isModernKyvernoPolicy({ apiVersion: 'example.com/v1' })).toBe(false)
|
|
264
|
+
expect(isModernKyvernoPolicy({})).toBe(false)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('defaults admission evaluation to enabled when unset', () => {
|
|
268
|
+
expect(isKyvernoAdmissionEnabled(vpol({}))).toBe(true)
|
|
269
|
+
expect(isKyvernoAdmissionEnabled(vpol({ evaluation: {} }))).toBe(true)
|
|
270
|
+
expect(isKyvernoAdmissionEnabled(vpol({ evaluation: { admission: { enabled: false } } }))).toBe(false)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('match summary', () => {
|
|
275
|
+
it('summarizes matched resources and truncates long lists', () => {
|
|
276
|
+
const spec = {
|
|
277
|
+
matchConstraints: {
|
|
278
|
+
resourceRules: [
|
|
279
|
+
{ apiGroups: [''], apiVersions: ['v1'], resources: ['pods', 'configmaps'], operations: ['CREATE'] },
|
|
280
|
+
{ apiGroups: ['apps'], apiVersions: ['v1'], resources: ['deployments', 'statefulsets'], operations: ['CREATE'] },
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
expect(getKyvernoMatchSummary(vpol(spec))).toBe('pods, configmaps, deployments +1')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('returns a dash when nothing is matched', () => {
|
|
288
|
+
expect(getKyvernoMatchSummary(vpol({}))).toBe('-')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { CNPGClusterRenderer } from './CNPGClusterRenderer'
|
|
4
|
+
import { getCNPGClusterStatus } from '../resource-utils-cnpg'
|
|
5
|
+
|
|
6
|
+
const cluster = (phase: string, readyInstances: number, extra: any = {}) => ({
|
|
7
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
8
|
+
kind: 'Cluster',
|
|
9
|
+
metadata: { name: 'pg', namespace: 'db' },
|
|
10
|
+
spec: { instances: 3 },
|
|
11
|
+
status: { phase, readyInstances, ...extra },
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const html = (resource: any) => renderToString(<CNPGClusterRenderer data={resource} />)
|
|
15
|
+
|
|
16
|
+
describe('CNPGClusterRenderer — the drawer must not contradict the badge', () => {
|
|
17
|
+
it('does not raise Degraded for a shortfall the phase already explains', () => {
|
|
18
|
+
// Mid-switchover at 2/3 is the operation, not a fault. The badge says
|
|
19
|
+
// "Switchover"; the drawer used to say "Degraded Cluster" beside it.
|
|
20
|
+
const switching = cluster('Switchover in progress', 2)
|
|
21
|
+
expect(getCNPGClusterStatus(switching).text).toBe('Switchover')
|
|
22
|
+
expect(html(switching)).not.toContain('Degraded Cluster')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it.each([
|
|
26
|
+
['Upgrading cluster', 'transient'],
|
|
27
|
+
['Waiting for user action', 'attention'],
|
|
28
|
+
['Failing over', 'failing'],
|
|
29
|
+
['Cluster is unrecoverable and needs manual intervention', 'terminal'],
|
|
30
|
+
])('suppresses the Degraded banner during %s (%s)', (phase) => {
|
|
31
|
+
expect(html(cluster(phase, 2))).not.toContain('Degraded Cluster')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('still raises Degraded when the phase does NOT explain the shortfall', () => {
|
|
35
|
+
// The regression guard for the fix: a settled cluster missing an instance
|
|
36
|
+
// is a genuine problem, and both surfaces must say so.
|
|
37
|
+
const settled = cluster('Cluster in healthy state', 2)
|
|
38
|
+
expect(getCNPGClusterStatus(settled).text).toBe('Degraded')
|
|
39
|
+
expect(html(settled)).toContain('Degraded Cluster')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('still raises Cluster Down at zero ready, whatever the phase', () => {
|
|
43
|
+
// No phase excuses a database with nothing serving.
|
|
44
|
+
expect(html(cluster('Upgrading cluster', 0))).toContain('Cluster Down')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders a failed last backup at the same tier as the badge and detector', () => {
|
|
48
|
+
// Three surfaces, one severity: badge `degraded`, Go SeverityWarning, and
|
|
49
|
+
// the banner. An error banner made the drawer the loudest of the three.
|
|
50
|
+
const failed = cluster('Cluster in healthy state', 3, {
|
|
51
|
+
conditions: [{ type: 'LastBackupSucceeded', status: 'False', reason: 'BackupFailed', message: 'upload rejected' }],
|
|
52
|
+
})
|
|
53
|
+
const out = html(failed)
|
|
54
|
+
expect(out).toContain('Last backup failed')
|
|
55
|
+
expect(getCNPGClusterStatus(failed).level).toBe('degraded')
|
|
56
|
+
// AlertBanner keys its tone off the variant; the error tone must be absent
|
|
57
|
+
// for this banner while the warning tone is present.
|
|
58
|
+
const banner = out.slice(out.indexOf('Last backup failed') - 400, out.indexOf('Last backup failed'))
|
|
59
|
+
expect(banner).not.toMatch(/status-unhealthy|text-red-/)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('the WAL banner must not claim nothing else looks wrong when something does', () => {
|
|
64
|
+
const walFailing = (phase: string, ready: number) => cluster(phase, ready, {
|
|
65
|
+
conditions: [{ type: 'ContinuousArchiving', status: 'False', reason: 'ContinuousArchivingFailing' }],
|
|
66
|
+
})
|
|
67
|
+
const CLAIM = 'otherwise serving normally'
|
|
68
|
+
|
|
69
|
+
it('makes the claim on a genuinely healthy cluster', () => {
|
|
70
|
+
expect(html(walFailing('Cluster in healthy state', 3))).toContain(CLAIM)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('drops it mid-switchover, where a Switchover banner sits right above', () => {
|
|
74
|
+
// Regression guard for a sibling the phase gate itself introduced: this
|
|
75
|
+
// clause used to be suppressed by isDegraded, which a phase-explained
|
|
76
|
+
// shortfall no longer sets.
|
|
77
|
+
const out = html(walFailing('Switchover in progress', 2))
|
|
78
|
+
expect(out).toContain('Switchover in Progress')
|
|
79
|
+
expect(out).not.toContain(CLAIM)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it.each(['Waiting for user action', 'Upgrading cluster', 'Failing over'])('drops it during %s', (phase) => {
|
|
83
|
+
expect(html(walFailing(phase, 2))).not.toContain(CLAIM)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
getCNPGClusterPostgresParams,
|
|
19
19
|
getCNPGClusterInstancesReportedState,
|
|
20
20
|
getCNPGClusterCertificateExpirations,
|
|
21
|
+
getCNPGWALArchivingFailure,
|
|
22
|
+
getCNPGLastBackupFailure,
|
|
23
|
+
classifyCNPGClusterPhase,
|
|
21
24
|
} from '../resource-utils-cnpg'
|
|
25
|
+
import { formatAge } from '../resource-utils'
|
|
22
26
|
|
|
23
27
|
interface CNPGClusterRendererProps {
|
|
24
28
|
data: any
|
|
@@ -28,7 +32,12 @@ interface CNPGClusterRendererProps {
|
|
|
28
32
|
export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererProps) {
|
|
29
33
|
const conditions = data.status?.conditions || []
|
|
30
34
|
const instances = data.spec?.instances ?? 0
|
|
31
|
-
|
|
35
|
+
// Presence matters — see getCNPGClusterStatus. A cluster whose status has no
|
|
36
|
+
// readyInstances yet is unknown, not zero; defaulting to 0 raised a "Cluster
|
|
37
|
+
// Down" banner on a cluster that had simply not reported yet.
|
|
38
|
+
const readyKnown = typeof data.status?.readyInstances === 'number'
|
|
39
|
+
const readyInstances: number = readyKnown ? data.status.readyInstances : 0
|
|
40
|
+
const countsKnown = instances > 0 && readyKnown
|
|
32
41
|
const phase = getCNPGClusterPhase(data)
|
|
33
42
|
const backupConfig = getCNPGClusterBackupConfig(data)
|
|
34
43
|
const monitoring = getCNPGClusterMonitoring(data)
|
|
@@ -57,15 +66,46 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
57
66
|
// Last failed backup
|
|
58
67
|
const lastFailedBackup = data.status?.lastFailedBackup
|
|
59
68
|
|
|
69
|
+
// WAL archiving / backup health, straight from the operator's own conditions.
|
|
70
|
+
const walArchivingFailure = getCNPGWALArchivingFailure(data)
|
|
71
|
+
const lastBackupFailure = getCNPGLastBackupFailure(data)
|
|
72
|
+
const phaseBucket = classifyCNPGClusterPhase(phase)
|
|
73
|
+
|
|
60
74
|
// Problem detection
|
|
61
|
-
const isDown =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
const isDown = countsKnown && readyInstances === 0
|
|
76
|
+
// A shortfall the PHASE already explains is not a separate problem. Mirrors
|
|
77
|
+
// source_cnpg.go's phaseExplained gate and the badge's ordering, both of
|
|
78
|
+
// which check the phase before the instance counts: mid-switchover or waiting
|
|
79
|
+
// on a supervised update, running below the desired count is the operation,
|
|
80
|
+
// not a fault. Without this the drawer raised "Degraded Cluster" while the
|
|
81
|
+
// badge deliberately said "Switchover" — the same two-surfaces-one-cluster
|
|
82
|
+
// disagreement this integration exists to remove, inverted.
|
|
83
|
+
const phaseExplainsShortfall =
|
|
84
|
+
phaseBucket === 'terminal' || phaseBucket === 'failing'
|
|
85
|
+
|| phaseBucket === 'attention' || phaseBucket === 'transient'
|
|
86
|
+
const isDegraded = countsKnown && readyInstances < instances && readyInstances > 0 && !phaseExplainsShortfall
|
|
87
|
+
const isFailover = phaseBucket === 'failing'
|
|
88
|
+
const isSwitchover = phase === 'Switchover in progress'
|
|
89
|
+
const isTerminal = phaseBucket === 'terminal'
|
|
90
|
+
// "The cluster is otherwise serving normally, so nothing else here will look
|
|
91
|
+
// wrong" is a claim about the REST OF THIS DRAWER, so any other banner
|
|
92
|
+
// falsifies it. It can no longer lean on isDegraded now that a
|
|
93
|
+
// phase-explained shortfall doesn't set it: a cluster mid-switchover at 2/3
|
|
94
|
+
// would assert nothing else looks wrong directly beneath a Switchover banner.
|
|
95
|
+
const hasOtherProblemBanner =
|
|
96
|
+
isDown || isDegraded || primaryMismatch || hasSplitBrain
|
|
97
|
+
|| (phaseBucket !== 'healthy' && phaseBucket !== 'unknown')
|
|
65
98
|
|
|
66
99
|
return (
|
|
67
100
|
<>
|
|
68
101
|
{/* Problem alerts */}
|
|
102
|
+
{isTerminal && (
|
|
103
|
+
<AlertBanner
|
|
104
|
+
variant="error"
|
|
105
|
+
title="Reconciliation has stopped"
|
|
106
|
+
message={`${phase}. This state does not resolve on its own — the operator has stopped reconciling this cluster and it needs manual intervention.`}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
69
109
|
{hasSplitBrain && (
|
|
70
110
|
<AlertBanner
|
|
71
111
|
variant="error"
|
|
@@ -88,8 +128,13 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
88
128
|
/>
|
|
89
129
|
)}
|
|
90
130
|
{isFailover && (
|
|
131
|
+
// Warning, not error: the badge renders failover at the alert tier and
|
|
132
|
+
// the issue detector reports it as a warning. A failover that has also
|
|
133
|
+
// taken every instance down still gets the red "Cluster Down" banner
|
|
134
|
+
// above, so severity is not lost — this just stops one surface shouting
|
|
135
|
+
// louder than the other two about the same event.
|
|
91
136
|
<AlertBanner
|
|
92
|
-
variant="
|
|
137
|
+
variant="warning"
|
|
93
138
|
title="Failover in Progress"
|
|
94
139
|
message={`Cluster is performing a failover. Current phase: ${phase}`}
|
|
95
140
|
/>
|
|
@@ -108,11 +153,39 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
108
153
|
message={`Target primary is ${targetPrimary} but current primary is ${currentPrimary}.`}
|
|
109
154
|
/>
|
|
110
155
|
)}
|
|
111
|
-
{
|
|
156
|
+
{walArchivingFailure && (
|
|
112
157
|
<AlertBanner
|
|
113
158
|
variant="error"
|
|
114
|
-
title="
|
|
115
|
-
message={
|
|
159
|
+
title="WAL archiving is failing"
|
|
160
|
+
message={
|
|
161
|
+
// Precision matters here. The condition proves the last archival
|
|
162
|
+
// attempt did not complete and has stayed False since its
|
|
163
|
+
// transition — it does not prove a continuous run of failures, and
|
|
164
|
+
// it says nothing about an exact RPO. The "still serving" framing
|
|
165
|
+
// is gated on the cluster actually being up: asserting it next to a
|
|
166
|
+
// Cluster Down banner would be plainly false.
|
|
167
|
+
`CNPG reports that the last WAL archival did not complete${walArchivingFailure.lastTransitionTime ? `, and archiving has been in this state for ${formatAge(walArchivingFailure.lastTransitionTime)}` : ''}. ` +
|
|
168
|
+
'Recovery-point advancement is uncertain — check the backup destination before relying on point-in-time recovery.' +
|
|
169
|
+
(hasOtherProblemBanner
|
|
170
|
+
? ''
|
|
171
|
+
: ' The cluster is otherwise serving normally, so nothing else here will look wrong.') +
|
|
172
|
+
(walArchivingFailure.message ? ` Operator reported: ${walArchivingFailure.message}` : '')
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
{lastBackupFailure && (
|
|
177
|
+
// Warning, not error: the badge renders this at the degraded tier and
|
|
178
|
+
// the issue detector reports SeverityWarning. One failed attempt puts
|
|
179
|
+
// the recovery window at risk and CNPG will retry — unlike the
|
|
180
|
+
// archiving failure above, which is continuous and stays red on all
|
|
181
|
+
// three surfaces.
|
|
182
|
+
<AlertBanner
|
|
183
|
+
variant="warning"
|
|
184
|
+
title="Last backup failed"
|
|
185
|
+
message={
|
|
186
|
+
`The most recent backup attempt failed${lastBackupFailure.lastTransitionTime ? ` ${formatAge(lastBackupFailure.lastTransitionTime)} ago` : ''}.` +
|
|
187
|
+
(lastBackupFailure.message ? ` Operator reported: ${lastBackupFailure.message}` : '')
|
|
188
|
+
}
|
|
116
189
|
/>
|
|
117
190
|
)}
|
|
118
191
|
{expiredCerts.length > 0 && (
|
|
@@ -242,6 +315,22 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
242
315
|
{backupConfig.configured && (
|
|
243
316
|
<Section title="Backup" icon={Clock} defaultExpanded>
|
|
244
317
|
<PropertyList>
|
|
318
|
+
{backupConfig.plugin && (
|
|
319
|
+
<Property label="Managed By" value="barman-cloud plugin" />
|
|
320
|
+
)}
|
|
321
|
+
{backupConfig.plugin?.barmanObjectName && (
|
|
322
|
+
<Property label="Object Store" value={
|
|
323
|
+
<ResourceLink
|
|
324
|
+
name={backupConfig.plugin.barmanObjectName}
|
|
325
|
+
kind="objectstores"
|
|
326
|
+
namespace={data.metadata?.namespace || ''}
|
|
327
|
+
onNavigate={onNavigate}
|
|
328
|
+
/>
|
|
329
|
+
} />
|
|
330
|
+
)}
|
|
331
|
+
{backupConfig.plugin?.isWALArchiver && (
|
|
332
|
+
<Property label="WAL Archiver" value="This plugin" />
|
|
333
|
+
)}
|
|
245
334
|
{backupConfig.destinationPath && (
|
|
246
335
|
<Property label="Destination" value={backupConfig.destinationPath} />
|
|
247
336
|
)}
|
|
@@ -258,6 +347,17 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
258
347
|
<Property label="First Recoverability" value={backupConfig.firstRecoverabilityPoint} />
|
|
259
348
|
)}
|
|
260
349
|
</PropertyList>
|
|
350
|
+
{/* Without this note the section renders empty on plugin-migrated
|
|
351
|
+
clusters, which is indistinguishable from "no backups configured" —
|
|
352
|
+
the worst possible ambiguity for a recovery-point display. */}
|
|
353
|
+
{backupConfig.rpoTrackedOnObjectStore && (
|
|
354
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
355
|
+
Recovery-point fields are not published on the Cluster when backups run through the
|
|
356
|
+
barman-cloud plugin. The recovery window is tracked on the ObjectStore
|
|
357
|
+
{backupConfig.plugin?.barmanObjectName ? ` "${backupConfig.plugin.barmanObjectName}"` : ''}
|
|
358
|
+
{backupConfig.plugin?.serverName ? `, under server "${backupConfig.plugin.serverName}"` : ''}.
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
261
361
|
</Section>
|
|
262
362
|
)}
|
|
263
363
|
|
|
@@ -17,8 +17,12 @@ interface CNPGPoolerRendererProps {
|
|
|
17
17
|
|
|
18
18
|
export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps) {
|
|
19
19
|
const desired = data.spec?.instances ?? 0
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// status.instances is the number of pods trying to be SCHEDULED, not ready
|
|
21
|
+
// pods — a Pooler whose PgBouncer pods are all Pending still reports the full
|
|
22
|
+
// count. Naming it `ready` here is what let the banner claim a readiness the
|
|
23
|
+
// CR never establishes.
|
|
24
|
+
const scheduled = data.status?.instances ?? 0
|
|
25
|
+
const isDegraded = desired > 0 && scheduled < desired
|
|
22
26
|
const clusterName = getCNPGPoolerCluster(data)
|
|
23
27
|
const parameters = getCNPGPoolerParameters(data)
|
|
24
28
|
const authQuery = getCNPGPoolerAuthQuery(data)
|
|
@@ -31,7 +35,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
31
35
|
<AlertBanner
|
|
32
36
|
variant="warning"
|
|
33
37
|
title="Pooler Degraded"
|
|
34
|
-
message={`Only ${
|
|
38
|
+
message={`Only ${scheduled} of ${desired} pooler instances are scheduled. Readiness is tracked on the "${data.metadata?.name}" Deployment, not on the Pooler.`}
|
|
35
39
|
/>
|
|
36
40
|
)}
|
|
37
41
|
|
|
@@ -40,7 +44,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
40
44
|
<PropertyList>
|
|
41
45
|
<Property label="Type" value={getCNPGPoolerType(data)} />
|
|
42
46
|
<Property label="Pool Mode" value={getCNPGPoolerMode(data)} />
|
|
43
|
-
<Property label="Instances" value={getCNPGPoolerInstances(data)} />
|
|
47
|
+
<Property label="Instances Scheduled" value={getCNPGPoolerInstances(data)} />
|
|
44
48
|
</PropertyList>
|
|
45
49
|
</Section>
|
|
46
50
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { EventRenderer } from './EventRenderer'
|
|
5
|
+
|
|
6
|
+
describe('EventRenderer', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
['Warning', 'status-degraded'],
|
|
9
|
+
['Normal', 'status-neutral'],
|
|
10
|
+
])('uses the theme-aware status tone for %s events', (type, statusClass) => {
|
|
11
|
+
const html = renderToString(<EventRenderer data={{
|
|
12
|
+
type,
|
|
13
|
+
reason: 'FailedScheduling',
|
|
14
|
+
message: 'No nodes are available',
|
|
15
|
+
}} />)
|
|
16
|
+
|
|
17
|
+
expect(html).toContain(statusClass)
|
|
18
|
+
expect(html).toContain('FailedScheduling')
|
|
19
|
+
expect(html).toContain('No nodes are available')
|
|
20
|
+
expect(html).not.toMatch(/text-(?:amber|blue)-(?:200|300|400)/)
|
|
21
|
+
expect(html).not.toContain('opacity-')
|
|
22
|
+
})
|
|
23
|
+
})
|