@skyhook-io/k8s-ui 1.6.1 → 1.6.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 +6 -1
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +338 -57
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isManagedResource,
|
|
4
|
+
isComposite,
|
|
5
|
+
isClaim,
|
|
6
|
+
getCrossplaneStatus,
|
|
7
|
+
getCrossplaneStatusReason,
|
|
8
|
+
getProviderStatus,
|
|
9
|
+
getProviderConfigStatus,
|
|
10
|
+
getProviderConfigRef,
|
|
11
|
+
getCrossplaneResourceRefs,
|
|
12
|
+
getExternalName,
|
|
13
|
+
getComposingXRRef,
|
|
14
|
+
isCrossplanePaused,
|
|
15
|
+
} from './resource-utils-crossplane'
|
|
16
|
+
|
|
17
|
+
describe('isManagedResource', () => {
|
|
18
|
+
it('returns true when spec.providerConfigRef is set (v1)', () => {
|
|
19
|
+
const resource = {
|
|
20
|
+
spec: { providerConfigRef: { name: 'default' } },
|
|
21
|
+
} as const
|
|
22
|
+
expect(isManagedResource(resource)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns true when spec.crossplane.providerConfigRef is set (v2)', () => {
|
|
26
|
+
const resource = {
|
|
27
|
+
spec: { crossplane: { providerConfigRef: { name: 'default' } } },
|
|
28
|
+
} as const
|
|
29
|
+
expect(isManagedResource(resource)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns false when no providerConfigRef exists at either path', () => {
|
|
33
|
+
expect(isManagedResource({ spec: {} } as const)).toBe(false)
|
|
34
|
+
expect(isManagedResource({ spec: { crossplane: {} } } as const)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false when spec is missing or null', () => {
|
|
38
|
+
expect(isManagedResource({} as const)).toBe(false)
|
|
39
|
+
expect(isManagedResource({ spec: null } as const)).toBe(false)
|
|
40
|
+
expect(isManagedResource(null as any)).toBe(false)
|
|
41
|
+
expect(isManagedResource(undefined as any)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('isComposite', () => {
|
|
46
|
+
it('returns true when spec.resourceRefs is an array (v1)', () => {
|
|
47
|
+
const resource = {
|
|
48
|
+
spec: {
|
|
49
|
+
resourceRefs: [
|
|
50
|
+
{ apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
} as const
|
|
54
|
+
expect(isComposite(resource)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns true when spec.crossplane.resourceRefs is an array (v2)', () => {
|
|
58
|
+
const resource = {
|
|
59
|
+
spec: {
|
|
60
|
+
crossplane: {
|
|
61
|
+
resourceRefs: [
|
|
62
|
+
{ apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
} as const
|
|
67
|
+
expect(isComposite(resource)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns true when resourceRefs is an empty array', () => {
|
|
71
|
+
expect(isComposite({ spec: { resourceRefs: [] } } as const)).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns false when isManagedResource is true (MR with providerConfigRef wins)', () => {
|
|
75
|
+
const resource = {
|
|
76
|
+
spec: {
|
|
77
|
+
providerConfigRef: { name: 'default' },
|
|
78
|
+
resourceRefs: [{ apiVersion: 'v1', kind: 'Foo', name: 'bar' }],
|
|
79
|
+
},
|
|
80
|
+
} as const
|
|
81
|
+
expect(isComposite(resource)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns false when resourceRefs is missing', () => {
|
|
85
|
+
expect(isComposite({ spec: {} } as const)).toBe(false)
|
|
86
|
+
expect(isComposite({} as const)).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns false when resourceRefs is not an array', () => {
|
|
90
|
+
expect(isComposite({ spec: { resourceRefs: 'oops' } } as const)).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('isClaim', () => {
|
|
95
|
+
it('returns true when spec.resourceRef and spec.compositionRef both exist (v1)', () => {
|
|
96
|
+
const resource = {
|
|
97
|
+
spec: {
|
|
98
|
+
resourceRef: { apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'bound-xr' },
|
|
99
|
+
compositionRef: { name: 'postgres-aws' },
|
|
100
|
+
},
|
|
101
|
+
} as const
|
|
102
|
+
expect(isClaim(resource)).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns false when only resourceRef exists', () => {
|
|
106
|
+
const resource = {
|
|
107
|
+
spec: {
|
|
108
|
+
resourceRef: { apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'bound-xr' },
|
|
109
|
+
},
|
|
110
|
+
} as const
|
|
111
|
+
expect(isClaim(resource)).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns false when only compositionRef exists', () => {
|
|
115
|
+
expect(isClaim({ spec: { compositionRef: { name: 'foo' } } } as const)).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns false for a Managed Resource even if both refs were set somehow', () => {
|
|
119
|
+
const resource = {
|
|
120
|
+
spec: {
|
|
121
|
+
providerConfigRef: { name: 'default' },
|
|
122
|
+
resourceRef: { kind: 'Foo', name: 'bar' },
|
|
123
|
+
compositionRef: { name: 'baz' },
|
|
124
|
+
},
|
|
125
|
+
} as const
|
|
126
|
+
expect(isClaim(resource)).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns false when spec is missing', () => {
|
|
130
|
+
expect(isClaim({} as const)).toBe(false)
|
|
131
|
+
expect(isClaim(null as any)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('getCrossplaneStatus', () => {
|
|
136
|
+
it('returns Ready/healthy when Ready=True and Synced=True', () => {
|
|
137
|
+
const resource = {
|
|
138
|
+
status: {
|
|
139
|
+
conditions: [
|
|
140
|
+
{ type: 'Ready', status: 'True' },
|
|
141
|
+
{ type: 'Synced', status: 'True' },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
} as const
|
|
145
|
+
const badge = getCrossplaneStatus(resource)
|
|
146
|
+
expect(badge.text).toBe('Ready')
|
|
147
|
+
expect(badge.level).toBe('healthy')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns Out of sync/alert when Synced=False (regardless of Ready)', () => {
|
|
151
|
+
const resource = {
|
|
152
|
+
status: {
|
|
153
|
+
conditions: [
|
|
154
|
+
{ type: 'Ready', status: 'True' },
|
|
155
|
+
{ type: 'Synced', status: 'False', reason: 'ReconcileError', message: 'bad spec' },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
} as const
|
|
159
|
+
const badge = getCrossplaneStatus(resource)
|
|
160
|
+
expect(badge.text).toBe('Out of sync')
|
|
161
|
+
expect(badge.level).toBe('alert')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('returns Ready.reason/unhealthy when Ready=False and Synced is not False', () => {
|
|
165
|
+
const resource = {
|
|
166
|
+
status: {
|
|
167
|
+
conditions: [
|
|
168
|
+
{ type: 'Ready', status: 'False', reason: 'Creating', message: 'still creating' },
|
|
169
|
+
{ type: 'Synced', status: 'True' },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
} as const
|
|
173
|
+
const badge = getCrossplaneStatus(resource)
|
|
174
|
+
expect(badge.text).toBe('Creating')
|
|
175
|
+
expect(badge.level).toBe('unhealthy')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('falls back to "Not ready" when Ready=False has no reason', () => {
|
|
179
|
+
const resource = {
|
|
180
|
+
status: {
|
|
181
|
+
conditions: [
|
|
182
|
+
{ type: 'Ready', status: 'False' },
|
|
183
|
+
{ type: 'Synced', status: 'True' },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
} as const
|
|
187
|
+
const badge = getCrossplaneStatus(resource)
|
|
188
|
+
expect(badge.text).toBe('Not ready')
|
|
189
|
+
expect(badge.level).toBe('unhealthy')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('returns Provisioning/degraded when Ready=Unknown', () => {
|
|
193
|
+
const resource = {
|
|
194
|
+
status: {
|
|
195
|
+
conditions: [
|
|
196
|
+
{ type: 'Ready', status: 'Unknown' },
|
|
197
|
+
{ type: 'Synced', status: 'True' },
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
} as const
|
|
201
|
+
const badge = getCrossplaneStatus(resource)
|
|
202
|
+
expect(badge.text).toBe('Provisioning')
|
|
203
|
+
expect(badge.level).toBe('degraded')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('returns Pending/neutral when no Ready or Synced conditions exist', () => {
|
|
207
|
+
expect(getCrossplaneStatus({} as const)).toMatchObject({
|
|
208
|
+
text: 'Pending',
|
|
209
|
+
level: 'neutral',
|
|
210
|
+
})
|
|
211
|
+
expect(getCrossplaneStatus({ status: { conditions: [] } } as const)).toMatchObject({
|
|
212
|
+
text: 'Pending',
|
|
213
|
+
level: 'neutral',
|
|
214
|
+
})
|
|
215
|
+
expect(
|
|
216
|
+
getCrossplaneStatus({
|
|
217
|
+
status: { conditions: [{ type: 'Other', status: 'True' }] },
|
|
218
|
+
} as const),
|
|
219
|
+
).toMatchObject({ text: 'Pending', level: 'neutral' })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns Terminating/degraded when deletionTimestamp is set', () => {
|
|
223
|
+
const resource = {
|
|
224
|
+
metadata: { deletionTimestamp: '2026-05-18T00:00:00Z' },
|
|
225
|
+
status: {
|
|
226
|
+
conditions: [
|
|
227
|
+
{ type: 'Ready', status: 'True' },
|
|
228
|
+
{ type: 'Synced', status: 'True' },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
} as const
|
|
232
|
+
const badge = getCrossplaneStatus(resource)
|
|
233
|
+
expect(badge.text).toBe('Terminating')
|
|
234
|
+
expect(badge.level).toBe('degraded')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('getCrossplaneStatusReason', () => {
|
|
239
|
+
it('returns the Synced=False message when present (Synced wins over Ready)', () => {
|
|
240
|
+
const resource = {
|
|
241
|
+
status: {
|
|
242
|
+
conditions: [
|
|
243
|
+
{ type: 'Ready', status: 'False', message: 'ready message' },
|
|
244
|
+
{ type: 'Synced', status: 'False', message: 'synced message' },
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
} as const
|
|
248
|
+
expect(getCrossplaneStatusReason(resource)).toBe('synced message')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns the Ready=False message when Synced is True', () => {
|
|
252
|
+
const resource = {
|
|
253
|
+
status: {
|
|
254
|
+
conditions: [
|
|
255
|
+
{ type: 'Ready', status: 'False', message: 'pod crashlooping' },
|
|
256
|
+
{ type: 'Synced', status: 'True' },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
} as const
|
|
260
|
+
expect(getCrossplaneStatusReason(resource)).toBe('pod crashlooping')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('falls back to the reason when message is empty', () => {
|
|
264
|
+
const resource = {
|
|
265
|
+
status: {
|
|
266
|
+
conditions: [
|
|
267
|
+
{ type: 'Synced', status: 'False', reason: 'ReconcileError', message: '' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
} as const
|
|
271
|
+
expect(getCrossplaneStatusReason(resource)).toBe('ReconcileError')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('falls back to Ready.reason when Synced is True and Ready has no message', () => {
|
|
275
|
+
const resource = {
|
|
276
|
+
status: {
|
|
277
|
+
conditions: [
|
|
278
|
+
{ type: 'Ready', status: 'False', reason: 'Creating' },
|
|
279
|
+
{ type: 'Synced', status: 'True' },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
} as const
|
|
283
|
+
expect(getCrossplaneStatusReason(resource)).toBe('Creating')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('returns null when all conditions are True', () => {
|
|
287
|
+
const resource = {
|
|
288
|
+
status: {
|
|
289
|
+
conditions: [
|
|
290
|
+
{ type: 'Ready', status: 'True' },
|
|
291
|
+
{ type: 'Synced', status: 'True' },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
} as const
|
|
295
|
+
expect(getCrossplaneStatusReason(resource)).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('returns null when no conditions exist', () => {
|
|
299
|
+
expect(getCrossplaneStatusReason({} as const)).toBeNull()
|
|
300
|
+
expect(getCrossplaneStatusReason({ status: {} } as const)).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('getProviderStatus', () => {
|
|
305
|
+
it('returns Healthy/healthy when Installed=True and Healthy=True', () => {
|
|
306
|
+
const resource = {
|
|
307
|
+
status: {
|
|
308
|
+
conditions: [
|
|
309
|
+
{ type: 'Installed', status: 'True' },
|
|
310
|
+
{ type: 'Healthy', status: 'True' },
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
} as const
|
|
314
|
+
const badge = getProviderStatus(resource)
|
|
315
|
+
expect(badge.text).toBe('Healthy')
|
|
316
|
+
expect(badge.level).toBe('healthy')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('returns Installed=False reason as unhealthy', () => {
|
|
320
|
+
const resource = {
|
|
321
|
+
status: {
|
|
322
|
+
conditions: [
|
|
323
|
+
{ type: 'Installed', status: 'False', reason: 'ImagePullBackOff' },
|
|
324
|
+
{ type: 'Healthy', status: 'True' },
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
} as const
|
|
328
|
+
const badge = getProviderStatus(resource)
|
|
329
|
+
expect(badge.text).toBe('ImagePullBackOff')
|
|
330
|
+
expect(badge.level).toBe('unhealthy')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('falls back to "Install failed" when Installed=False has no reason', () => {
|
|
334
|
+
const resource = {
|
|
335
|
+
status: { conditions: [{ type: 'Installed', status: 'False' }] },
|
|
336
|
+
} as const
|
|
337
|
+
expect(getProviderStatus(resource)).toMatchObject({
|
|
338
|
+
text: 'Install failed',
|
|
339
|
+
level: 'unhealthy',
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('returns alert when Healthy=False and Installed is not False', () => {
|
|
344
|
+
const resource = {
|
|
345
|
+
status: {
|
|
346
|
+
conditions: [
|
|
347
|
+
{ type: 'Installed', status: 'True' },
|
|
348
|
+
{ type: 'Healthy', status: 'False', reason: 'CrashLoopBackOff' },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
} as const
|
|
352
|
+
const badge = getProviderStatus(resource)
|
|
353
|
+
expect(badge.text).toBe('CrashLoopBackOff')
|
|
354
|
+
expect(badge.level).toBe('alert')
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('returns Installed/degraded when only Installed=True (no Healthy condition)', () => {
|
|
358
|
+
const resource = {
|
|
359
|
+
status: { conditions: [{ type: 'Installed', status: 'True' }] },
|
|
360
|
+
} as const
|
|
361
|
+
const badge = getProviderStatus(resource)
|
|
362
|
+
expect(badge.text).toBe('Installed')
|
|
363
|
+
expect(badge.level).toBe('degraded')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('returns Terminating/degraded when deletionTimestamp is set', () => {
|
|
367
|
+
const resource = {
|
|
368
|
+
metadata: { deletionTimestamp: '2026-05-18T00:00:00Z' },
|
|
369
|
+
status: {
|
|
370
|
+
conditions: [
|
|
371
|
+
{ type: 'Installed', status: 'True' },
|
|
372
|
+
{ type: 'Healthy', status: 'True' },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
} as const
|
|
376
|
+
expect(getProviderStatus(resource)).toMatchObject({
|
|
377
|
+
text: 'Terminating',
|
|
378
|
+
level: 'degraded',
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
describe('getProviderConfigStatus', () => {
|
|
384
|
+
it('returns "{n} in use"/healthy when status.users > 0', () => {
|
|
385
|
+
const resource = { status: { users: 3 } } as const
|
|
386
|
+
const badge = getProviderConfigStatus(resource)
|
|
387
|
+
expect(badge.text).toBe('3 in use')
|
|
388
|
+
expect(badge.level).toBe('healthy')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('returns Idle/neutral when status.users is 0', () => {
|
|
392
|
+
const resource = { status: { users: 0 } } as const
|
|
393
|
+
const badge = getProviderConfigStatus(resource)
|
|
394
|
+
expect(badge.text).toBe('Idle')
|
|
395
|
+
expect(badge.level).toBe('neutral')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('returns Idle/neutral when status.users is missing', () => {
|
|
399
|
+
expect(getProviderConfigStatus({} as const)).toMatchObject({
|
|
400
|
+
text: 'Idle',
|
|
401
|
+
level: 'neutral',
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('returns Ready=False reason as unhealthy (takes precedence over users count)', () => {
|
|
406
|
+
const resource = {
|
|
407
|
+
status: {
|
|
408
|
+
users: 5,
|
|
409
|
+
conditions: [{ type: 'Ready', status: 'False', reason: 'AuthFailure' }],
|
|
410
|
+
},
|
|
411
|
+
} as const
|
|
412
|
+
const badge = getProviderConfigStatus(resource)
|
|
413
|
+
expect(badge.text).toBe('AuthFailure')
|
|
414
|
+
expect(badge.level).toBe('unhealthy')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('falls back to "Not ready" when Ready=False has no reason', () => {
|
|
418
|
+
const resource = {
|
|
419
|
+
status: { conditions: [{ type: 'Ready', status: 'False' }] },
|
|
420
|
+
} as const
|
|
421
|
+
expect(getProviderConfigStatus(resource)).toMatchObject({
|
|
422
|
+
text: 'Not ready',
|
|
423
|
+
level: 'unhealthy',
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('getProviderConfigRef', () => {
|
|
429
|
+
it('reads spec.providerConfigRef (v1)', () => {
|
|
430
|
+
const resource = {
|
|
431
|
+
spec: { providerConfigRef: { name: 'aws-default', kind: 'ProviderConfig' } },
|
|
432
|
+
} as const
|
|
433
|
+
expect(getProviderConfigRef(resource)).toEqual({
|
|
434
|
+
name: 'aws-default',
|
|
435
|
+
kind: 'ProviderConfig',
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('reads spec.crossplane.providerConfigRef (v2) and prefers it over v1', () => {
|
|
440
|
+
const resource = {
|
|
441
|
+
spec: {
|
|
442
|
+
providerConfigRef: { name: 'v1-name' },
|
|
443
|
+
crossplane: { providerConfigRef: { name: 'v2-name', kind: 'ProviderConfig' } },
|
|
444
|
+
},
|
|
445
|
+
} as const
|
|
446
|
+
expect(getProviderConfigRef(resource)).toEqual({
|
|
447
|
+
name: 'v2-name',
|
|
448
|
+
kind: 'ProviderConfig',
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('returns null when no providerConfigRef exists', () => {
|
|
453
|
+
expect(getProviderConfigRef({ spec: {} } as const)).toBeNull()
|
|
454
|
+
expect(getProviderConfigRef({} as const)).toBeNull()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('returns null when providerConfigRef has no name', () => {
|
|
458
|
+
expect(
|
|
459
|
+
getProviderConfigRef({ spec: { providerConfigRef: { kind: 'ProviderConfig' } } } as const),
|
|
460
|
+
).toBeNull()
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('getCrossplaneResourceRefs', () => {
|
|
465
|
+
it('returns refs from spec.resourceRefs (v1)', () => {
|
|
466
|
+
const resource = {
|
|
467
|
+
spec: {
|
|
468
|
+
resourceRefs: [
|
|
469
|
+
{ apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
|
|
470
|
+
{ apiVersion: 'ec2.aws.upbound.io/v1beta1', kind: 'VPC', name: 'vpc-x' },
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
} as const
|
|
474
|
+
expect(getCrossplaneResourceRefs(resource)).toHaveLength(2)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('returns refs from spec.crossplane.resourceRefs (v2) and prefers it over v1', () => {
|
|
478
|
+
const resource = {
|
|
479
|
+
spec: {
|
|
480
|
+
resourceRefs: [{ apiVersion: 'a/v1', kind: 'A', name: 'a-v1' }],
|
|
481
|
+
crossplane: {
|
|
482
|
+
resourceRefs: [{ apiVersion: 'b/v1', kind: 'B', name: 'b-v2' }],
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
} as const
|
|
486
|
+
const refs = getCrossplaneResourceRefs(resource)
|
|
487
|
+
expect(refs).toHaveLength(1)
|
|
488
|
+
expect(refs[0].name).toBe('b-v2')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('filters out entries missing kind or name', () => {
|
|
492
|
+
const resource = {
|
|
493
|
+
spec: {
|
|
494
|
+
resourceRefs: [
|
|
495
|
+
{ apiVersion: 'v1', kind: 'A', name: 'good' },
|
|
496
|
+
{ apiVersion: 'v1', kind: 'B' },
|
|
497
|
+
{ apiVersion: 'v1', name: 'no-kind' },
|
|
498
|
+
{},
|
|
499
|
+
null,
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
} as const
|
|
503
|
+
const refs = getCrossplaneResourceRefs(resource)
|
|
504
|
+
expect(refs).toHaveLength(1)
|
|
505
|
+
expect(refs[0].name).toBe('good')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('returns empty array when resourceRefs is missing or not an array', () => {
|
|
509
|
+
expect(getCrossplaneResourceRefs({} as const)).toEqual([])
|
|
510
|
+
expect(getCrossplaneResourceRefs({ spec: {} } as const)).toEqual([])
|
|
511
|
+
expect(getCrossplaneResourceRefs({ spec: { resourceRefs: 'oops' } } as const)).toEqual([])
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
describe('getExternalName', () => {
|
|
516
|
+
it('reads the crossplane.io/external-name annotation', () => {
|
|
517
|
+
const resource = {
|
|
518
|
+
metadata: { annotations: { 'crossplane.io/external-name': 'arn:aws:rds:db-x' } },
|
|
519
|
+
} as const
|
|
520
|
+
expect(getExternalName(resource)).toBe('arn:aws:rds:db-x')
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('returns null when the annotation is missing', () => {
|
|
524
|
+
expect(getExternalName({ metadata: { annotations: {} } } as const)).toBeNull()
|
|
525
|
+
expect(getExternalName({ metadata: {} } as const)).toBeNull()
|
|
526
|
+
expect(getExternalName({} as const)).toBeNull()
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe('getComposingXRRef', () => {
|
|
531
|
+
it('returns the controller=true ownerReference', () => {
|
|
532
|
+
const resource = {
|
|
533
|
+
metadata: {
|
|
534
|
+
ownerReferences: [
|
|
535
|
+
{ apiVersion: 'example.io/v1', kind: 'XOther', name: 'not-controller', controller: false },
|
|
536
|
+
{ apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'the-xr', controller: true },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
} as const
|
|
540
|
+
expect(getComposingXRRef(resource)).toEqual({
|
|
541
|
+
apiVersion: 'example.io/v1',
|
|
542
|
+
kind: 'XPostgres',
|
|
543
|
+
name: 'the-xr',
|
|
544
|
+
})
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('falls back to refs[0] when no ownerReference has controller=true', () => {
|
|
548
|
+
const resource = {
|
|
549
|
+
metadata: {
|
|
550
|
+
ownerReferences: [
|
|
551
|
+
{ apiVersion: 'example.io/v1', kind: 'XFirst', name: 'first' },
|
|
552
|
+
{ apiVersion: 'example.io/v1', kind: 'XSecond', name: 'second' },
|
|
553
|
+
],
|
|
554
|
+
},
|
|
555
|
+
} as const
|
|
556
|
+
expect(getComposingXRRef(resource)).toEqual({
|
|
557
|
+
apiVersion: 'example.io/v1',
|
|
558
|
+
kind: 'XFirst',
|
|
559
|
+
name: 'first',
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('returns null when ownerReferences is empty or missing', () => {
|
|
564
|
+
expect(getComposingXRRef({} as const)).toBeNull()
|
|
565
|
+
expect(getComposingXRRef({ metadata: {} } as const)).toBeNull()
|
|
566
|
+
expect(getComposingXRRef({ metadata: { ownerReferences: [] } } as const)).toBeNull()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('returns null when the picked ref has no kind or name', () => {
|
|
570
|
+
const resource = {
|
|
571
|
+
metadata: { ownerReferences: [{ apiVersion: 'v1', controller: true }] },
|
|
572
|
+
} as const
|
|
573
|
+
expect(getComposingXRRef(resource)).toBeNull()
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
describe('isCrossplanePaused', () => {
|
|
578
|
+
it('returns true when annotation is the string "true"', () => {
|
|
579
|
+
const resource = {
|
|
580
|
+
metadata: { annotations: { 'crossplane.io/paused': 'true' } },
|
|
581
|
+
} as const
|
|
582
|
+
expect(isCrossplanePaused(resource)).toBe(true)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('returns false when annotation is missing', () => {
|
|
586
|
+
expect(isCrossplanePaused({} as const)).toBe(false)
|
|
587
|
+
expect(isCrossplanePaused({ metadata: {} } as const)).toBe(false)
|
|
588
|
+
expect(isCrossplanePaused({ metadata: { annotations: {} } } as const)).toBe(false)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('returns false for empty string, "false", or boolean true', () => {
|
|
592
|
+
expect(
|
|
593
|
+
isCrossplanePaused({ metadata: { annotations: { 'crossplane.io/paused': '' } } } as const),
|
|
594
|
+
).toBe(false)
|
|
595
|
+
expect(
|
|
596
|
+
isCrossplanePaused({
|
|
597
|
+
metadata: { annotations: { 'crossplane.io/paused': 'false' } },
|
|
598
|
+
} as const),
|
|
599
|
+
).toBe(false)
|
|
600
|
+
expect(
|
|
601
|
+
isCrossplanePaused({
|
|
602
|
+
metadata: { annotations: { 'crossplane.io/paused': true as any } },
|
|
603
|
+
} as const),
|
|
604
|
+
).toBe(false)
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// Pluralization moved to the shared kindToPlural in utils/navigation.ts
|
|
609
|
+
// (discovery-aware, handles provider-defined irregular plurals).
|