@skyhook-io/k8s-ui 1.13.3 → 1.13.4
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/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getGatewayPolicyStatus } from './gateway-policy-status'
|
|
3
|
+
import { getGenericResourceStatus } from './generic-status'
|
|
4
|
+
|
|
5
|
+
const cond = (type: string, status: string, reason?: string, message?: string) => ({ type, status, reason, message })
|
|
6
|
+
const policy = (...ancestors: any[]) => ({ status: { ancestors } })
|
|
7
|
+
const anc = (...conditions: any[]) => ({ ancestorRef: { kind: 'Gateway', name: 'gw' }, conditions })
|
|
8
|
+
|
|
9
|
+
describe('Gateway API PolicyStatus', () => {
|
|
10
|
+
// Taken from a live EnvoyPatchPolicy. The controller accepted the policy and
|
|
11
|
+
// then could not apply it. Read positives-first — the way the generic ladder
|
|
12
|
+
// resolves conditions — this object is healthy, which is the whole reason it
|
|
13
|
+
// gets its own reader.
|
|
14
|
+
it('reports the failure on a policy that was accepted and then not applied', () => {
|
|
15
|
+
const p = policy(anc(cond('Accepted', 'True', 'Accepted'), cond('Programmed', 'False', 'ResourceNotFound')))
|
|
16
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ResourceNotFound', tone: 'unhealthy' })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('reports a plain accepted policy as healthy', () => {
|
|
20
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'True', 'Accepted')))))
|
|
21
|
+
.toMatchObject({ text: 'Accepted', tone: 'healthy' })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('reports a rejected policy with the controller reason', () => {
|
|
25
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False', 'Conflicted')))))
|
|
26
|
+
.toMatchObject({ text: 'Conflicted', tone: 'unhealthy' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('falls back to the condition type when the controller gave no reason', () => {
|
|
30
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False')))))
|
|
31
|
+
.toMatchObject({ text: 'Not Accepted', tone: 'unhealthy' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Envoy Gateway publishes this combination. A positives-first read returns
|
|
35
|
+
// healthy and the warning never surfaces.
|
|
36
|
+
it('does not let an accepted policy hide a warning', () => {
|
|
37
|
+
const p = policy(anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')))
|
|
38
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ShadowedRules', tone: 'degraded' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Accepted=False reads as "Not Accepted"; Warning=True means there IS a
|
|
42
|
+
// warning, so the same construction would invert it.
|
|
43
|
+
it('names a reason-less warning after the condition, not its negation', () => {
|
|
44
|
+
const p = policy(anc(cond('Accepted', 'True'), cond('Warning', 'True')))
|
|
45
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'Warning', tone: 'degraded' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('carries the controller message as the tooltip reason', () => {
|
|
49
|
+
const p = policy(anc(cond('Accepted', 'False', 'Invalid', 'spec.targetRef.kind is not supported')))
|
|
50
|
+
expect(getGatewayPolicyStatus(p)?.reason).toBe('spec.targetRef.kind is not supported')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('aggregating across ancestors', () => {
|
|
55
|
+
// A policy may attach to several Gateways with different outcomes. Collapsing
|
|
56
|
+
// to a bare "Accepted" would hide the one that rejected it.
|
|
57
|
+
it('reports a failure anywhere, and how widespread it is', () => {
|
|
58
|
+
const p = policy(
|
|
59
|
+
anc(cond('Accepted', 'True')),
|
|
60
|
+
anc(cond('Accepted', 'False', 'NotAllowed')),
|
|
61
|
+
anc(cond('Accepted', 'True')),
|
|
62
|
+
)
|
|
63
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (1/3)', tone: 'unhealthy' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not add a count when the policy attaches to one ancestor', () => {
|
|
67
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False', 'NotAllowed'))))?.text)
|
|
68
|
+
.toBe('NotAllowed')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Absence is not acceptance: an ancestor the controller has not answered for
|
|
72
|
+
// must not be counted as healthy just because its sibling was.
|
|
73
|
+
it('does not read an ancestor with no verdict as accepted', () => {
|
|
74
|
+
const p = policy(anc(cond('Accepted', 'True')), anc())
|
|
75
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'Pending (1/2)', tone: 'degraded' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('treats Unknown as undecided rather than as failure', () => {
|
|
79
|
+
const p = policy(anc(cond('Accepted', 'Unknown', 'Pending')))
|
|
80
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ tone: 'degraded' })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// One slot for both kinds of problem showed the warning's reason next to the
|
|
84
|
+
// failure's count and tone — the operator reads the warning as the failure.
|
|
85
|
+
it('reports the failure, not an earlier warning, when both are present', () => {
|
|
86
|
+
const p = policy(
|
|
87
|
+
anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')),
|
|
88
|
+
anc(cond('Accepted', 'False', 'NotAllowed')),
|
|
89
|
+
)
|
|
90
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (1/2)', tone: 'unhealthy' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('still reports the warning when nothing failed', () => {
|
|
94
|
+
const p = policy(
|
|
95
|
+
anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')),
|
|
96
|
+
anc(cond('Accepted', 'True')),
|
|
97
|
+
)
|
|
98
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ShadowedRules (1/2)', tone: 'degraded' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('is healthy only when every ancestor accepted', () => {
|
|
102
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'True')), anc(cond('Accepted', 'True')))))
|
|
103
|
+
.toMatchObject({ text: 'Accepted', tone: 'healthy' })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('shapes that are not a verdict', () => {
|
|
108
|
+
// Yields nothing rather than a verdict, so a policy that also publishes
|
|
109
|
+
// top-level conditions still gets read from those.
|
|
110
|
+
it('declines to judge a policy that attaches to nothing', () => {
|
|
111
|
+
expect(getGatewayPolicyStatus(policy())).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('ignores malformed ancestors rather than throwing', () => {
|
|
115
|
+
expect(() => getGatewayPolicyStatus({ status: { ancestors: [null, 'nope', 42] } })).not.toThrow()
|
|
116
|
+
expect(() => getGatewayPolicyStatus({ status: { ancestors: [{ conditions: 'nope' }] } })).not.toThrow()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('yields nothing when ancestors is absent or not an array', () => {
|
|
120
|
+
expect(getGatewayPolicyStatus({ status: { conditions: [] } })).toBeNull()
|
|
121
|
+
expect(getGatewayPolicyStatus({ status: { ancestors: {} } })).toBeNull()
|
|
122
|
+
expect(getGatewayPolicyStatus({ status: {} })).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('wiring into the generic ladder', () => {
|
|
127
|
+
it('resolves a policy through its own reader', () => {
|
|
128
|
+
const p = policy(anc(cond('Accepted', 'True', 'Accepted'), cond('Programmed', 'False', 'ResourceNotFound')))
|
|
129
|
+
expect(getGenericResourceStatus(p)).toMatchObject({ text: 'ResourceNotFound', tone: 'unhealthy' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// The dispatch is on shape, so it must not intercept an ordinary resource.
|
|
133
|
+
it('leaves a resource with top-level conditions alone', () => {
|
|
134
|
+
const r = { status: { conditions: [{ type: 'Ready', status: 'True' }] } }
|
|
135
|
+
expect(getGenericResourceStatus(r)).toMatchObject({ text: 'Ready', tone: 'healthy' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('leaves a phase-based resource alone', () => {
|
|
139
|
+
expect(getGenericResourceStatus({ status: { phase: 'Running' } })).toMatchObject({ text: 'Running', tone: 'healthy' })
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('condition vocabulary beyond Accepted', () => {
|
|
144
|
+
// GKE reports attachment with its own condition; Gateway API's
|
|
145
|
+
// BackendTLSPolicy requires ResolvedRefs as well as Accepted.
|
|
146
|
+
it.each([
|
|
147
|
+
['Attached', 'InvalidTarget'],
|
|
148
|
+
['ResolvedRefs', 'InvalidCACertificateRef'],
|
|
149
|
+
['Programmed', 'ResourceNotFound'],
|
|
150
|
+
])('treats %s=False as a failure', (type, reason) => {
|
|
151
|
+
const p = policy(anc(cond('Accepted', 'True'), cond(type, 'False', reason)))
|
|
152
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: reason, tone: 'unhealthy' })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// GEP-713 lists Reconciling as a legitimate state on the way to being
|
|
156
|
+
// applied. Painting ordinary convergence red teaches operators to ignore red.
|
|
157
|
+
it.each(['Reconciling', 'Pending'])('treats %s as in-flight, not failed', reason => {
|
|
158
|
+
const p = policy(anc(cond('Accepted', 'True'), cond('Programmed', 'False', reason)))
|
|
159
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: reason, tone: 'degraded' })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// GKE's healthy shape carries no Accepted condition at all. Checking only
|
|
163
|
+
// Accepted read it as pending, which is a policy that works reported amber.
|
|
164
|
+
it('accepts a verdict that is not spelled Accepted', () => {
|
|
165
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Attached', 'True')))))
|
|
166
|
+
.toMatchObject({ text: 'Attached', tone: 'healthy' })
|
|
167
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('Programmed', 'True')))))
|
|
168
|
+
.toMatchObject({ text: 'Programmed', tone: 'healthy' })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Refs resolving is a precondition, not a statement that the policy applies.
|
|
172
|
+
it('does not treat ResolvedRefs alone as a verdict', () => {
|
|
173
|
+
expect(getGatewayPolicyStatus(policy(anc(cond('ResolvedRefs', 'True')))))
|
|
174
|
+
.toMatchObject({ tone: 'degraded' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does not read a partially applied policy as success', () => {
|
|
178
|
+
const p = policy(anc(cond('Accepted', 'True'), cond('Programmed', 'True', 'PartiallyProgrammed')))
|
|
179
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'PartiallyProgrammed', tone: 'degraded' })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Reporting the first reason with a count claims every ancestor failed that
|
|
183
|
+
// way. When they differ, say how many failed instead of guessing why.
|
|
184
|
+
it('does not attribute one ancestor\'s reason to another', () => {
|
|
185
|
+
const p = policy(
|
|
186
|
+
anc(cond('Accepted', 'False', 'NotAllowed')),
|
|
187
|
+
anc(cond('Accepted', 'False', 'ResourceNotFound')),
|
|
188
|
+
anc(cond('Accepted', 'True')),
|
|
189
|
+
)
|
|
190
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: '2/3 failed', tone: 'unhealthy' })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// The badge says "2/3 failed"; the tooltip must say which Gateway failed
|
|
194
|
+
// how, or the operator digs through raw status for the names.
|
|
195
|
+
it('names each failed ancestor and its reason in the tooltip', () => {
|
|
196
|
+
const p = policy(
|
|
197
|
+
{ ancestorRef: { kind: 'Gateway', namespace: 'team-a', name: 'gw-a' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
|
|
198
|
+
{ ancestorRef: { kind: 'Gateway', name: 'gw-b' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
|
|
199
|
+
anc(cond('Accepted', 'True')),
|
|
200
|
+
)
|
|
201
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({
|
|
202
|
+
text: '2/3 failed',
|
|
203
|
+
tone: 'unhealthy',
|
|
204
|
+
reason: 'team-a/gw-a: NotAllowed; gw-b: ResourceNotFound',
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// GEP-713 keys ancestor status on ancestorRef + controllerName: the same
|
|
209
|
+
// Gateway appears once per controller, and a bare ns/name label would
|
|
210
|
+
// attribute both verdicts to one indistinguishable thing.
|
|
211
|
+
it('tells colliding labels apart by controller', () => {
|
|
212
|
+
const p = policy(
|
|
213
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-a.example', conditions: [cond('Accepted', 'False', 'NotAllowed')] },
|
|
214
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-b.example', conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
|
|
215
|
+
)
|
|
216
|
+
expect(getGatewayPolicyStatus(p)?.reason)
|
|
217
|
+
.toBe('team-a/gw (ctrl-a.example): NotAllowed; team-a/gw (ctrl-b.example): ResourceNotFound')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// sectionName and a non-Gateway kind are part of the identity and short
|
|
221
|
+
// enough to always carry.
|
|
222
|
+
it('carries section and kind in the label', () => {
|
|
223
|
+
const p = policy(
|
|
224
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw', sectionName: 'tls' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
|
|
225
|
+
{ ancestorRef: { kind: 'Service', namespace: 'team-a', name: 'svc' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
|
|
226
|
+
)
|
|
227
|
+
expect(getGatewayPolicyStatus(p)?.reason)
|
|
228
|
+
.toBe('team-a/gw:tls: NotAllowed; Service team-a/svc: ResourceNotFound')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// The collision that qualifies a controller may be with an ancestor that
|
|
232
|
+
// SUCCEEDED: two controllers on one Gateway, one failing, must still say
|
|
233
|
+
// which one failed.
|
|
234
|
+
it('names the controller when the collision is with a successful ancestor', () => {
|
|
235
|
+
const p = policy(
|
|
236
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-a.example', conditions: [cond('Accepted', 'False', 'NotAllowed')] },
|
|
237
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-b.example', conditions: [cond('Accepted', 'True')] },
|
|
238
|
+
{ ancestorRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
|
|
239
|
+
)
|
|
240
|
+
expect(getGatewayPolicyStatus(p)?.reason)
|
|
241
|
+
.toBe('team-a/gw (ctrl-a.example): NotAllowed; gw-b: ResourceNotFound')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// group and port are part of the composite key too: entries differing only
|
|
245
|
+
// by them must not render identically.
|
|
246
|
+
it('carries group and port in the label', () => {
|
|
247
|
+
const p = policy(
|
|
248
|
+
{ ancestorRef: { group: 'multicluster.x-k8s.io', kind: 'ServiceImport', namespace: 'team-a', name: 'svc' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
|
|
249
|
+
{ ancestorRef: { namespace: 'team-a', name: 'gw', port: 443 }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
|
|
250
|
+
)
|
|
251
|
+
expect(getGatewayPolicyStatus(p)?.reason)
|
|
252
|
+
.toBe('ServiceImport.multicluster.x-k8s.io team-a/svc: NotAllowed; team-a/gw:443: ResourceNotFound')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// Reasons may be 1024 chars and foreign CRDs need not honor the 16-ancestor
|
|
256
|
+
// cap, so the list is bounded rather than trusted.
|
|
257
|
+
it('caps the tooltip detail list', () => {
|
|
258
|
+
const p = policy(
|
|
259
|
+
...[1, 2, 3, 4, 5, 6].map(i => ({
|
|
260
|
+
ancestorRef: { name: `gw-${i}` },
|
|
261
|
+
conditions: [cond('Accepted', 'False', `R${i}`)],
|
|
262
|
+
})),
|
|
263
|
+
)
|
|
264
|
+
expect(getGatewayPolicyStatus(p)?.reason).toBe('gw-1: R1; gw-2: R2; gw-3: R3; gw-4: R4; +2 more')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('keeps the reason when every failure agrees', () => {
|
|
268
|
+
const p = policy(anc(cond('Accepted', 'False', 'NotAllowed')), anc(cond('Accepted', 'False', 'NotAllowed')))
|
|
269
|
+
expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (2/2)', tone: 'unhealthy' })
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('policies that also publish top-level conditions', () => {
|
|
274
|
+
// GCPBackendPolicy declares both status.ancestors and status.conditions.
|
|
275
|
+
// Dispatching on the shape alone suppressed the conditions entirely.
|
|
276
|
+
it('falls back to top-level conditions when the ancestors say nothing', () => {
|
|
277
|
+
const dual = { status: { ancestors: [], conditions: [{ type: 'Ready', status: 'False', reason: 'Invalid' }] } }
|
|
278
|
+
expect(getGenericResourceStatus(dual)).toMatchObject({ text: 'Invalid', tone: 'unhealthy' })
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('still prefers a verdict the ancestors did give', () => {
|
|
282
|
+
const dual = {
|
|
283
|
+
status: {
|
|
284
|
+
ancestors: [{ conditions: [cond('Accepted', 'False', 'NotAllowed')] }],
|
|
285
|
+
conditions: [{ type: 'Ready', status: 'True' }],
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
expect(getGenericResourceStatus(dual)).toMatchObject({ text: 'NotAllowed', tone: 'unhealthy' })
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { GenericStatus } from './generic-status'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gateway API policy attachment (GEP-713) reports status per *ancestor*, at
|
|
5
|
+
* `status.ancestors[].conditions`, not at `status.conditions` — so the generic
|
|
6
|
+
* ladder finds nothing and these kinds render "-".
|
|
7
|
+
*
|
|
8
|
+
* The shape is shared by every policy implementation: Gateway API's own
|
|
9
|
+
* BackendTLSPolicy and BackendLBPolicy, Envoy Gateway's SecurityPolicy /
|
|
10
|
+
* ClientTrafficPolicy / BackendTrafficPolicy / EnvoyPatchPolicy /
|
|
11
|
+
* EnvoyExtensionPolicy, and GKE's GCPBackendPolicy family. Dispatching on the
|
|
12
|
+
* shape rather than on a list of kinds means a policy from an implementation
|
|
13
|
+
* Radar has never heard of reads correctly too.
|
|
14
|
+
*
|
|
15
|
+
* Kept separate from the generic ladder rather than folded into it, because the
|
|
16
|
+
* ladder resolves positive conditions before negative ones and that order is
|
|
17
|
+
* wrong here: a policy is routinely `Accepted=True` *and* `Programmed=False`,
|
|
18
|
+
* which means the controller took ownership and then failed to apply it. Read
|
|
19
|
+
* positives-first, that object is healthy; it is not.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Conditions that describe whether the policy took effect. `Accepted` is
|
|
24
|
+
* GEP-713's own verdict; `Programmed` and `ResolvedRefs` are how Gateway API
|
|
25
|
+
* and its implementations report that a policy the controller took on could
|
|
26
|
+
* not actually be applied, and `Attached` is GKE's equivalent.
|
|
27
|
+
*/
|
|
28
|
+
const EFFECT_CONDITIONS = ['Accepted', 'Attached', 'Programmed', 'ResolvedRefs'] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The subset that is a verdict in its own right, so True means the policy took
|
|
32
|
+
* effect. `ResolvedRefs` is deliberately absent: refs resolving is a
|
|
33
|
+
* precondition, not a statement that the policy applies. Kept separate from
|
|
34
|
+
* EFFECT_CONDITIONS because a condition can be conclusive when False and say
|
|
35
|
+
* nothing when True — GKE reports a healthy policy as `Attached=True` with no
|
|
36
|
+
* `Accepted` at all, so checking only `Accepted` read it as pending.
|
|
37
|
+
*/
|
|
38
|
+
const VERDICT_CONDITIONS = ['Accepted', 'Attached', 'Programmed'] as const
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reasons that mean "not settled yet" rather than "failed". GEP-713 lists
|
|
42
|
+
* Reconciling as a legitimate state for a policy on its way to being applied,
|
|
43
|
+
* and rendering ordinary convergence as a red failure would train operators to
|
|
44
|
+
* ignore the colour.
|
|
45
|
+
*/
|
|
46
|
+
const TRANSIENT_REASONS = new Set(['Reconciling', 'Pending'])
|
|
47
|
+
|
|
48
|
+
/** A True condition can still be qualified: partial application is not success. */
|
|
49
|
+
const QUALIFIED_SUCCESS_REASONS = new Set(['PartiallyProgrammed'])
|
|
50
|
+
|
|
51
|
+
/** Conditions that mean trouble when True, mirroring the generic ladder's set. */
|
|
52
|
+
const NEGATIVE_CONDITIONS = new Set(['Degraded', 'Warning'])
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* GEP-713 keys an ancestor's status on the full ancestorRef plus the
|
|
56
|
+
* controllerName — the same Gateway can appear once per controller, section,
|
|
57
|
+
* or kind. The label carries the short parts outright; the controllerName is
|
|
58
|
+
* long, so it is appended only when two labels would otherwise collide.
|
|
59
|
+
*/
|
|
60
|
+
function ancestorLabel(ancestor: any): string {
|
|
61
|
+
const ref = ancestor?.ancestorRef
|
|
62
|
+
const name = typeof ref?.name === 'string' ? ref.name : ''
|
|
63
|
+
if (!name) return ''
|
|
64
|
+
const ns = typeof ref?.namespace === 'string' ? ref.namespace : ''
|
|
65
|
+
const kind = typeof ref?.kind === 'string' ? ref.kind : ''
|
|
66
|
+
const group = typeof ref?.group === 'string' ? ref.group : ''
|
|
67
|
+
const section = typeof ref?.sectionName === 'string' ? ref.sectionName : ''
|
|
68
|
+
const port = typeof ref?.port === 'number' ? ref.port : null
|
|
69
|
+
let label = ns ? `${ns}/${name}` : name
|
|
70
|
+
let prefix = kind && kind !== 'Gateway' ? kind : ''
|
|
71
|
+
if (group && group !== 'gateway.networking.k8s.io') prefix = prefix ? `${prefix}.${group}` : group
|
|
72
|
+
if (prefix) label = `${prefix} ${label}`
|
|
73
|
+
if (section) label += `:${section}`
|
|
74
|
+
if (port !== null) label += `:${port}`
|
|
75
|
+
return label
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reasons are usually short CamelCase tokens, but the API allows 1024 chars
|
|
80
|
+
* and this reader accepts any CRD with an ancestors array, so the list is
|
|
81
|
+
* capped rather than trusted to stay small.
|
|
82
|
+
*/
|
|
83
|
+
const MAX_FAILURE_DETAILS = 4
|
|
84
|
+
|
|
85
|
+
function renderFailureDetails(
|
|
86
|
+
failures: { label: string; controller: string; problem: string }[],
|
|
87
|
+
// Collisions are counted across every ancestor, not just the failed ones: a
|
|
88
|
+
// failure and a success for the same Gateway under different controllers
|
|
89
|
+
// must still say which controller failed.
|
|
90
|
+
labelCounts: Map<string, number>,
|
|
91
|
+
): string {
|
|
92
|
+
const entries = failures.map(f => {
|
|
93
|
+
let label = f.label
|
|
94
|
+
if (label && (labelCounts.get(label) ?? 0) > 1 && f.controller) label += ` (${f.controller})`
|
|
95
|
+
return label ? `${label}: ${f.problem}` : f.problem
|
|
96
|
+
})
|
|
97
|
+
const shown = entries.slice(0, MAX_FAILURE_DETAILS)
|
|
98
|
+
const hidden = entries.length - shown.length
|
|
99
|
+
return hidden > 0 ? `${shown.join('; ')}; +${hidden} more` : shown.join('; ')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function conditionsOf(ancestor: any): any[] {
|
|
103
|
+
return Array.isArray(ancestor?.conditions) ? ancestor.conditions : []
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The controller's reason where it gave one — it names the actual failure
|
|
108
|
+
* (ResourceNotFound, Conflicted, NotAllowed) where the type does not.
|
|
109
|
+
*
|
|
110
|
+
* The fallback has to follow the condition's polarity. `Accepted=False` reads
|
|
111
|
+
* as "Not Accepted", but `Warning=True` means there *is* a warning, so the same
|
|
112
|
+
* construction would render "Not Warning" and inverts the meaning.
|
|
113
|
+
*
|
|
114
|
+
* Spelled with a space where the Go reader behind MCP writes "NotAccepted",
|
|
115
|
+
* matching each side's existing convention. Whenever a controller supplies a
|
|
116
|
+
* reason the two sides are identical; they part ways only here and on mixed
|
|
117
|
+
* failures, where the per-ancestor detail goes to the tooltip while MCP,
|
|
118
|
+
* having no tooltip, carries it in the text.
|
|
119
|
+
*/
|
|
120
|
+
function problemText(cond: any, trueMeansTrouble = false): string {
|
|
121
|
+
const reason = typeof cond?.reason === 'string' ? cond.reason.trim() : ''
|
|
122
|
+
if (reason) return reason
|
|
123
|
+
const type = typeof cond?.type === 'string' ? cond.type.trim() : ''
|
|
124
|
+
if (!type) return 'Failed'
|
|
125
|
+
return trueMeansTrouble ? type : `Not ${type}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Status for one policy, aggregated across the Gateways it attaches to.
|
|
130
|
+
*
|
|
131
|
+
* A policy may attach to several ancestors with different outcomes, so a
|
|
132
|
+
* failure anywhere wins and the count says how widespread it is — collapsing to
|
|
133
|
+
* a bare "Accepted" would hide the Gateway that rejected it.
|
|
134
|
+
*/
|
|
135
|
+
export function getGatewayPolicyStatus(resource: any): GenericStatus | null {
|
|
136
|
+
const ancestors = resource?.status?.ancestors
|
|
137
|
+
if (!Array.isArray(ancestors)) return null
|
|
138
|
+
|
|
139
|
+
// Null rather than a verdict, so the caller can fall back to any top-level
|
|
140
|
+
// conditions the object also carries. A policy that attaches to nothing and
|
|
141
|
+
// says nothing else is left to render as absent, because "not attached" and
|
|
142
|
+
// "not reconciled yet" are indistinguishable from here.
|
|
143
|
+
if (ancestors.length === 0) return null
|
|
144
|
+
|
|
145
|
+
let failed = 0
|
|
146
|
+
let degraded = 0
|
|
147
|
+
let accepted = 0
|
|
148
|
+
// Tracked apart, not in one slot: a warning on the first ancestor and a
|
|
149
|
+
// failure on the second would otherwise show the warning's text alongside
|
|
150
|
+
// the failure's count and tone, reading as though the warning was the
|
|
151
|
+
// failure.
|
|
152
|
+
let failure: { text: string; reason?: string } | null = null
|
|
153
|
+
let warning: { text: string; reason?: string } | null = null
|
|
154
|
+
// Ancestors can fail for different reasons. Reporting the first one with a
|
|
155
|
+
// count claims they all failed that way.
|
|
156
|
+
const failureReasons = new Set<string>()
|
|
157
|
+
// Which Gateway failed with what, for the tooltip: "2/3 failed" without it
|
|
158
|
+
// sends the operator digging through raw status for the names.
|
|
159
|
+
const failureDetails: { label: string; controller: string; problem: string }[] = []
|
|
160
|
+
const ancestorLabelCounts = new Map<string, number>()
|
|
161
|
+
let acceptedAs: string | null = null
|
|
162
|
+
|
|
163
|
+
for (const ancestor of ancestors) {
|
|
164
|
+
const label = ancestorLabel(ancestor)
|
|
165
|
+
if (label) ancestorLabelCounts.set(label, (ancestorLabelCounts.get(label) ?? 0) + 1)
|
|
166
|
+
const conditions = conditionsOf(ancestor)
|
|
167
|
+
|
|
168
|
+
const broken = conditions.find(
|
|
169
|
+
(c: any) => EFFECT_CONDITIONS.includes(c?.type) && c?.status === 'False',
|
|
170
|
+
)
|
|
171
|
+
if (broken) {
|
|
172
|
+
const reason = typeof broken?.reason === 'string' ? broken.reason.trim() : ''
|
|
173
|
+
// A controller still working towards the desired state is not a failure.
|
|
174
|
+
if (TRANSIENT_REASONS.has(reason)) {
|
|
175
|
+
degraded++
|
|
176
|
+
warning ??= { text: reason, reason: broken?.message }
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
failed++
|
|
180
|
+
failure ??= { text: problemText(broken), reason: broken?.message }
|
|
181
|
+
failureReasons.add(problemText(broken))
|
|
182
|
+
failureDetails.push({
|
|
183
|
+
label,
|
|
184
|
+
controller: typeof ancestor?.controllerName === 'string' ? ancestor.controllerName : '',
|
|
185
|
+
problem: problemText(broken),
|
|
186
|
+
})
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const warned = conditions.find((c: any) => NEGATIVE_CONDITIONS.has(c?.type) && c?.status === 'True')
|
|
191
|
+
if (warned) {
|
|
192
|
+
degraded++
|
|
193
|
+
warning ??= { text: problemText(warned, true), reason: warned?.message }
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A qualified success is not success: PartiallyProgrammed means some of the
|
|
198
|
+
// policy landed and some did not.
|
|
199
|
+
const qualified = conditions.find(
|
|
200
|
+
(c: any) => EFFECT_CONDITIONS.includes(c?.type) && c?.status === 'True' &&
|
|
201
|
+
QUALIFIED_SUCCESS_REASONS.has(typeof c?.reason === 'string' ? c.reason.trim() : ''),
|
|
202
|
+
)
|
|
203
|
+
if (qualified) {
|
|
204
|
+
degraded++
|
|
205
|
+
warning ??= { text: qualified.reason.trim(), reason: qualified?.message }
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const verdict = conditions.find(
|
|
210
|
+
(c: any) => VERDICT_CONDITIONS.includes(c?.type) && c?.status === 'True',
|
|
211
|
+
)
|
|
212
|
+
if (verdict) {
|
|
213
|
+
accepted++
|
|
214
|
+
// Named after the condition that granted it, so a GKE policy reads
|
|
215
|
+
// "Attached" where an Envoy one reads "Accepted".
|
|
216
|
+
acceptedAs ??= verdict.type
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const total = ancestors.length
|
|
221
|
+
const scope = (n: number) => (total > 1 ? ` (${n}/${total})` : '')
|
|
222
|
+
|
|
223
|
+
if (failed > 0 && failure) {
|
|
224
|
+
if (failureReasons.size > 1) {
|
|
225
|
+
return { text: `${failed}/${total} failed`, tone: 'unhealthy', reason: renderFailureDetails(failureDetails, ancestorLabelCounts) }
|
|
226
|
+
}
|
|
227
|
+
return { text: `${failure.text}${scope(failed)}`, tone: 'unhealthy', reason: failure.reason }
|
|
228
|
+
}
|
|
229
|
+
if (degraded > 0 && warning) {
|
|
230
|
+
return { text: `${warning.text}${scope(degraded)}`, tone: 'degraded', reason: warning.reason }
|
|
231
|
+
}
|
|
232
|
+
if (accepted === total) return { text: acceptedAs ?? 'Accepted', tone: 'healthy' }
|
|
233
|
+
|
|
234
|
+
// Some ancestor published neither an outcome nor a failure — the controller
|
|
235
|
+
// has seen the policy but not finished with it.
|
|
236
|
+
return { text: `Pending${scope(total - accepted)}`, tone: 'degraded' }
|
|
237
|
+
}
|