@skyhook-io/k8s-ui 1.14.0 → 1.14.2
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/gitops/GitOpsTableView.tsx +168 -11
- package/src/components/gitops/applicationset-row.test.ts +214 -0
- package/src/components/gitops/detail-helpers.test.ts +54 -0
- package/src/components/gitops/detail-helpers.ts +13 -3
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
- package/src/components/issues/IssuesView.tsx +1 -1
- package/src/components/issues/types.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +382 -141
- package/src/components/resources/column-header-width.test.ts +75 -0
- package/src/components/resources/column-settings-merge.test.ts +104 -0
- package/src/components/resources/istio-gateway-filter.test.ts +5 -3
- package/src/components/resources/istio-policy-accessors.test.ts +193 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
- package/src/components/resources/renderers/eso-cells.tsx +0 -5
- package/src/components/resources/renderers/istio-cells.tsx +36 -26
- package/src/components/resources/renderers/kueue-cells.tsx +0 -9
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
- package/src/components/resources/renderers/ray-cells.tsx +12 -4
- package/src/components/resources/renderers/volcano-cells.tsx +0 -9
- package/src/components/resources/resource-utils-cnpg.ts +5 -0
- package/src/components/resources/resource-utils-eso.test.ts +56 -0
- package/src/components/resources/resource-utils-eso.ts +37 -5
- package/src/components/resources/resource-utils-istio.ts +152 -44
- package/src/components/resources/resource-utils-kyverno.ts +3 -6
- package/src/components/resources/resource-utils-prometheus.ts +51 -23
- package/src/components/resources/resource-utils-ray.test.ts +36 -0
- package/src/components/resources/resource-utils-ray.ts +6 -0
- package/src/components/resources/resource-utils.ts +166 -18
- package/src/components/resources/rules-column-width.test.ts +42 -0
- package/src/components/resources/status-accessors.test.ts +363 -0
- package/src/components/resources/status-overclaim.test.ts +158 -0
- package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
- package/src/components/timeline/DiffViewer.test.tsx +26 -0
- package/src/components/timeline/DiffViewer.tsx +16 -12
- package/src/components/topology/GroupNode.tsx +6 -2
- package/src/components/topology/TopologyGraph.tsx +7 -2
- package/src/components/topology/topology.css +4 -1
- package/src/components/ui/Collapse.tsx +18 -3
- package/src/components/ui/ConfirmDialog.test.tsx +37 -0
- package/src/components/ui/ConfirmDialog.tsx +3 -1
- package/src/components/ui/RowActionMenu.tsx +4 -0
- package/src/components/ui/drawer-components.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +11 -3
- package/src/types/gitops.ts +51 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getNetworkPolicySelector, getRouteStatus, getRouteStatusReason } from './resource-utils'
|
|
3
|
+
import { getIstioGatewayStatus } from './resource-utils-istio'
|
|
4
|
+
|
|
5
|
+
// Each case names the cluster state behind the verdict, so a future change that
|
|
6
|
+
// re-greens one of them fails here with the reason attached.
|
|
7
|
+
|
|
8
|
+
describe('getNetworkPolicySelector', () => {
|
|
9
|
+
it('reports a matchExpressions-only policy as targeting a subset, not everything', () => {
|
|
10
|
+
// podSelector is a LabelSelector: a policy can target a fraction of the
|
|
11
|
+
// namespace through expressions alone, and must not claim to cover all of it.
|
|
12
|
+
const np = {
|
|
13
|
+
spec: { podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web', 'api'] }] } },
|
|
14
|
+
}
|
|
15
|
+
expect(getNetworkPolicySelector(np)).toBe('tier In (web,api)')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('preserves every value rather than collapsing a multi-value NotIn', () => {
|
|
19
|
+
const np = {
|
|
20
|
+
spec: { podSelector: { matchExpressions: [{ key: 'env', operator: 'NotIn', values: ['prod', 'staging'] }] } },
|
|
21
|
+
}
|
|
22
|
+
expect(getNetworkPolicySelector(np)).toBe('env NotIn (prod,staging)')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders valueless operators', () => {
|
|
26
|
+
const np = { spec: { podSelector: { matchExpressions: [{ key: 'role', operator: 'Exists' }] } } }
|
|
27
|
+
expect(getNetworkPolicySelector(np)).toBe('role Exists')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('combines matchLabels with matchExpressions', () => {
|
|
31
|
+
const np = {
|
|
32
|
+
spec: {
|
|
33
|
+
podSelector: {
|
|
34
|
+
matchLabels: { app: 'api' },
|
|
35
|
+
matchExpressions: [{ key: 'tier', operator: 'In', values: ['web'] }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
expect(getNetworkPolicySelector(np)).toBe('app=api, tier In (web)')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not switch vocabulary when every expression entry is malformed', () => {
|
|
43
|
+
// The shared formatter skips entries it cannot read and has its own
|
|
44
|
+
// empty-case wording; reaching it would describe one state in two ways.
|
|
45
|
+
expect(getNetworkPolicySelector({ spec: { podSelector: { matchExpressions: [null] } } })).toBe('All pods')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('still says All pods when the selector is genuinely empty', () => {
|
|
49
|
+
expect(getNetworkPolicySelector({ spec: { podSelector: {} } })).toBe('All pods')
|
|
50
|
+
expect(getNetworkPolicySelector({ spec: {} })).toBe('All pods')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('getRouteStatus', () => {
|
|
55
|
+
const route = (opts: {
|
|
56
|
+
refs?: any[]
|
|
57
|
+
parents?: any[]
|
|
58
|
+
generation?: number
|
|
59
|
+
namespace?: string
|
|
60
|
+
}) => ({
|
|
61
|
+
metadata: { namespace: opts.namespace ?? 'prod', generation: opts.generation },
|
|
62
|
+
spec: { parentRefs: opts.refs ?? [{ name: 'gw' }] },
|
|
63
|
+
status: { parents: opts.parents ?? [] },
|
|
64
|
+
})
|
|
65
|
+
const cond = (type: string, status: string, observedGeneration?: number) =>
|
|
66
|
+
({ type, status, ...(observedGeneration === undefined ? {} : { observedGeneration }) })
|
|
67
|
+
|
|
68
|
+
it('is not healthy when a backendRef names a Service that does not exist', () => {
|
|
69
|
+
// Accepted=True + ResolvedRefs=False is the documented shape for a missing
|
|
70
|
+
// backend — the gateway still routes, and answers 5xx.
|
|
71
|
+
const r = route({
|
|
72
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'False')] }],
|
|
73
|
+
})
|
|
74
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('is healthy only when both conditions are true', () => {
|
|
78
|
+
const r = route({
|
|
79
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
80
|
+
})
|
|
81
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not call an absent ResolvedRefs healthy', () => {
|
|
85
|
+
const r = route({ parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True')] }] })
|
|
86
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('reports rejection when every requested parent rejects', () => {
|
|
90
|
+
const r = route({
|
|
91
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] }],
|
|
92
|
+
})
|
|
93
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Not Accepted', level: 'unhealthy' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('degrades rather than rejects when only one of several parents rejects', () => {
|
|
97
|
+
const r = route({
|
|
98
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
99
|
+
parents: [
|
|
100
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] },
|
|
101
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('is Pending while a requested parent has not reported yet', () => {
|
|
108
|
+
const r = route({
|
|
109
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
110
|
+
parents: [
|
|
111
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('ignores a report left behind for a parentRef the spec no longer names', () => {
|
|
118
|
+
const r = route({
|
|
119
|
+
refs: [{ name: 'gw-new' }],
|
|
120
|
+
parents: [{ parentRef: { name: 'gw-old' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
121
|
+
})
|
|
122
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown', level: 'unknown' })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does not accept conditions observed against a superseded generation', () => {
|
|
126
|
+
const r = route({
|
|
127
|
+
generation: 3,
|
|
128
|
+
parents: [{
|
|
129
|
+
parentRef: { name: 'gw' },
|
|
130
|
+
conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)],
|
|
131
|
+
}],
|
|
132
|
+
})
|
|
133
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not let a stale controller report outvote a live rejection, in either order', () => {
|
|
137
|
+
// Status entries are keyed by parentRef AND controllerName, so one parent
|
|
138
|
+
// can carry two controllers' reports while one replaces the other.
|
|
139
|
+
const old = { parentRef: { name: 'gw' }, controllerName: 'old/ctrl', conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }
|
|
140
|
+
const live = { parentRef: { name: 'gw' }, controllerName: 'new/ctrl', conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'False')] }
|
|
141
|
+
// Neither order may be green, and both must agree. Degraded rather than
|
|
142
|
+
// unhealthy is deliberate: nothing here establishes which controller is
|
|
143
|
+
// authoritative, so the verdict says "something is wrong" without claiming
|
|
144
|
+
// the route is rejected outright.
|
|
145
|
+
const forward = getRouteStatus(route({ parents: [old, live] }))
|
|
146
|
+
const reversed = getRouteStatus(route({ parents: [live, old] }))
|
|
147
|
+
expect(forward).toEqual(reversed)
|
|
148
|
+
expect(forward.level).not.toBe('healthy')
|
|
149
|
+
expect(forward).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('counts a default-Gateway attachment the spec never names', () => {
|
|
153
|
+
// With useDefaultGateways the attachment exists only in status, so a
|
|
154
|
+
// failure there is the only evidence there is.
|
|
155
|
+
const r = {
|
|
156
|
+
metadata: { namespace: 'prod' },
|
|
157
|
+
spec: { useDefaultGateways: 'All' },
|
|
158
|
+
status: { parents: [{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'False')] }] },
|
|
159
|
+
}
|
|
160
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('accepts a default-only route whose default parent is healthy', () => {
|
|
164
|
+
const r = {
|
|
165
|
+
metadata: { namespace: 'prod' },
|
|
166
|
+
spec: { useDefaultGateways: 'All' },
|
|
167
|
+
status: { parents: [{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }] },
|
|
168
|
+
}
|
|
169
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not let a healthy explicit parent mask a failing default one', () => {
|
|
173
|
+
const r = {
|
|
174
|
+
metadata: { namespace: 'prod' },
|
|
175
|
+
spec: { parentRefs: [{ name: 'gw' }], useDefaultGateways: 'All' },
|
|
176
|
+
status: {
|
|
177
|
+
parents: [
|
|
178
|
+
{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] },
|
|
179
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'False'), cond('ResolvedRefs', 'True')] },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('ignores a dropped mesh Service parent when weighing default Gateways', () => {
|
|
187
|
+
// Services cannot be default Gateways, so an unmatched Service report is
|
|
188
|
+
// obsolete by definition — and would otherwise outvote the live one
|
|
189
|
+
// indefinitely once its controller is gone.
|
|
190
|
+
const r = {
|
|
191
|
+
metadata: { namespace: 'prod', generation: 3 },
|
|
192
|
+
spec: { useDefaultGateways: 'All' },
|
|
193
|
+
status: {
|
|
194
|
+
parents: [
|
|
195
|
+
{ parentRef: { group: '', kind: 'Service', name: 'legacy-mesh' }, conditions: [cond('Accepted', 'True', 2)] },
|
|
196
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'True', 3), cond('ResolvedRefs', 'True', 3)] },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('still reports a rejecting default Gateway as rejected despite a dropped Service parent', () => {
|
|
204
|
+
const r = {
|
|
205
|
+
metadata: { namespace: 'prod', generation: 3 },
|
|
206
|
+
spec: { useDefaultGateways: 'All' },
|
|
207
|
+
status: {
|
|
208
|
+
parents: [
|
|
209
|
+
{ parentRef: { group: '', kind: 'Service', name: 'legacy-mesh' }, conditions: [cond('Accepted', 'True', 2)] },
|
|
210
|
+
{ parentRef: { name: 'default-gw' }, conditions: [cond('Accepted', 'False', 3)] },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Not Accepted', level: 'unhealthy' })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('takes a condition with no observedGeneration at face value', () => {
|
|
218
|
+
// Missing is not stale: a controller that never sets it must not strand the
|
|
219
|
+
// route as Pending forever.
|
|
220
|
+
const r = route({
|
|
221
|
+
generation: 7,
|
|
222
|
+
parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }],
|
|
223
|
+
})
|
|
224
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('lets a live healthy parent win over a superseded report for the same parent', () => {
|
|
228
|
+
// A report describing a spec that has since changed says nothing about the
|
|
229
|
+
// current one, so it must not hold a confirmed parent at Pending.
|
|
230
|
+
const r = route({
|
|
231
|
+
generation: 3,
|
|
232
|
+
parents: [
|
|
233
|
+
{ parentRef: { name: 'gw' }, controllerName: 'old/ctrl', conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)] },
|
|
234
|
+
{ parentRef: { name: 'gw' }, controllerName: 'new/ctrl', conditions: [cond('Accepted', 'True', 3), cond('ResolvedRefs', 'True', 3)] },
|
|
235
|
+
],
|
|
236
|
+
})
|
|
237
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('does not let confirmed parents speak for one that only has a stale report', () => {
|
|
241
|
+
// gw-a's sole report describes a superseded spec, so nothing has confirmed
|
|
242
|
+
// it. A healthy gw-b must not carry the verdict to Accepted on its own.
|
|
243
|
+
const r = route({
|
|
244
|
+
generation: 4,
|
|
245
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
246
|
+
parents: [
|
|
247
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'True', 2), cond('ResolvedRefs', 'True', 2)] },
|
|
248
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'True', 4), cond('ResolvedRefs', 'True', 4)] },
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Pending', level: 'degraded' })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('does not claim full rejection while a parent is unconfirmed', () => {
|
|
255
|
+
const r = route({
|
|
256
|
+
generation: 4,
|
|
257
|
+
refs: [{ name: 'gw-a' }, { name: 'gw-b' }],
|
|
258
|
+
parents: [
|
|
259
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False', 2)] },
|
|
260
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'False', 4)] },
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
// gw-a is unconfirmed against the current spec, so "every parent rejects"
|
|
264
|
+
// is not established — the live rejection still shows, as Degraded.
|
|
265
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Degraded', level: 'degraded' })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('matches a parent whose namespace and kind are defaulted on one side only', () => {
|
|
269
|
+
const r = route({
|
|
270
|
+
namespace: 'prod',
|
|
271
|
+
refs: [{ name: 'gw' }],
|
|
272
|
+
parents: [{
|
|
273
|
+
parentRef: { name: 'gw', kind: 'Gateway', namespace: 'prod', group: 'gateway.networking.k8s.io' },
|
|
274
|
+
conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')],
|
|
275
|
+
}],
|
|
276
|
+
})
|
|
277
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('does not match a report for a different sectionName', () => {
|
|
281
|
+
const r = route({
|
|
282
|
+
refs: [{ name: 'gw', sectionName: 'https' }],
|
|
283
|
+
parents: [{ parentRef: { name: 'gw', sectionName: 'http' }, conditions: [cond('Accepted', 'True')] }],
|
|
284
|
+
})
|
|
285
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown', level: 'unknown' })
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('getRouteStatusReason', () => {
|
|
290
|
+
const cond = (type: string, status: string, extra: any = {}) => ({ type, status, ...extra })
|
|
291
|
+
|
|
292
|
+
it('names the parent and the controller reason behind a Degraded badge', () => {
|
|
293
|
+
const r = {
|
|
294
|
+
metadata: { namespace: 'prod' },
|
|
295
|
+
spec: { parentRefs: [{ name: 'gw' }] },
|
|
296
|
+
status: {
|
|
297
|
+
parents: [{
|
|
298
|
+
parentRef: { name: 'gw' },
|
|
299
|
+
conditions: [
|
|
300
|
+
cond('Accepted', 'True'),
|
|
301
|
+
cond('ResolvedRefs', 'False', { reason: 'BackendNotFound', message: 'Service prod/api not found' }),
|
|
302
|
+
],
|
|
303
|
+
}],
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
expect(getRouteStatusReason(r)).toBe('gw: BackendNotFound: Service prod/api not found')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('is empty when nothing is failing, so a healthy badge carries no hover', () => {
|
|
310
|
+
const r = {
|
|
311
|
+
metadata: { namespace: 'prod' },
|
|
312
|
+
spec: { parentRefs: [{ name: 'gw' }] },
|
|
313
|
+
status: { parents: [{ parentRef: { name: 'gw' }, conditions: [cond('Accepted', 'True'), cond('ResolvedRefs', 'True')] }] },
|
|
314
|
+
}
|
|
315
|
+
expect(getRouteStatusReason(r)).toBe('')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('reports every failing parent, not just the first', () => {
|
|
319
|
+
const r = {
|
|
320
|
+
metadata: { namespace: 'prod' },
|
|
321
|
+
spec: { parentRefs: [{ name: 'gw-a' }, { name: 'gw-b' }] },
|
|
322
|
+
status: {
|
|
323
|
+
parents: [
|
|
324
|
+
{ parentRef: { name: 'gw-a' }, conditions: [cond('Accepted', 'False', { reason: 'NotAllowedByListeners' })] },
|
|
325
|
+
{ parentRef: { name: 'gw-b' }, conditions: [cond('ResolvedRefs', 'False', { reason: 'BackendNotFound' })] },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
expect(getRouteStatusReason(r)).toBe('gw-a: NotAllowedByListeners · gw-b: BackendNotFound')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('describes the same parents the verdict counted', () => {
|
|
333
|
+
// A leftover report for a dropped parentRef must not explain a badge that
|
|
334
|
+
// never considered it.
|
|
335
|
+
const r = {
|
|
336
|
+
metadata: { namespace: 'prod' },
|
|
337
|
+
spec: { parentRefs: [{ name: 'gw-new' }] },
|
|
338
|
+
status: { parents: [{ parentRef: { name: 'gw-old' }, conditions: [cond('ResolvedRefs', 'False', { reason: 'BackendNotFound' })] }] },
|
|
339
|
+
}
|
|
340
|
+
expect(getRouteStatus(r)).toMatchObject({ text: 'Unknown' })
|
|
341
|
+
expect(getRouteStatusReason(r)).toBe('')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('getIstioGatewayStatus', () => {
|
|
346
|
+
it('does not claim health it cannot observe from spec alone', () => {
|
|
347
|
+
// A Gateway whose selector matches no running ingressgateway pod has
|
|
348
|
+
// servers like any other, so servers alone cannot assert liveness.
|
|
349
|
+
const gw = { spec: { servers: [{ port: { number: 80 } }] } }
|
|
350
|
+
expect(getIstioGatewayStatus(gw)).toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('does not treat TLS as a verdict', () => {
|
|
354
|
+
const gw = { spec: { servers: [{ tls: { mode: 'SIMPLE' } }] } }
|
|
355
|
+
expect(getIstioGatewayStatus(gw)).toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('still flags a gateway with no servers', () => {
|
|
359
|
+
expect(getIstioGatewayStatus({ spec: { servers: [] } })).toMatchObject({
|
|
360
|
+
text: 'No Servers', level: 'unhealthy',
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getServiceMonitorStatus, getPrometheusRuleStatus, getPodMonitorStatus } from './resource-utils-prometheus'
|
|
3
|
+
import { getKyvernoPolicyStatus } from './resource-utils-kyverno'
|
|
4
|
+
import { getServiceEntryStatus, getVirtualServiceStatus } from './resource-utils-istio'
|
|
5
|
+
import { getCNPGObjectStoreStatus } from './resource-utils-cnpg'
|
|
6
|
+
|
|
7
|
+
// A green badge asserts something a controller checked. These pin the line
|
|
8
|
+
// between what an object declares and what something actually confirmed.
|
|
9
|
+
|
|
10
|
+
describe('Prometheus configuration resources', () => {
|
|
11
|
+
// Status lives at status.bindings[].conditions, only condition type
|
|
12
|
+
// "Accepted" exists, and the operator writes it only under the
|
|
13
|
+
// StatusForConfigurationResources feature gate.
|
|
14
|
+
const accepted = (status: string, extra: Record<string, unknown> = {}) => ({
|
|
15
|
+
metadata: { generation: 3 },
|
|
16
|
+
status: { bindings: [{ conditions: [{ type: 'Accepted', status, observedGeneration: 3, ...extra }] }] },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
for (const [name, fn] of [
|
|
20
|
+
['ServiceMonitor', getServiceMonitorStatus],
|
|
21
|
+
['PrometheusRule', getPrometheusRuleStatus],
|
|
22
|
+
['PodMonitor', getPodMonitorStatus],
|
|
23
|
+
] as const) {
|
|
24
|
+
it(`${name} does not report health when the gate wrote nothing`, () => {
|
|
25
|
+
// The overwhelmingly common shape: no status at all.
|
|
26
|
+
expect(fn({ spec: {} })).toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it(`${name} ignores a status.conditions that these kinds never have`, () => {
|
|
30
|
+
// This path is not part of these resources' schema.
|
|
31
|
+
expect(fn({ spec: {}, status: { conditions: [{ type: 'Reconciled', status: 'True' }] } }))
|
|
32
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it(`${name} surfaces an explicit rejection`, () => {
|
|
36
|
+
expect(fn(accepted('False', { reason: 'InvalidConfig' })))
|
|
37
|
+
.toMatchObject({ text: 'InvalidConfig', level: 'unhealthy' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it(`${name} reports acceptance when a workload confirmed it`, () => {
|
|
41
|
+
expect(fn(accepted('True'))).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('does not read a condition observed against an older spec', () => {
|
|
46
|
+
const stale = {
|
|
47
|
+
metadata: { generation: 7 },
|
|
48
|
+
status: { bindings: [{ conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 4 }] }] },
|
|
49
|
+
}
|
|
50
|
+
expect(getServiceMonitorStatus(stale)).toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not read one workload as all of them when another has not decided', () => {
|
|
54
|
+
const undecided = {
|
|
55
|
+
metadata: { generation: 1 },
|
|
56
|
+
status: {
|
|
57
|
+
bindings: [
|
|
58
|
+
{ name: 'a', conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 1 }] },
|
|
59
|
+
{ name: 'b', conditions: [{ type: 'Accepted', status: 'Unknown', observedGeneration: 1 }] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
expect(getServiceMonitorStatus(undecided)).toMatchObject({ text: 'Accepted by 1/2', level: 'unknown' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reports partial acceptance when workloads disagree', () => {
|
|
67
|
+
// One config resource can be selected by several Prometheus workloads, and
|
|
68
|
+
// each reports separately.
|
|
69
|
+
const mixed = {
|
|
70
|
+
metadata: { generation: 1 },
|
|
71
|
+
status: {
|
|
72
|
+
bindings: [
|
|
73
|
+
{ name: 'a', conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 1 }] },
|
|
74
|
+
{ name: 'b', conditions: [{ type: 'Accepted', status: 'False', observedGeneration: 1 }] },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
expect(getServiceMonitorStatus(mixed)).toMatchObject({ text: 'Accepted by 1/2', level: 'degraded' })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('getKyvernoPolicyStatus', () => {
|
|
83
|
+
it('reads the Ready condition the controller writes', () => {
|
|
84
|
+
expect(getKyvernoPolicyStatus({ status: { conditions: [{ type: 'Ready', status: 'True' }] } }))
|
|
85
|
+
.toMatchObject({ text: 'Ready', level: 'healthy' })
|
|
86
|
+
expect(getKyvernoPolicyStatus({ status: { conditions: [{ type: 'Ready', status: 'False', reason: 'Failed' }] } }))
|
|
87
|
+
.toMatchObject({ text: 'Failed', level: 'unhealthy' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not read declared rules as the controller accepting them', () => {
|
|
91
|
+
// Fires before the controller reports, and for Ready=Unknown.
|
|
92
|
+
expect(getKyvernoPolicyStatus({ spec: { rules: [{}, {}] } }))
|
|
93
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
94
|
+
expect(getKyvernoPolicyStatus({ spec: { rules: [{}] }, status: { conditions: [{ type: 'Ready', status: 'Unknown' }] } }))
|
|
95
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('getServiceEntryStatus', () => {
|
|
100
|
+
it('treats location as a classification, not reachability', () => {
|
|
101
|
+
// MESH_EXTERNAL/MESH_INTERNAL say where the host sits relative to the mesh.
|
|
102
|
+
// Nothing here establishes it resolves or answers.
|
|
103
|
+
expect(getServiceEntryStatus({ spec: { hosts: ['api.example.com'] } }))
|
|
104
|
+
.toMatchObject({ text: 'External', level: 'neutral' })
|
|
105
|
+
expect(getServiceEntryStatus({ spec: { hosts: ['a'], location: 'MESH_INTERNAL' } }))
|
|
106
|
+
.toMatchObject({ text: 'Internal', level: 'neutral' })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still flags an entry that selects nothing', () => {
|
|
110
|
+
expect(getServiceEntryStatus({ spec: {} })).toMatchObject({ text: 'No Hosts', level: 'unhealthy' })
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('getVirtualServiceStatus', () => {
|
|
115
|
+
it('does not call a hostless VirtualService broken', () => {
|
|
116
|
+
// Istio requires hosts to be empty on a delegate VirtualService, and this
|
|
117
|
+
// object cannot see whether a parent delegates to it.
|
|
118
|
+
expect(getVirtualServiceStatus({ spec: { http: [{ route: [{}] }] } }))
|
|
119
|
+
.toMatchObject({ text: 'No Hosts', level: 'unknown' })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not read a declaration as working routing', () => {
|
|
123
|
+
expect(getVirtualServiceStatus({ spec: { hosts: ['a'], http: [{ route: [{}] }] } }))
|
|
124
|
+
.toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('still flags hosts with nowhere to send them', () => {
|
|
128
|
+
expect(getVirtualServiceStatus({ spec: { hosts: ['a'] } }))
|
|
129
|
+
.toMatchObject({ text: 'No Routes', level: 'unhealthy' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('flags a routeless object even when it declares no hosts', () => {
|
|
133
|
+
// A delegate omits hosts, but it exists to supply routes.
|
|
134
|
+
expect(getVirtualServiceStatus({ spec: {} }))
|
|
135
|
+
.toMatchObject({ text: 'No Routes', level: 'unhealthy' })
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('getCNPGObjectStoreStatus', () => {
|
|
140
|
+
it('does not call a listed server recoverable with no recovery point', () => {
|
|
141
|
+
// Every timestamp on a RecoveryWindow is optional, so the server can be
|
|
142
|
+
// listed while holding nothing restorable.
|
|
143
|
+
expect(getCNPGObjectStoreStatus({ status: { serverRecoveryWindow: { pg: {} } } }))
|
|
144
|
+
.toMatchObject({ text: 'No recovery point', level: 'unknown' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('reports recoverable once a real point exists', () => {
|
|
148
|
+
expect(getCNPGObjectStoreStatus({
|
|
149
|
+
status: { serverRecoveryWindow: { pg: { firstRecoverabilityPoint: '2026-01-01T00:00:00Z' } } },
|
|
150
|
+
})).toMatchObject({ text: 'Recoverable', level: 'healthy' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still reports failing backups ahead of everything else', () => {
|
|
154
|
+
expect(getCNPGObjectStoreStatus({
|
|
155
|
+
status: { serverRecoveryWindow: { pg: { lastFailedBackupTime: '2026-02-01T00:00:00Z' } } },
|
|
156
|
+
})).toMatchObject({ text: 'Backups Failing', level: 'unhealthy' })
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -651,6 +651,81 @@ describe('colliding plurals — near-match API groups', () => {
|
|
|
651
651
|
})
|
|
652
652
|
})
|
|
653
653
|
|
|
654
|
+
describe('CAPI Machine kind collisions', () => {
|
|
655
|
+
const foreignApiVersion = 'extension.cluster.x-k8s.io/v1'
|
|
656
|
+
|
|
657
|
+
it.each([
|
|
658
|
+
['machines', 'Machine', 'Role'],
|
|
659
|
+
['machinesets', 'MachineSet', 'Delete Policy'],
|
|
660
|
+
])('routes a foreign %s CRD through the generic renderer only', (plural, kind, capiMarker) => {
|
|
661
|
+
const html = renderKind(plural, {
|
|
662
|
+
apiVersion: foreignApiVersion,
|
|
663
|
+
kind,
|
|
664
|
+
metadata: {
|
|
665
|
+
name: 'foreign',
|
|
666
|
+
namespace: 'default',
|
|
667
|
+
labels: { 'topology.cluster.x-k8s.io/owned': '' },
|
|
668
|
+
},
|
|
669
|
+
spec: { collisionProbe: COLLISION_PROBE },
|
|
670
|
+
status: { phase: 'provisioned' },
|
|
671
|
+
}, 'default')
|
|
672
|
+
|
|
673
|
+
expect(html).toContain(COLLISION_PROBE)
|
|
674
|
+
expect(html).not.toContain(capiMarker)
|
|
675
|
+
expect(html).not.toContain('Topology-controlled')
|
|
676
|
+
expect(getResourceStatus(plural, {
|
|
677
|
+
apiVersion: foreignApiVersion,
|
|
678
|
+
status: { phase: 'provisioned' },
|
|
679
|
+
})?.text).toBe('provisioned')
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it.each([
|
|
683
|
+
['machines', 'Machine', 'Role'],
|
|
684
|
+
['machinesets', 'MachineSet', 'Delete Policy'],
|
|
685
|
+
])('keeps exact-group CAPI %s resources on the dedicated path', (plural, kind, capiMarker) => {
|
|
686
|
+
const html = renderKind(plural, {
|
|
687
|
+
apiVersion: 'cluster.x-k8s.io/v1beta1',
|
|
688
|
+
kind,
|
|
689
|
+
metadata: {
|
|
690
|
+
name: 'capi',
|
|
691
|
+
namespace: 'default',
|
|
692
|
+
labels: { 'topology.cluster.x-k8s.io/owned': '' },
|
|
693
|
+
},
|
|
694
|
+
spec: { collisionProbe: COLLISION_PROBE },
|
|
695
|
+
status: { phase: 'provisioned' },
|
|
696
|
+
}, 'default')
|
|
697
|
+
|
|
698
|
+
expect(html).toContain(capiMarker)
|
|
699
|
+
expect(html).toContain('Topology-controlled')
|
|
700
|
+
expect(html).not.toContain(COLLISION_PROBE)
|
|
701
|
+
expect(getResourceStatus(plural, {
|
|
702
|
+
apiVersion: 'cluster.x-k8s.io/v1beta1',
|
|
703
|
+
status: { phase: 'provisioned' },
|
|
704
|
+
})?.text).toBe('Provisioned')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it.each([
|
|
708
|
+
['kubeadmcontrolplanes', 'KubeadmControlPlane', 'controlplane.cluster.x-k8s.io/v1beta1'],
|
|
709
|
+
['awsmanagedcontrolplanes', 'AWSManagedControlPlane', 'controlplane.cluster.x-k8s.io/v1beta2'],
|
|
710
|
+
['awsmanagedmachinepools', 'AWSManagedMachinePool', 'infrastructure.cluster.x-k8s.io/v1beta2'],
|
|
711
|
+
['awsmachines', 'AWSMachine', 'infrastructure.cluster.x-k8s.io/v1beta2'],
|
|
712
|
+
])('keeps the topology-owned warning for %s', (plural, kind, apiVersion) => {
|
|
713
|
+
const html = renderKind(plural, {
|
|
714
|
+
apiVersion,
|
|
715
|
+
kind,
|
|
716
|
+
metadata: {
|
|
717
|
+
name: 'topology-owned',
|
|
718
|
+
namespace: 'default',
|
|
719
|
+
labels: { 'topology.cluster.x-k8s.io/owned': '' },
|
|
720
|
+
},
|
|
721
|
+
spec: {},
|
|
722
|
+
status: {},
|
|
723
|
+
}, 'default')
|
|
724
|
+
|
|
725
|
+
expect(html).toContain('Topology-controlled')
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
654
729
|
describe('shared plurals — the status path collides too', () => {
|
|
655
730
|
// The renderer and the status badge are two separate switches over the same
|
|
656
731
|
// plural, and fixing one leaves the other reading a foreign CRD's conditions
|