@skyhook-io/k8s-ui 1.12.0 → 1.13.3

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 (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. package/src/utils/resource-icons.ts +29 -2
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ CALICO_SELECTOR_NOT_APPLICABLE,
4
+ getCalicoApiGroup,
5
+ getCalicoIPPoolAllowedUses,
6
+ getCalicoIPPoolBlockSize,
7
+ getCalicoIPPoolEncapsulation,
8
+ getCalicoPolicyKindLabel,
9
+ getCalicoPolicySelector,
10
+ getCalicoPolicyTypes,
11
+ getCalicoTierRef,
12
+ isCalicoApiVersion,
13
+ isCalicoPolicyResource,
14
+ isCalicoStagedDeletion,
15
+ isCalicoStagedKubernetesNetworkPolicyKind,
16
+ isCoreNetworkPolicyKind,
17
+ } from './resource-utils-calico'
18
+
19
+ describe('Calico API group matching', () => {
20
+ it('extracts only supported Calico API groups', () => {
21
+ expect(getCalicoApiGroup('projectcalico.org/v3')).toBe('projectcalico.org')
22
+ expect(getCalicoApiGroup('crd.projectcalico.org/v1')).toBe(
23
+ 'crd.projectcalico.org',
24
+ )
25
+ expect(getCalicoApiGroup('extension.projectcalico.org/v1')).toBeUndefined()
26
+ })
27
+
28
+ it('builds a cluster-scoped Tier reference with the policy API group', () => {
29
+ expect(
30
+ getCalicoTierRef({
31
+ apiVersion: 'crd.projectcalico.org/v1',
32
+ spec: { tier: 'security' },
33
+ }),
34
+ ).toEqual({
35
+ kind: 'Tier',
36
+ namespace: '',
37
+ name: 'security',
38
+ group: 'crd.projectcalico.org',
39
+ })
40
+ })
41
+
42
+ it.each(['projectcalico.org/v3', 'crd.projectcalico.org/v1'])(
43
+ 'accepts the exact Calico group: %s',
44
+ (apiVersion) => {
45
+ expect(isCalicoApiVersion(apiVersion)).toBe(true)
46
+ expect(
47
+ isCalicoPolicyResource({ apiVersion, kind: 'NetworkPolicy' }),
48
+ ).toBe(true)
49
+ },
50
+ )
51
+
52
+ it.each(['projectcalico.org/v3', 'crd.projectcalico.org/v1'])(
53
+ 'recognizes staged Kubernetes network policies in %s',
54
+ (apiVersion) => {
55
+ const policy = {
56
+ apiVersion,
57
+ kind: 'StagedKubernetesNetworkPolicy',
58
+ spec: {
59
+ podSelector: { matchLabels: { app: 'frontend' } },
60
+ policyTypes: ['Ingress'],
61
+ },
62
+ }
63
+ expect(isCalicoStagedKubernetesNetworkPolicyKind(policy.kind)).toBe(true)
64
+ expect(isCalicoPolicyResource(policy)).toBe(true)
65
+ expect(getCalicoPolicySelector(policy)).toBe('app=frontend')
66
+ expect(getCalicoPolicyKindLabel(policy.kind)).toBe(
67
+ 'CalicoStagedKubernetesNetworkPolicy',
68
+ )
69
+ },
70
+ )
71
+
72
+ it.each([
73
+ 'extension.projectcalico.org/v1',
74
+ 'projectcalico.org.evil/v1',
75
+ 'networking.example.io/v1',
76
+ 'v1',
77
+ ])('rejects a non-Calico group: %s', (apiVersion) => {
78
+ expect(isCalicoApiVersion(apiVersion)).toBe(false)
79
+ expect(isCalicoPolicyResource({ apiVersion, kind: 'NetworkPolicy' })).toBe(
80
+ false,
81
+ )
82
+ })
83
+
84
+ it('recognizes only the core networking.k8s.io policy', () => {
85
+ expect(
86
+ isCoreNetworkPolicyKind('NetworkPolicy', 'networking.k8s.io/v1'),
87
+ ).toBe(true)
88
+ expect(
89
+ isCoreNetworkPolicyKind('NetworkPolicy', 'projectcalico.org/v3'),
90
+ ).toBe(false)
91
+ expect(
92
+ isCoreNetworkPolicyKind('NetworkPolicy', 'other.example.io/v1'),
93
+ ).toBe(false)
94
+ expect(isCoreNetworkPolicyKind('NetworkPolicy', undefined)).toBe(true)
95
+ expect(
96
+ isCoreNetworkPolicyKind('NetworkPolicy', undefined, 'networking.k8s.io'),
97
+ ).toBe(true)
98
+ expect(
99
+ isCoreNetworkPolicyKind('NetworkPolicy', undefined, 'other.example.io'),
100
+ ).toBe(false)
101
+ })
102
+
103
+ it('does not let the exact core fallback classify foreign kinds', () => {
104
+ expect(
105
+ isCoreNetworkPolicyKind('GlobalNetworkPolicy', 'networking.k8s.io/v1'),
106
+ ).toBe(false)
107
+ expect(isCoreNetworkPolicyKind('Widget', undefined)).toBe(false)
108
+ })
109
+ })
110
+
111
+ describe('staged deletions', () => {
112
+ const deletion = {
113
+ apiVersion: 'projectcalico.org/v3',
114
+ kind: 'StagedNetworkPolicy',
115
+ metadata: { name: 'retire-frontend', namespace: 'demo' },
116
+ // The Calico API rejects any other spec field alongside a Delete action.
117
+ spec: { stagedAction: 'Delete', tier: 'default' },
118
+ }
119
+
120
+ it('does not read an absent selector as every workload', () => {
121
+ expect(isCalicoStagedDeletion(deletion)).toBe(true)
122
+ expect(getCalicoPolicySelector(deletion)).toBe(
123
+ CALICO_SELECTOR_NOT_APPLICABLE,
124
+ )
125
+ })
126
+
127
+ it('leaves a staged Set reading its real selector', () => {
128
+ const set = {
129
+ apiVersion: 'projectcalico.org/v3',
130
+ kind: 'StagedNetworkPolicy',
131
+ metadata: { name: 'tighten', namespace: 'demo' },
132
+ spec: { stagedAction: 'Set', selector: "app == 'web'" },
133
+ }
134
+ expect(isCalicoStagedDeletion(set)).toBe(false)
135
+ expect(getCalicoPolicySelector(set)).toBe("app == 'web'")
136
+ })
137
+
138
+ it('still reads an enforced policy with no selector as every workload', () => {
139
+ const enforced = {
140
+ apiVersion: 'projectcalico.org/v3',
141
+ kind: 'NetworkPolicy',
142
+ metadata: { name: 'catch-all', namespace: 'demo' },
143
+ spec: {},
144
+ }
145
+ expect(getCalicoPolicySelector(enforced)).toBe('all workloads')
146
+ })
147
+ })
148
+
149
+ describe('IPPool derivations', () => {
150
+ it("applies Calico's block size default per address family", () => {
151
+ expect(getCalicoIPPoolBlockSize({ spec: { cidr: '192.168.0.0/16' } })).toBe(
152
+ 26,
153
+ )
154
+ expect(getCalicoIPPoolBlockSize({ spec: { cidr: 'fd00::/48' } })).toBe(122)
155
+ expect(
156
+ getCalicoIPPoolBlockSize({ spec: { cidr: '10.0.0.0/8', blockSize: 24 } }),
157
+ ).toBe(24)
158
+ expect(getCalicoIPPoolBlockSize({ spec: {} })).toBeUndefined()
159
+ })
160
+
161
+ it('names the tunnel a pool encapsulates through', () => {
162
+ expect(getCalicoIPPoolEncapsulation({ spec: {} })).toBe('None')
163
+ expect(getCalicoIPPoolEncapsulation({ spec: { ipipMode: 'Always' } })).toBe(
164
+ 'IPIP Always',
165
+ )
166
+ expect(
167
+ getCalicoIPPoolEncapsulation({ spec: { vxlanMode: 'CrossSubnet' } }),
168
+ ).toBe('VXLAN CrossSubnet')
169
+ })
170
+
171
+ it("falls back to Calico's default allowed uses", () => {
172
+ expect(getCalicoIPPoolAllowedUses({ spec: {} })).toBe('Workload, Tunnel')
173
+ expect(
174
+ getCalicoIPPoolAllowedUses({ spec: { allowedUses: ['Tunnel'] } }),
175
+ ).toBe('Tunnel')
176
+ })
177
+ })
178
+
179
+ describe('derived policy types', () => {
180
+ // Verified against Calico v3.32.1: applying these without spec.types and
181
+ // reading them back yields exactly these values.
182
+ const policy = (spec: any) => ({
183
+ apiVersion: 'projectcalico.org/v3',
184
+ kind: 'NetworkPolicy',
185
+ spec: { selector: "app == 'x'", ...spec },
186
+ })
187
+
188
+ it('derives the types from which rule lists exist', () => {
189
+ expect(getCalicoPolicyTypes(policy({ egress: [{ action: 'Allow' }] }))).toEqual(['Egress'])
190
+ expect(getCalicoPolicyTypes(policy({ ingress: [{ action: 'Allow' }] }))).toEqual(['Ingress'])
191
+ expect(
192
+ getCalicoPolicyTypes(policy({ ingress: [{ action: 'Allow' }], egress: [{ action: 'Allow' }] })),
193
+ ).toEqual(['Ingress', 'Egress'])
194
+ expect(getCalicoPolicyTypes(policy({}))).toEqual(['Ingress'])
195
+ })
196
+
197
+ it('follows the Kubernetes rule for the Kubernetes-shaped staged kind', () => {
198
+ // Kubernetes: every policy affects ingress, egress only when an egress
199
+ // section exists. Calico leaves policyTypes empty on this kind, so the
200
+ // derivation below is what the UI renders.
201
+ const staged = (spec: any) => ({
202
+ apiVersion: 'projectcalico.org/v3',
203
+ kind: 'StagedKubernetesNetworkPolicy',
204
+ spec: { podSelector: { matchLabels: { app: 'x' } }, stagedAction: 'Set', ...spec },
205
+ })
206
+ expect(getCalicoPolicyTypes(staged({ egress: [{ to: [] }] }))).toEqual(['Ingress', 'Egress'])
207
+ expect(getCalicoPolicyTypes(staged({ ingress: [{ from: [] }] }))).toEqual(['Ingress'])
208
+ expect(getCalicoPolicyTypes(staged({}))).toEqual(['Ingress'])
209
+ })
210
+
211
+ it('prefers what the policy actually declares', () => {
212
+ expect(getCalicoPolicyTypes(policy({ types: ['Egress'], ingress: [{ action: 'Allow' }] }))).toEqual(['Egress'])
213
+ expect(getCalicoPolicyTypes({ spec: undefined })).toEqual([])
214
+ })
215
+ })
@@ -0,0 +1,261 @@
1
+ import type { ResourceRef } from '../../types'
2
+ import { isApiGroup } from './resource-utils-cnpg'
3
+
4
+ export const CALICO_GROUPS = [
5
+ 'projectcalico.org',
6
+ 'crd.projectcalico.org',
7
+ ] as const
8
+
9
+ export type CalicoApiGroup = (typeof CALICO_GROUPS)[number]
10
+
11
+ const CALICO_POLICY_KINDS = new Set([
12
+ 'networkpolicy',
13
+ 'networkpolicies',
14
+ 'globalnetworkpolicy',
15
+ 'globalnetworkpolicies',
16
+ 'stagednetworkpolicy',
17
+ 'stagednetworkpolicies',
18
+ 'stagedglobalnetworkpolicy',
19
+ 'stagedglobalnetworkpolicies',
20
+ 'stagedkubernetesnetworkpolicy',
21
+ 'stagedkubernetesnetworkpolicies',
22
+ ])
23
+
24
+ const CALICO_POLICY_LABELS: Record<string, string> = {
25
+ networkpolicy: 'CalicoNetworkPolicy',
26
+ networkpolicies: 'CalicoNetworkPolicy',
27
+ globalnetworkpolicy: 'CalicoGlobalNetworkPolicy',
28
+ globalnetworkpolicies: 'CalicoGlobalNetworkPolicy',
29
+ stagednetworkpolicy: 'CalicoStagedNetworkPolicy',
30
+ stagednetworkpolicies: 'CalicoStagedNetworkPolicy',
31
+ stagedglobalnetworkpolicy: 'CalicoStagedGlobalNetworkPolicy',
32
+ stagedglobalnetworkpolicies: 'CalicoStagedGlobalNetworkPolicy',
33
+ stagedkubernetesnetworkpolicy: 'CalicoStagedKubernetesNetworkPolicy',
34
+ stagedkubernetesnetworkpolicies: 'CalicoStagedKubernetesNetworkPolicy',
35
+ }
36
+
37
+ function apiGroup(apiVersion: unknown): string {
38
+ if (typeof apiVersion !== 'string') return ''
39
+ const parts = apiVersion.split('/')
40
+ return parts.length === 2 ? parts[0] : ''
41
+ }
42
+
43
+ export function isCalicoApiGroup(group: unknown): group is CalicoApiGroup {
44
+ return (
45
+ typeof group === 'string' &&
46
+ (CALICO_GROUPS as readonly string[]).includes(group)
47
+ )
48
+ }
49
+
50
+ export function isCalicoApiVersion(apiVersion: unknown): boolean {
51
+ return isCalicoApiGroup(apiGroup(apiVersion))
52
+ }
53
+
54
+ export function getCalicoApiGroup(
55
+ apiVersion: unknown,
56
+ ): CalicoApiGroup | undefined {
57
+ const group = apiGroup(apiVersion)
58
+ return isCalicoApiGroup(group) ? group : undefined
59
+ }
60
+
61
+ export function getCalicoTierRef(policy: any): ResourceRef {
62
+ return {
63
+ kind: 'Tier',
64
+ namespace: '',
65
+ name: String(policy?.spec?.tier ?? 'default'),
66
+ group: getCalicoApiGroup(policy?.apiVersion),
67
+ }
68
+ }
69
+
70
+ export function isCalicoPolicyKind(kind: unknown): boolean {
71
+ return typeof kind === 'string' && CALICO_POLICY_KINDS.has(kind.toLowerCase())
72
+ }
73
+
74
+ export function isCalicoNetworkPolicyKind(kind: unknown): boolean {
75
+ return (
76
+ typeof kind === 'string' &&
77
+ ['networkpolicy', 'networkpolicies'].includes(kind.toLowerCase())
78
+ )
79
+ }
80
+
81
+ export function isCalicoStagedKubernetesNetworkPolicyKind(
82
+ kind: unknown,
83
+ ): boolean {
84
+ return (
85
+ typeof kind === 'string' &&
86
+ [
87
+ 'stagedkubernetesnetworkpolicy',
88
+ 'stagedkubernetesnetworkpolicies',
89
+ ].includes(kind.toLowerCase())
90
+ )
91
+ }
92
+
93
+ export function isCalicoPolicyResource(data: any): boolean {
94
+ return isCalicoApiVersion(data?.apiVersion) && isCalicoPolicyKind(data?.kind)
95
+ }
96
+
97
+ export function isCoreNetworkPolicyApiVersion(apiVersion: unknown): boolean {
98
+ return isApiGroup(apiVersion, 'networking.k8s.io')
99
+ }
100
+
101
+ export function isCoreNetworkPolicyResource(data: any): boolean {
102
+ return isCoreNetworkPolicyKind(data?.kind, data?.apiVersion)
103
+ }
104
+
105
+ export function isCoreNetworkPolicyKind(
106
+ kind: unknown,
107
+ apiVersion: unknown,
108
+ group?: string,
109
+ ): boolean {
110
+ if (!isCalicoNetworkPolicyKind(kind)) return false
111
+ if (apiVersion !== undefined && apiVersion !== null && apiVersion !== '') {
112
+ return isCoreNetworkPolicyApiVersion(apiVersion)
113
+ }
114
+ return group === undefined || group === '' || group === 'networking.k8s.io'
115
+ }
116
+
117
+ export function getCalicoPolicyKindLabel(kind: unknown): string {
118
+ if (typeof kind !== 'string') return 'CalicoNetworkPolicy'
119
+ return CALICO_POLICY_LABELS[kind.toLowerCase()] ?? 'CalicoNetworkPolicy'
120
+ }
121
+
122
+ export function getCalicoPolicyTypes(policy: any): string[] {
123
+ const raw = policy?.spec?.types ?? policy?.spec?.policyTypes
124
+ if (Array.isArray(raw) && raw.length > 0) return raw.map(String)
125
+ if (typeof raw === 'string' && raw !== '') return [raw]
126
+ if (policy?.spec === undefined) return []
127
+ const hasIngress = Array.isArray(policy.spec.ingress) && policy.spec.ingress.length > 0
128
+ const hasEgress = Array.isArray(policy.spec.egress) && policy.spec.egress.length > 0
129
+
130
+ // The two families default this field by different rules, and a
131
+ // StagedKubernetesNetworkPolicy carries the Kubernetes shape. Kubernetes
132
+ // treats every policy as affecting ingress and adds egress only when an
133
+ // egress section exists; Calico derives from which rule lists exist, so an
134
+ // egress-only Calico policy is egress ONLY. Calico stores its own derivation
135
+ // but leaves policyTypes empty on the Kubernetes-shaped kind, so this runs.
136
+ if (isCalicoStagedKubernetesNetworkPolicyKind(policy?.kind)) {
137
+ return hasEgress ? ['Ingress', 'Egress'] : ['Ingress']
138
+ }
139
+ if (hasEgress && !hasIngress) return ['Egress']
140
+ return hasEgress ? ['Ingress', 'Egress'] : ['Ingress']
141
+ }
142
+
143
+ export function getCalicoPolicyRuleCount(policy: any): {
144
+ ingress: number
145
+ egress: number
146
+ } {
147
+ return {
148
+ ingress: Array.isArray(policy?.spec?.ingress)
149
+ ? policy.spec.ingress.length
150
+ : 0,
151
+ egress: Array.isArray(policy?.spec?.egress) ? policy.spec.egress.length : 0,
152
+ }
153
+ }
154
+
155
+ /** The staged action, lower-cased. Absent means Set, which is Calico's default. */
156
+ export function getCalicoStagedAction(policy: any): string {
157
+ const action = policy?.spec?.stagedAction
158
+ return typeof action === 'string' ? action.toLowerCase() : ''
159
+ }
160
+
161
+ /**
162
+ * A staged policy that stages a removal rather than a change. Calico requires
163
+ * the rest of its spec to be empty, so it has no selector and no rules — an
164
+ * absent selector here means "not applicable", never "every workload".
165
+ */
166
+ export function isCalicoStagedDeletion(policy: any): boolean {
167
+ return getCalicoStagedAction(policy) === 'delete'
168
+ }
169
+
170
+ /**
171
+ * A staged policy Calico skips entirely. It previews nothing, so presenting its
172
+ * rules as a preview of protection is the same overstatement as reading a
173
+ * deletion's absent selector as "every workload".
174
+ */
175
+ export function isCalicoStagedIgnored(policy: any): boolean {
176
+ return getCalicoStagedAction(policy) === 'ignore'
177
+ }
178
+
179
+ export const CALICO_SELECTOR_NOT_APPLICABLE = '-'
180
+
181
+ export function getCalicoPolicySelector(policy: any): string {
182
+ if (isCalicoStagedDeletion(policy)) return CALICO_SELECTOR_NOT_APPLICABLE
183
+ const kubernetesPolicy = isCalicoStagedKubernetesNetworkPolicyKind(
184
+ policy?.kind,
185
+ )
186
+ const selector = kubernetesPolicy
187
+ ? policy?.spec?.podSelector
188
+ : policy?.spec?.selector
189
+ if (kubernetesPolicy) return formatKubernetesLabelSelector(selector)
190
+ return selector === undefined || selector === null || selector === ''
191
+ ? 'all workloads'
192
+ : String(selector)
193
+ }
194
+
195
+ export function formatKubernetesLabelSelector(selector: any): string {
196
+ if (!selector || typeof selector !== 'object') return 'all workloads'
197
+ const parts: string[] = []
198
+ for (const [key, value] of Object.entries(selector.matchLabels ?? {})) {
199
+ parts.push(`${key}=${String(value)}`)
200
+ }
201
+ for (const expression of Array.isArray(selector.matchExpressions)
202
+ ? selector.matchExpressions
203
+ : []) {
204
+ if (!expression || typeof expression !== 'object') continue
205
+ const item = expression as {
206
+ key?: string
207
+ operator?: string
208
+ values?: unknown[]
209
+ }
210
+ const values = Array.isArray(item.values)
211
+ ? item.values.map(String).join(',')
212
+ : ''
213
+ parts.push(
214
+ values
215
+ ? `${item.key ?? '?'} ${item.operator ?? '?'} (${values})`
216
+ : `${item.key ?? '?'} ${item.operator ?? '?'}`,
217
+ )
218
+ }
219
+ return parts.length > 0 ? parts.join(', ') : 'all workloads'
220
+ }
221
+
222
+ export function getCalicoPolicyNamespaceSelector(policy: any): string {
223
+ const selector = policy?.spec?.namespaceSelector
224
+ return selector === undefined || selector === null || selector === ''
225
+ ? '-'
226
+ : String(selector)
227
+ }
228
+
229
+ export function getCalicoPolicyServiceAccountSelector(policy: any): string {
230
+ const selector = policy?.spec?.serviceAccountSelector
231
+ return selector === undefined || selector === null || selector === ''
232
+ ? '-'
233
+ : String(selector)
234
+ }
235
+
236
+ /**
237
+ * Calico's own defaults for an IPPool, applied when a field is absent. The pool
238
+ * behaves this way whether or not the field is written, so a reader that shows
239
+ * only what is set describes the pool inaccurately.
240
+ */
241
+ export function getCalicoIPPoolBlockSize(pool: any): number | undefined {
242
+ if (pool?.spec?.blockSize !== undefined) return Number(pool.spec.blockSize)
243
+ const cidr = pool?.spec?.cidr
244
+ if (typeof cidr !== 'string') return undefined
245
+ return cidr.includes(':') ? 122 : 26
246
+ }
247
+
248
+ /** The tunnel a pool encapsulates through, or "None" when it routes directly. */
249
+ export function getCalicoIPPoolEncapsulation(pool: any): string {
250
+ const ipip = String(pool?.spec?.ipipMode ?? 'Never')
251
+ const vxlan = String(pool?.spec?.vxlanMode ?? 'Never')
252
+ if (ipip !== 'Never') return `IPIP ${ipip}`
253
+ if (vxlan !== 'Never') return `VXLAN ${vxlan}`
254
+ return 'None'
255
+ }
256
+
257
+ export function getCalicoIPPoolAllowedUses(pool: any): string {
258
+ const uses = pool?.spec?.allowedUses
259
+ if (!Array.isArray(uses)) return 'Workload, Tunnel'
260
+ return uses.length > 0 ? uses.map(String).join(', ') : '-'
261
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ splitCNPGDeclarativeByApplied,
4
+ getCNPGDeclarativeStatus,
5
+ getCNPGDeclarativeMessage,
6
+ getCNPGReclaimPolicy,
7
+ getCNPGImageCatalogEntries,
8
+ } from './resource-utils-cnpg'
9
+
10
+ /**
11
+ * `status.applied` has three meanings and the middle one is easy to lose.
12
+ * Verified against a live CNPG 1.27 operator: `demo-app` came back true,
13
+ * `demo-broken` came back false with `role "nobody-owns-this" does not exist`,
14
+ * and every object is briefly absent-status before the operator reaches it.
15
+ */
16
+ describe('declarative object status', () => {
17
+ it('is healthy once the operator has applied it', () => {
18
+ expect(getCNPGDeclarativeStatus({ status: { applied: true } }).level).toBe('healthy')
19
+ })
20
+
21
+ it('is unhealthy when the operator tried and failed', () => {
22
+ expect(getCNPGDeclarativeStatus({ status: { applied: false } }).level).toBe('unhealthy')
23
+ })
24
+
25
+ // The one that matters: a freshly created object has no `applied` at all, and
26
+ // reporting that as failed condemns everything in its first seconds.
27
+ it('is unknown — not failed — before the operator has reconciled', () => {
28
+ expect(getCNPGDeclarativeStatus({}).level).toBe('unknown')
29
+ expect(getCNPGDeclarativeStatus({ status: {} }).level).toBe('unknown')
30
+ expect(getCNPGDeclarativeStatus({ status: { applied: undefined } }).text).toBe('Pending')
31
+ })
32
+
33
+ it('passes the operator’s message through untouched', () => {
34
+ const msg = 'while creating database "demo_broken": ERROR: role "nobody-owns-this" does not exist'
35
+ expect(getCNPGDeclarativeMessage({ status: { message: msg } })).toBe(msg)
36
+ expect(getCNPGDeclarativeMessage({ status: { message: '' } })).toBeUndefined()
37
+ expect(getCNPGDeclarativeMessage({})).toBeUndefined()
38
+ })
39
+ })
40
+
41
+ /**
42
+ * The reclaim policy decides whether deleting a manifest drops real data, so
43
+ * the destructive case has to be identified rather than printed.
44
+ */
45
+ describe('reclaim policy', () => {
46
+ it('flags delete as destructive across all three kinds', () => {
47
+ expect(getCNPGReclaimPolicy({ spec: { databaseReclaimPolicy: 'delete' } }).destructive).toBe(true)
48
+ expect(getCNPGReclaimPolicy({ spec: { publicationReclaimPolicy: 'delete' } }).destructive).toBe(true)
49
+ expect(getCNPGReclaimPolicy({ spec: { subscriptionReclaimPolicy: 'delete' } }).destructive).toBe(true)
50
+ })
51
+
52
+ it('does not flag retain', () => {
53
+ expect(getCNPGReclaimPolicy({ spec: { databaseReclaimPolicy: 'retain' } }).destructive).toBe(false)
54
+ })
55
+
56
+ // CNPG defaults to retain, and defaulting the other way in the UI would
57
+ // warn about data loss that is not going to happen.
58
+ it('defaults to retain when unset', () => {
59
+ const r = getCNPGReclaimPolicy({ spec: {} })
60
+ expect(r.value).toBe('retain')
61
+ expect(r.destructive).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('image catalog entries', () => {
66
+ it('sorts by major so the list does not reshuffle between reads', () => {
67
+ const e = getCNPGImageCatalogEntries({
68
+ spec: { images: [{ major: 17, image: 'pg:17' }, { major: 15, image: 'pg:15' }, { major: 16, image: 'pg:16' }] },
69
+ })
70
+ expect(e.map((x) => x.major)).toEqual([15, 16, 17])
71
+ })
72
+
73
+ it('drops entries with no image rather than rendering a blank row', () => {
74
+ expect(getCNPGImageCatalogEntries({ spec: { images: [{ major: 16 }] } })).toEqual([])
75
+ })
76
+
77
+ it('returns nothing for a catalog with no images', () => {
78
+ expect(getCNPGImageCatalogEntries({ spec: {} })).toEqual([])
79
+ expect(getCNPGImageCatalogEntries({})).toEqual([])
80
+ })
81
+ })
82
+
83
+ /**
84
+ * The Database page renders these three buckets with three different claims, so
85
+ * collapsing pending into failed puts "this exists in Kubernetes and not in
86
+ * PostgreSQL" under an object nothing has looked at yet.
87
+ */
88
+ describe('splitCNPGDeclarativeByApplied', () => {
89
+ it('keeps pending out of the failures', () => {
90
+ const live = { status: { applied: true } }
91
+ const broken = { status: { applied: false, message: 'boom' } }
92
+ const fresh = {}
93
+ const alsoFresh = { status: {} }
94
+
95
+ const out = splitCNPGDeclarativeByApplied([live, broken, fresh, alsoFresh])
96
+ expect(out.applied).toEqual([live])
97
+ expect(out.notApplied).toEqual([broken])
98
+ expect(out.pending).toEqual([fresh, alsoFresh])
99
+ })
100
+
101
+ it('returns three empty buckets for nothing', () => {
102
+ expect(splitCNPGDeclarativeByApplied([])).toEqual({ applied: [], notApplied: [], pending: [] })
103
+ })
104
+ })