@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.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +39 -7
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +964 -96
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  36. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  40. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  41. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  42. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  43. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  44. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  45. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  46. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  47. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  48. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  49. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  50. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  51. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  52. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  53. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  54. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  55. package/src/components/resources/resource-utils-hpa.ts +6 -2
  56. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  57. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  58. package/src/components/resources/resource-utils-kai.ts +105 -0
  59. package/src/components/resources/resource-utils-kaito.ts +114 -0
  60. package/src/components/resources/resource-utils-kserve.ts +186 -0
  61. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  62. package/src/components/resources/resource-utils-kueue.ts +198 -0
  63. package/src/components/resources/resource-utils-nim.ts +109 -0
  64. package/src/components/resources/resource-utils-ray.ts +214 -0
  65. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  66. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  67. package/src/components/resources/resource-utils-volcano.ts +176 -0
  68. package/src/components/resources/resource-utils.ts +156 -47
  69. package/src/components/resources/resources-column-filter.test.ts +27 -1
  70. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  71. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  72. package/src/components/shared/EditableYamlView.tsx +4 -2
  73. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  74. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  75. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  76. package/src/components/shared/SetImageDialog.test.ts +129 -0
  77. package/src/components/shared/SetImageDialog.tsx +577 -0
  78. package/src/components/shared/index.ts +1 -0
  79. package/src/components/shared/revision-role-badges.test.ts +56 -4
  80. package/src/components/timeline/TimelineList.tsx +19 -3
  81. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  82. package/src/components/trace/ReachabilityView.tsx +1 -1
  83. package/src/components/ui/drawer-components.tsx +12 -0
  84. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  85. package/src/components/workload/WorkloadView.tsx +144 -81
  86. package/src/hooks/useProgressiveRefresh.ts +37 -0
  87. package/src/source-hygiene.test.ts +28 -0
  88. package/src/theme/tailwind-theme.css +4 -0
  89. package/src/types/core.ts +55 -0
  90. package/src/utils/api-resources.test.ts +40 -1
  91. package/src/utils/api-resources.ts +11 -1
  92. package/src/utils/applications.ts +50 -0
  93. package/src/utils/bulk-workload-actions.test.ts +20 -0
  94. package/src/utils/bulk-workload-actions.ts +15 -0
  95. package/src/utils/navigation.test.ts +31 -1
  96. package/src/utils/navigation.ts +21 -2
  97. package/src/utils/workload-rollout.ts +292 -0
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { columnSettingsKey, hasCuratedColumns } from './ResourcesView'
3
+ import { getCellFilterValue } from './resource-utils'
4
+ import { getGenericResourceStatus } from './generic-status'
5
+
6
+ describe('columnSettingsKey', () => {
7
+ // Two uncurated CRDs sharing a plural across API groups — Crossplane ships
8
+ // two `Usage` kinds — shared one blob and leaked column visibility, widths
9
+ // and custom columns between them.
10
+ it('qualifies uncurated CRDs by their API group', () => {
11
+ expect(columnSettingsKey('usages', 'apiextensions.crossplane.io'))
12
+ .not.toBe(columnSettingsKey('usages', 'protection.crossplane.io'))
13
+ expect(columnSettingsKey('usages', 'apiextensions.crossplane.io'))
14
+ .toBe('usages.apiextensions.crossplane.io')
15
+ })
16
+
17
+ // Curated and core kinds keep the key their saved preferences are already
18
+ // under, so nobody's columns reset on upgrade.
19
+ it('leaves core kinds alone', () => {
20
+ expect(columnSettingsKey('pods')).toBe('pods')
21
+ expect(columnSettingsKey('deployments', 'apps')).toBe('deployments')
22
+ })
23
+
24
+ it('leaves curated CRDs alone', () => {
25
+ expect(columnSettingsKey('rollouts', 'argoproj.io')).toBe('rollouts')
26
+ })
27
+
28
+ // Crossplane managed resources are curated through a group heuristic, not a
29
+ // KNOWN_COLUMNS entry — curation has to be read from getColumnsForKind, not
30
+ // from key membership.
31
+ it('leaves Crossplane managed resources alone', () => {
32
+ const key = columnSettingsKey('buckets', 's3.aws.upbound.io')
33
+ expect(key).toBe('buckets')
34
+ })
35
+ })
36
+
37
+ describe('hasCuratedColumns', () => {
38
+ // One definition shared by the XOR, the storage key, and the host's decision
39
+ // whether to request a table. If they disagreed, the server would resolve a
40
+ // table the client discards — or two of them would put a kind on different
41
+ // column sets.
42
+ it('recognizes curated kinds, including group-routed and heuristic ones', () => {
43
+ expect(hasCuratedColumns('pods')).toBe(true)
44
+ expect(hasCuratedColumns('rollouts', 'argoproj.io')).toBe(true)
45
+ expect(hasCuratedColumns('clusters', 'postgresql.cnpg.io')).toBe(true)
46
+ // Crossplane managed resources are curated by group heuristic, not plural.
47
+ expect(hasCuratedColumns('buckets', 's3.aws.upbound.io')).toBe(true)
48
+ })
49
+
50
+ it('reports uncurated CRDs as uncurated', () => {
51
+ expect(hasCuratedColumns('usages', 'protection.crossplane.io')).toBe(false)
52
+ expect(hasCuratedColumns('widgets', 'example.com')).toBe(false)
53
+ })
54
+
55
+ it('agrees with columnSettingsKey about which kinds get group-qualified', () => {
56
+ for (const [kind, group] of [['usages', 'protection.crossplane.io'], ['widgets', 'example.com']] as const) {
57
+ expect(hasCuratedColumns(kind, group)).toBe(false)
58
+ expect(columnSettingsKey(kind, group)).toContain(group)
59
+ }
60
+ for (const [kind, group] of [['rollouts', 'argoproj.io'], ['buckets', 's3.aws.upbound.io']] as const) {
61
+ expect(hasCuratedColumns(kind, group)).toBe(true)
62
+ expect(columnSettingsKey(kind, group)).not.toContain(group)
63
+ }
64
+ })
65
+ })
66
+
67
+ describe('status sort scope', () => {
68
+ const pod = {
69
+ status: {
70
+ phase: 'Running',
71
+ conditions: [{ type: 'Ready', status: 'False' }],
72
+ containerStatuses: [{ name: 'app', ready: false, restartCount: 3, state: { running: {} } }],
73
+ },
74
+ spec: { containers: [{ name: 'app' }] },
75
+ }
76
+ const uncuratedCR = { status: { conditions: [{ type: 'Ready', status: 'False', reason: 'QuorumLost' }] } }
77
+
78
+ // Curated kinds keep phase-based ordering: routing them through the per-kind
79
+ // readers would reorder the most-used lists for a divergence that predates
80
+ // this work.
81
+ it('leaves a curated kind sorting on its phase', () => {
82
+ expect(hasCuratedColumns('pods')).toBe(true)
83
+ expect(pod.status.phase).toBe('Running')
84
+ // The displayed text differs from the phase — that gap is intentional here.
85
+ expect(getCellFilterValue(pod, 'status', 'pods')).not.toBe('Running')
86
+ })
87
+
88
+ // An uncurated CR has no phase at all, so phase sorted every row as equal.
89
+ it('sorts an uncurated kind on the text its badge shows', () => {
90
+ expect(hasCuratedColumns('widgets', 'example.com')).toBe(false)
91
+ expect((uncuratedCR.status as any).phase).toBeUndefined()
92
+ const shown = getGenericResourceStatus(uncuratedCR)?.text
93
+ expect(shown).toBe('QuorumLost')
94
+ // Sort and the filter dropdown must offer the same string, or the dropdown
95
+ // lists values that appear on no row.
96
+ expect(getCellFilterValue(uncuratedCR, 'status', 'widgets')).toBe(shown)
97
+ })
98
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mergeSavedVisibleColumns, selectedKindIdentityOf } from './ResourcesView'
3
+
4
+ const printer = (key: string, defaultVisible = true) => ({ key, label: key, defaultVisible }) as any
5
+
6
+ describe('mergeSavedVisibleColumns', () => {
7
+ it('keeps the saved set when it already knows the printer columns', () => {
8
+ const merged = mergeSavedVisibleColumns(
9
+ ['name', 'printer:Phase'],
10
+ [],
11
+ [printer('printer:Phase'), printer('printer:Host')],
12
+ )
13
+ // Host is absent from a blob that names another printer column, so the user
14
+ // hid it on purpose — seeding it back would make it un-hideable.
15
+ expect(merged.has('printer:Host')).toBe(false)
16
+ expect(merged.has('printer:Phase')).toBe(true)
17
+ })
18
+
19
+ it('seeds printer defaults into a blob that predates them', () => {
20
+ const merged = mergeSavedVisibleColumns(
21
+ ['name', 'namespace', 'age'],
22
+ [],
23
+ [printer('printer:Phase'), printer('printer:Host')],
24
+ )
25
+ // Without this the table would render Name + Namespace + Age and nothing
26
+ // else for every user who had ever touched this kind's columns.
27
+ expect(merged.has('printer:Phase')).toBe(true)
28
+ expect(merged.has('printer:Host')).toBe(true)
29
+ expect(merged.has('name')).toBe(true)
30
+ })
31
+
32
+ it('does not seed a wide-tier column, which is hidden by default', () => {
33
+ const merged = mergeSavedVisibleColumns(['name'], [], [printer('printer:Wide', false)])
34
+ expect(merged.has('printer:Wide')).toBe(false)
35
+ })
36
+
37
+ it('always seeds host extras, which the embedding app injects', () => {
38
+ const merged = mergeSavedVisibleColumns(['name'], ['cluster'], [])
39
+ expect(merged.has('cluster')).toBe(true)
40
+ })
41
+
42
+ it('is a no-op for a kind with no printer columns', () => {
43
+ expect([...mergeSavedVisibleColumns(['name', 'status'], [], [])].sort()).toEqual(['name', 'status'])
44
+ })
45
+ })
46
+
47
+ describe('selectedKindIdentityOf', () => {
48
+ // Two CRDs can ship the same plural in different API groups. Keyed on the
49
+ // plural alone, a `printer:*` sort or filter survives the switch onto a table
50
+ // with no such column, and every row silently fails the filter.
51
+ it('separates the same plural in different groups', () => {
52
+ expect(selectedKindIdentityOf({ name: 'widgets', group: 'a.io' }))
53
+ .not.toBe(selectedKindIdentityOf({ name: 'widgets', group: 'b.io' }))
54
+ })
55
+
56
+ it('is stable for the same selection', () => {
57
+ expect(selectedKindIdentityOf({ name: 'widgets', group: 'a.io' }))
58
+ .toBe(selectedKindIdentityOf({ name: 'widgets', group: 'a.io' }))
59
+ })
60
+
61
+ it('treats an absent group and an empty group alike', () => {
62
+ expect(selectedKindIdentityOf({ name: 'pods' })).toBe(selectedKindIdentityOf({ name: 'pods', group: '' }))
63
+ })
64
+ })
@@ -0,0 +1,236 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { hasCuratedColumns } from './ResourcesView'
5
+
6
+ // Parsed from the source rather than exported: this asserts a property of the
7
+ // data as it is written, and an export would let the two drift.
8
+ const SRC = readFileSync(join(__dirname, 'ResourcesView.tsx'), 'utf8')
9
+
10
+ function objectBody(declName: string): string {
11
+ const start = SRC.indexOf(declName)
12
+ if (start < 0) throw new Error(`${declName} not found — the checks below would pass vacuously`)
13
+ const open = SRC.indexOf('{', start)
14
+ let depth = 1
15
+ let i = open + 1
16
+ while (depth > 0) {
17
+ if (SRC[i] === '{') depth++
18
+ else if (SRC[i] === '}') depth--
19
+ i++
20
+ }
21
+ return SRC.slice(open + 1, i)
22
+ }
23
+
24
+ function objectKeys(declName: string): string[] {
25
+ return [...objectBody(declName).matchAll(/^ {2}([a-z0-9_]+):/gm)].map(m => m[1])
26
+ }
27
+
28
+ const CURATED = objectKeys('const KNOWN_COLUMNS')
29
+ const OWNED = new Set(objectKeys('const CURATED_COLUMN_GROUPS'))
30
+
31
+ // Curated sets keyed on a kind the API server reserves. A CRD cannot be created
32
+ // in a core group, so these can never be claimed by a foreign resource.
33
+ const CORE_KEYS = new Set([
34
+ 'pods', 'services', 'deployments', 'replicasets', 'statefulsets', 'daemonsets', 'jobs',
35
+ 'cronjobs', 'configmaps', 'secrets', 'ingresses', 'ingressclasses', 'endpoints',
36
+ 'endpointslices', 'persistentvolumeclaims', 'persistentvolumes', 'storageclasses', 'nodes',
37
+ 'namespaces', 'serviceaccounts', 'roles', 'rolebindings', 'clusterroles', 'clusterrolebindings',
38
+ 'horizontalpodautoscalers', 'hpas', 'poddisruptionbudgets', 'networkpolicies', 'events',
39
+ 'leases', 'priorityclasses', 'runtimeclasses', 'resourcequotas', 'limitranges', 'csidrivers',
40
+ 'csinodes', 'volumeattachments', 'mutatingwebhookconfigurations', 'validatingwebhookconfigurations',
41
+ 'certificatesigningrequests', 'apiservices', 'customresourcedefinitions', 'replicationcontrollers',
42
+ 'controllerrevisions', 'podtemplates', 'volumeattributesclasses', 'validatingadmissionpolicies',
43
+ 'validatingadmissionpolicybindings', 'flowschemas', 'prioritylevelconfigurations',
44
+ 'componentstatuses', 'resourceclaims', 'resourceclaimtemplates', 'resourceslices', 'deviceclasses',
45
+ ])
46
+
47
+ // Groups are unbounded (one CRD per provider service), so these are gated by a
48
+ // predicate instead of an enumerated list.
49
+ const UNBOUNDED_KEYS = new Set(['providerconfigs', 'crossplanemanagedresources'])
50
+
51
+ describe('curated column ownership', () => {
52
+ // The guard that matters: a curated set added without an owning group would
53
+ // silently claim every CRD that happens to share its plural.
54
+ it('declares an owning group for every non-core curated set', () => {
55
+ const undeclared = CURATED.filter(k => !CORE_KEYS.has(k) && !UNBOUNDED_KEYS.has(k) && !OWNED.has(k))
56
+ expect(undeclared).toEqual([])
57
+ })
58
+
59
+ it('does not declare ownership for a set that no longer exists', () => {
60
+ expect([...OWNED].filter(k => !CURATED.includes(k))).toEqual([])
61
+ })
62
+
63
+ // Brace-counting, not parsing: assert the extraction actually saw the whole
64
+ // table, or a silently-missed key would let the guard above pass vacuously.
65
+ it('extracted every curated key', () => {
66
+ expect(CURATED.length).toBe(236)
67
+ expect(OWNED.size).toBe(198)
68
+ expect(CURATED).toContain('pods')
69
+ expect(CURATED).toContain('crossplanemanagedresources')
70
+ expect([...OWNED]).toContain('awsmanagedcontrolplanes')
71
+ })
72
+ })
73
+
74
+ describe('curated sets are reachable', () => {
75
+ // Ownership answers "who may use this key". It does not answer "can anything
76
+ // reach this key at all" — and a curated set nothing routes to is invisible
77
+ // while every other check stays green. kyvernopolicies was exactly that: real
78
+ // columns, real cells, and no normalization entry to arrive through.
79
+ const QUALIFIED_TARGETS = new Set(
80
+ [...objectBody('const GROUP_QUALIFIED_COLUMN_KEYS').matchAll(/'[^']*':\s*'([a-z0-9_]+)'/g)].map(m => m[1]),
81
+ )
82
+
83
+ // Keys named after a vendor are Radar's own invention rather than a real API
84
+ // plural, so something has to route to them explicitly.
85
+ const VENDOR_PREFIXES = /^(cnpg|capi|knative|calico|velero|kyverno|nvidia|crossplane|istio)/
86
+
87
+ // Reached by predicate rather than by the qualified map.
88
+ const PREDICATE_REACHED = new Set(['crossplanemanagedresources'])
89
+ // Genuine upstream plurals that happen to start with a vendor name.
90
+ const REAL_PLURALS = new Set(['nvidiadrivers'])
91
+
92
+ it('routes something to every vendor-named curated set', () => {
93
+ const unreachable = CURATED.filter(
94
+ k => VENDOR_PREFIXES.test(k) &&
95
+ !QUALIFIED_TARGETS.has(k) &&
96
+ !PREDICATE_REACHED.has(k) &&
97
+ !REAL_PLURALS.has(k),
98
+ )
99
+ expect(unreachable).toEqual([])
100
+ })
101
+
102
+ it('resolves the real plural to the vendor key, not just the key to itself', () => {
103
+ // The inverse of the vacuity trap above: assert the plural a cluster would
104
+ // actually serve, not Radar's internal name for the column set.
105
+ expect(hasCuratedColumns('policies', 'kyverno.io')).toBe(true)
106
+ expect(hasCuratedColumns('gateways', 'networking.istio.io')).toBe(true)
107
+ expect(hasCuratedColumns('backups', 'postgresql.cnpg.io')).toBe(true)
108
+ expect(hasCuratedColumns('clusters', 'cluster.x-k8s.io')).toBe(true)
109
+ expect(hasCuratedColumns('services', 'serving.knative.dev')).toBe(true)
110
+ // ...and that an unrelated vendor still gets nothing.
111
+ expect(hasCuratedColumns('policies', 'someone-else.io')).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('every declaration resolves', () => {
116
+ // Size checks cannot catch a wrong group. Each declared pair has to actually
117
+ // return curated columns, and the same key under a group nobody claims has to
118
+ // not — run over all 160 rather than a hand-picked sample.
119
+ const DECLARED = [...objectBody('const CURATED_COLUMN_GROUPS')
120
+ .matchAll(/^ {2}([a-z0-9_]+): \[([^\]\n]+)\],$/gm)]
121
+ .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
122
+
123
+ it('covers the whole ownership table', () => {
124
+ expect(DECLARED.length).toBe(198)
125
+ })
126
+
127
+ it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
128
+ for (const g of groups) expect(hasCuratedColumns(key, g)).toBe(true)
129
+ })
130
+
131
+ it.each(DECLARED)('%s is not curated under an unclaimed group', (key, _groups) => {
132
+ // Deliberately not a Crossplane/upbound suffix, which is matched by predicate.
133
+ expect(hasCuratedColumns(key, 'nobody-claims-this.invalid')).toBe(false)
134
+ })
135
+ })
136
+
137
+ describe('foreign CRDs do not inherit curated columns', () => {
138
+ // Each of these ships in the wild under a plural Radar curates for someone
139
+ // else. chaos-mesh Workflows are present on real clusters today.
140
+ it.each([
141
+ ['workflows', 'chaos-mesh.org', 'argoproj.io'],
142
+ ['providers', 'notification.toolkit.fluxcd.io', 'pkg.crossplane.io'],
143
+ ['services', 'example.com', ''],
144
+ ['backups', 'third-party.io', 'velero.io'],
145
+ ['clusters', 'kubeblocks.io', ''],
146
+ ['certificates', 'unrelated.io', 'cert-manager.io'],
147
+ ])('%s.%s is not treated as curated', (plural, foreignGroup) => {
148
+ expect(hasCuratedColumns(plural, foreignGroup)).toBe(false)
149
+ })
150
+
151
+ it.each([
152
+ ['workflows', 'argoproj.io'],
153
+ ['gateways', 'gateway.networking.k8s.io'],
154
+ ['providers', 'pkg.crossplane.io'],
155
+ ['scaledobjects', 'keda.sh'],
156
+ ['certificates', 'cert-manager.io'],
157
+ ['nodepools', 'karpenter.sh'],
158
+ ['externalsecrets', 'external-secrets.io'],
159
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
160
+ ['virtualservices', 'networking.istio.io'],
161
+ ['vulnerabilityreports', 'aquasecurity.github.io'],
162
+ ])('%s.%s keeps its curated columns', (plural, group) => {
163
+ expect(hasCuratedColumns(plural, group)).toBe(true)
164
+ })
165
+
166
+ // Core kinds carry no ownership entry and must not regress.
167
+ it.each([
168
+ ['pods', ''],
169
+ ['deployments', 'apps'],
170
+ ['jobs', 'batch'],
171
+ ['ingresses', 'networking.k8s.io'],
172
+ ['roles', 'rbac.authorization.k8s.io'],
173
+ ])('core %s.%s stays curated', (plural, group) => {
174
+ expect(hasCuratedColumns(plural, group)).toBe(true)
175
+ })
176
+
177
+ // Both are curated, but not by the same set: an Istio Gateway has servers and
178
+ // a workload selector, a Gateway API Gateway has a class, listeners, routes
179
+ // and addresses, and neither vocabulary describes the other.
180
+ it('gives Istio and Gateway API their own gateway column sets', () => {
181
+ expect(hasCuratedColumns('gateways', 'networking.istio.io')).toBe(true)
182
+ expect(hasCuratedColumns('gateways', 'gateway.networking.k8s.io')).toBe(true)
183
+
184
+ // objectBody slices to the first `{`, which inside an array is its first
185
+ // element — so take the array literal itself.
186
+ const columnKeys = (key: string) => {
187
+ const start = SRC.indexOf(`\n ${key}: [`)
188
+ expect(start).toBeGreaterThan(-1)
189
+ const end = SRC.indexOf('\n ],', start)
190
+ return [...SRC.slice(start, end).matchAll(/key: '([a-zA-Z]+)'/g)].map(m => m[1])
191
+ }
192
+ const istio = columnKeys('istiogateways')
193
+ const gwapi = columnKeys('gateways')
194
+ expect(istio).toContain('servers')
195
+ expect(istio).toContain('selector')
196
+ expect(gwapi).toContain('listeners')
197
+ expect(istio).not.toContain('listeners')
198
+ expect(gwapi).not.toContain('servers')
199
+ })
200
+
201
+ // CAPA serves AWSManagedControlPlane in the control-plane group; CAPZ and CAPG
202
+ // serve their equivalents in the infrastructure group. Declaring AWS as
203
+ // infrastructure silently dropped the curated columns on every CAPA cluster.
204
+ it('claims AWSManagedControlPlane in the control-plane group', () => {
205
+ expect(hasCuratedColumns('awsmanagedcontrolplanes', 'controlplane.cluster.x-k8s.io')).toBe(true)
206
+ expect(hasCuratedColumns('awsmanagedcontrolplanes', 'infrastructure.cluster.x-k8s.io')).toBe(false)
207
+ expect(hasCuratedColumns('azuremanagedcontrolplanes', 'infrastructure.cluster.x-k8s.io')).toBe(true)
208
+ expect(hasCuratedColumns('gcpmanagedcontrolplanes', 'infrastructure.cluster.x-k8s.io')).toBe(true)
209
+ })
210
+
211
+ // metrics.k8s.io is an aggregated API, not a group the API server reserves,
212
+ // and PodMetrics/NodeMetrics carry the plurals `pods`/`nodes`. Treating it as
213
+ // core handed them the Pod and Node column sets, which read fields they lack.
214
+ it('does not treat the aggregated metrics API as a core group', () => {
215
+ expect(hasCuratedColumns('pods', 'metrics.k8s.io')).toBe(false)
216
+ expect(hasCuratedColumns('nodes', 'metrics.k8s.io')).toBe(false)
217
+ expect(hasCuratedColumns('pods', '')).toBe(true)
218
+ })
219
+
220
+ // A provider ships one CRD per service, so managed resources collide with
221
+ // curated plurals constantly. The MR check has to run before the curated
222
+ // lookup or the collision resolves to generic columns and never reaches it.
223
+ it('keeps a managed resource whose plural collides with a curated kind', () => {
224
+ expect(hasCuratedColumns('certificates', 'acm.aws.upbound.io')).toBe(true)
225
+ expect(hasCuratedColumns('services', 's3.aws.upbound.io')).toBe(true)
226
+ expect(hasCuratedColumns('certificates', 'cert-manager.io')).toBe(true)
227
+ })
228
+
229
+ // Crossplane ProviderConfigs have one CRD per provider, so their groups
230
+ // cannot be enumerated and are matched by suffix instead.
231
+ it('keeps ProviderConfig curated across unbounded provider groups', () => {
232
+ expect(hasCuratedColumns('providerconfigs', 'aws.upbound.io')).toBe(true)
233
+ expect(hasCuratedColumns('providerconfigs', 'kubernetes.crossplane.io')).toBe(true)
234
+ expect(hasCuratedColumns('providerconfigs', 'not-crossplane.io')).toBe(false)
235
+ })
236
+ })