@skyhook-io/k8s-ui 1.13.4 → 1.14.1

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 (43) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsTableView.tsx +168 -11
  3. package/src/components/gitops/applicationset-row.test.ts +214 -0
  4. package/src/components/gitops/detail-helpers.test.ts +54 -0
  5. package/src/components/gitops/detail-helpers.ts +13 -3
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
  8. package/src/components/resources/ResourcesView.tsx +259 -80
  9. package/src/components/resources/column-header-width.test.ts +75 -0
  10. package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
  11. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  12. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  13. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  16. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  17. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  18. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  19. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  21. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  23. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  24. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  25. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  26. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  27. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  28. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
  29. package/src/components/resources/resource-utils-kueue.ts +4 -2
  30. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  31. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  32. package/src/components/resources/resource-utils-velero.ts +32 -0
  33. package/src/components/resources/rules-column-width.test.ts +42 -0
  34. package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
  35. package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
  36. package/src/components/topology/GroupNode.tsx +6 -2
  37. package/src/components/topology/topology.css +4 -1
  38. package/src/components/ui/Collapse.tsx +9 -1
  39. package/src/components/ui/RowActionMenu.tsx +4 -0
  40. package/src/components/ui/SelectMenu.tsx +10 -1
  41. package/src/components/ui/drawer-components.tsx +4 -2
  42. package/src/components/workload/WorkloadView.tsx +9 -2
  43. package/src/types/gitops.ts +51 -0
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getColumnMinWidth, type Column } from './ResourcesView'
3
+
4
+ // The header row is px-4 with a `truncate` label, under table-layout:fixed with
5
+ // explicit <col> widths. A column narrower than its own heading clips to
6
+ // "ADDRESS T…" and — because only the Name column flexes — stays clipped at
7
+ // 1280, 1920 and 2560 alike. These pin the floor that stops that.
8
+ //
9
+ // Reference measurements taken in the browser against the real header font
10
+ // (DM Sans 500, 12px, uppercase, tracking-wide), on kind-radar-kyverno-demo:
11
+ // RECLAIM 52.7px ADDRESSES 69.6px
12
+ // EXPANSION 67.5px ADDRESS TYPE 87.7px
13
+ const MEASURED = {
14
+ Reclaim: 52.7,
15
+ Expansion: 67.5,
16
+ Addresses: 69.6,
17
+ 'Address Type': 87.7,
18
+ }
19
+
20
+ const col = (over: Partial<Column> & Pick<Column, 'key' | 'label'>): Column => over
21
+
22
+ describe('getColumnMinWidth header floor', () => {
23
+ it.each(Object.entries(MEASURED))(
24
+ 'reserves at least the measured width of %s plus padding',
25
+ (label, measuredPx) => {
26
+ const got = getColumnMinWidth(col({ key: 'x', label, width: 'w-16' }))
27
+ // 32px is the px-4 on both sides; the estimate must never land under the
28
+ // width the browser actually renders, or the label clips again.
29
+ expect(got).toBeGreaterThanOrEqual(Math.ceil(measuredPx + 32))
30
+ },
31
+ )
32
+
33
+ it('leaves a column that already fits its label untouched', () => {
34
+ // "Age" needs ~25px + 32 padding + 20 for its sort icon = well under w-24.
35
+ expect(getColumnMinWidth(col({ key: 'age', label: 'Age', width: 'w-24' }))).toBe(96)
36
+ })
37
+
38
+ it('raises a column that is narrower than its label', () => {
39
+ // w-20 is 80px; "Address Type" cannot fit in 80 - 32 = 48px.
40
+ expect(getColumnMinWidth(col({ key: 'addressType', label: 'Address Type', width: 'w-20' })))
41
+ .toBeGreaterThan(80)
42
+ })
43
+
44
+ it('reserves extra room for the sort icon on sortable columns', () => {
45
+ const sortable = getColumnMinWidth(col({ key: 'status', label: 'Wide Label Here', width: 'w-16' }))
46
+ const plain = getColumnMinWidth(col({ key: 'notSortable', label: 'Wide Label Here', width: 'w-16' }))
47
+ expect(sortable).toBeGreaterThan(plain)
48
+ })
49
+
50
+ it('still honours an explicit minWidth override', () => {
51
+ expect(getColumnMinWidth(col({ key: 'x', label: 'Address Type', width: 'w-20', minWidth: 300 }))).toBe(300)
52
+ })
53
+
54
+ it('keeps the flexible Name column at its wider default', () => {
55
+ expect(getColumnMinWidth(col({ key: 'name', label: 'Name' }))).toBe(200)
56
+ })
57
+
58
+ // Printer columns (uncurated CRDs) and host-injected custom columns are
59
+ // sortable via their own getSortValue, not by being in the built-in key list —
60
+ // the header renders a sort icon for them, so the floor has to reserve it.
61
+ it('reserves the sort icon for a printer/custom column that carries getSortValue', () => {
62
+ const c = col({ key: 'printer:Phase', label: 'Reconcile Phase', width: 'w-24' })
63
+ const extras = new Map([[c.key, { ...c, render: () => null, getSortValue: () => '' }]]) as any
64
+ expect(getColumnMinWidth(c, extras)).toBeGreaterThan(getColumnMinWidth(c))
65
+ })
66
+
67
+ it('caps the floor so one long vendor label cannot size a column off screen', () => {
68
+ const monstrous = 'A'.repeat(200)
69
+ expect(getColumnMinWidth(col({ key: 'printer:x', label: monstrous, width: 'w-24' }))).toBe(320)
70
+ })
71
+
72
+ it('never returns less than the declared width, even past the cap', () => {
73
+ expect(getColumnMinWidth(col({ key: 'printer:x', label: 'A'.repeat(200), width: 'w-96' }))).toBe(384)
74
+ })
75
+ })
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getKyvernoPolicyAction } from './resource-utils-kyverno'
2
+ import {
3
+ getKyvernoPolicyAction,
4
+ getKyvernoPolicyAdmission,
5
+ getKyvernoEnforcement,
6
+ } from './resource-utils-kyverno'
3
7
 
4
8
  function cpol(spec: any) {
5
9
  return { apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', metadata: { name: 'p' }, spec }
@@ -95,4 +99,99 @@ describe('getKyvernoPolicyAction', () => {
95
99
  expect(getKyvernoPolicyAction(cpol({ rules: [{ name: 'r', validate: {} }] }))).toBe('Audit')
96
100
  expect(getKyvernoPolicyAction(cpol({}))).toBe('Audit')
97
101
  })
102
+
103
+ // Kyverno's ValidationFailureAction enum accepts the deprecated lowercase
104
+ // `enforce`/`audit` alongside the capitalized spelling, and the admission
105
+ // controller blocks on lowercase `enforce` exactly as on `Enforce`. A legacy
106
+ // policy carrying the lowercase value must not read as non-blocking.
107
+ it('treats a lowercase spec-level enforce as Enforce (no rules)', () => {
108
+ expect(getKyvernoPolicyAction(cpol({ validationFailureAction: 'enforce' }))).toBe('Enforce')
109
+ })
110
+
111
+ it('treats a lowercase spec-level enforce inherited by a validating rule as Enforce', () => {
112
+ expect(
113
+ getKyvernoPolicyAction(
114
+ cpol({
115
+ validationFailureAction: 'enforce',
116
+ rules: [{ name: 'v', validate: { pattern: {} } }],
117
+ }),
118
+ ),
119
+ ).toBe('Enforce')
120
+ })
121
+
122
+ it('canonicalizes a lowercase spec-level audit to Audit', () => {
123
+ expect(getKyvernoPolicyAction(cpol({ validationFailureAction: 'audit' }))).toBe('Audit')
124
+ expect(
125
+ getKyvernoPolicyAction(
126
+ cpol({
127
+ validationFailureAction: 'audit',
128
+ rules: [{ name: 'v', validate: { pattern: {} } }],
129
+ }),
130
+ ),
131
+ ).toBe('Audit')
132
+ })
133
+
134
+ // A per-rule failureAction is capitalized-only per its CRD enum, but matching
135
+ // case-insensitively there too keeps a single rule for reading the field.
136
+ it('treats a lowercase per-rule failureAction override as Enforce', () => {
137
+ expect(
138
+ getKyvernoPolicyAction(
139
+ cpol({
140
+ validationFailureAction: 'Audit',
141
+ rules: [{ name: 'r', validate: { failureAction: 'enforce' } }],
142
+ }),
143
+ ),
144
+ ).toBe('Enforce')
145
+ })
146
+ })
147
+
148
+ /**
149
+ * `spec.admission: false` leaves a legacy policy out of the admission webhook,
150
+ * so it rejects nothing whatever its failureAction. Confirmed against Kyverno's
151
+ * own AdmissionProcessingEnabled(): nil defaults to true.
152
+ */
153
+ describe('getKyvernoPolicyAdmission', () => {
154
+ it('defaults to true when the field is absent', () => {
155
+ expect(getKyvernoPolicyAdmission(cpol({}))).toBe(true)
156
+ expect(getKyvernoPolicyAdmission(cpol({ admission: true }))).toBe(true)
157
+ })
158
+
159
+ it('is false only when explicitly disabled', () => {
160
+ expect(getKyvernoPolicyAdmission(cpol({ admission: false }))).toBe(false)
161
+ })
162
+ })
163
+
164
+ describe('getKyvernoEnforcement', () => {
165
+ const enforcing = { validationFailureAction: 'Enforce', rules: [{ name: 'r', validate: { pattern: {} } }] }
166
+
167
+ it('blocks when admission is enabled (default) and the policy enforces', () => {
168
+ expect(getKyvernoEnforcement(cpol(enforcing))).toEqual({
169
+ label: 'Enforce',
170
+ blocks: true,
171
+ discrepancy: false,
172
+ })
173
+ })
174
+
175
+ it('does not block an Enforce policy whose admission is disabled', () => {
176
+ const e = getKyvernoEnforcement(cpol({ ...enforcing, admission: false }))
177
+ expect(e.blocks).toBe(false)
178
+ expect(e.discrepancy).toBe(true)
179
+ // Same label the modern CEL family uses for this posture.
180
+ expect(e.label).toBe('Background only')
181
+ })
182
+
183
+ it('reports a policy inactive when both admission and background are off', () => {
184
+ const e = getKyvernoEnforcement(cpol({ ...enforcing, admission: false, background: false }))
185
+ expect(e.blocks).toBe(false)
186
+ expect(e.label).toBe('Inactive')
187
+ })
188
+
189
+ it('leaves an Audit policy unchanged when admission is disabled', () => {
190
+ const e = getKyvernoEnforcement(cpol({
191
+ validationFailureAction: 'Audit',
192
+ admission: false,
193
+ rules: [{ name: 'r', validate: { pattern: {} } }],
194
+ }))
195
+ expect(e).toEqual({ label: 'Audit', blocks: false, discrepancy: false })
196
+ })
98
197
  })
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  getKyvernoEnforcementPosture,
4
+ getKyvernoGenerations,
4
5
  getKyvernoPolicyFamily,
5
6
  getKyvernoValidationActions,
6
7
  getKyvernoMatchSummary,
@@ -288,3 +289,38 @@ describe('match summary', () => {
288
289
  expect(getKyvernoMatchSummary(vpol({}))).toBe('-')
289
290
  })
290
291
  })
292
+
293
+ // A GeneratingPolicy declares each generated target as exactly one of an
294
+ // `expression` (CEL returning the resources) or a `template` (a YAML document,
295
+ // optionally CEL-interpolated). Both are first-class; the "Generates" count and
296
+ // list must see either form.
297
+ describe('generation targets', () => {
298
+ it('counts expression-form generations', () => {
299
+ const gen = getKyvernoGenerations(vpol({ generate: [{ expression: 'generator.Apply(ns, [cm])' }] }))
300
+ expect(gen).toEqual(['generator.Apply(ns, [cm])'])
301
+ })
302
+
303
+ it('counts template-form generations', () => {
304
+ const value = 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cfg\n'
305
+ const gen = getKyvernoGenerations(vpol({ generate: [{ template: { interpolate: 'none', value } }] }))
306
+ expect(gen).toHaveLength(1)
307
+ expect(gen[0]).toContain('ConfigMap')
308
+ })
309
+
310
+ it('counts a mix of expression and template forms', () => {
311
+ const gen = getKyvernoGenerations(
312
+ vpol({
313
+ generate: [
314
+ { expression: 'generator.Apply(ns, [cm])' },
315
+ { template: { value: 'kind: NetworkPolicy\n' } },
316
+ ],
317
+ }),
318
+ )
319
+ expect(gen).toHaveLength(2)
320
+ })
321
+
322
+ it('returns nothing for an empty or absent generate block', () => {
323
+ expect(getKyvernoGenerations(vpol({ generate: [] }))).toEqual([])
324
+ expect(getKyvernoGenerations(vpol({}))).toEqual([])
325
+ })
326
+ })
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CNPGBackupRenderer } from './CNPGBackupRenderer'
4
+
5
+ const backup = (extra: any = {}) => ({
6
+ apiVersion: 'postgresql.cnpg.io/v1',
7
+ kind: 'Backup',
8
+ metadata: { name: 'pg-backup', namespace: 'db' },
9
+ spec: { cluster: { name: 'pg' }, ...extra.spec },
10
+ status: { phase: 'completed', ...extra.status },
11
+ })
12
+
13
+ const html = (resource: any) => renderToString(<CNPGBackupRenderer data={resource} />)
14
+
15
+ describe('CNPGBackupRenderer — spec.target is instance selection, not a restore destination', () => {
16
+ it('labels spec.target as the instance the backup runs on, not a recovery target', () => {
17
+ // Backup.spec.target is `primary` | `prefer-standby`: the policy for which
18
+ // instance role performs the backup. "Recovery Target" would read as a
19
+ // PITR restore destination, which this field is not.
20
+ const out = html(backup({ spec: { target: 'prefer-standby' } }))
21
+ expect(out).toContain('prefer-standby')
22
+ expect(out).toContain('Backup Target')
23
+ expect(out).not.toContain('Recovery Target')
24
+ })
25
+
26
+ it('omits the target section entirely when spec.target is unset', () => {
27
+ expect(html(backup())).not.toContain('Backup Target')
28
+ })
29
+ })
@@ -127,11 +127,12 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
127
127
  </Section>
128
128
  )}
129
129
 
130
- {/* Target - for PITR backups */}
130
+ {/* spec.target is the policy for which instance runs the backup
131
+ (primary / prefer-standby); it is not a restore destination. */}
131
132
  {target !== '-' && (
132
133
  <Section title="Target" defaultExpanded>
133
134
  <PropertyList>
134
- <Property label="Recovery Target" value={target} />
135
+ <Property label="Backup Target" value={target} />
135
136
  </PropertyList>
136
137
  </Section>
137
138
  )}
@@ -39,9 +39,33 @@ describe('CNPGClusterRenderer — the drawer must not contradict the badge', ()
39
39
  expect(html(settled)).toContain('Degraded Cluster')
40
40
  })
41
41
 
42
- it('still raises Cluster Down at zero ready, whatever the phase', () => {
43
- // No phase excuses a database with nothing serving.
44
- expect(html(cluster('Upgrading cluster', 0))).toContain('Cluster Down')
42
+ it('still raises Cluster Down for a was-up cluster at zero ready, whatever the phase', () => {
43
+ // No phase excuses a database that was serving and now has nothing ready.
44
+ // readyInstances is OMITTED — the shape CNPG actually emits for zero ready —
45
+ // and currentPrimary is the "was up" signal that tells this apart from a
46
+ // first bootstrap.
47
+ const wasUpDown = {
48
+ apiVersion: 'postgresql.cnpg.io/v1',
49
+ kind: 'Cluster',
50
+ metadata: { name: 'pg', namespace: 'db' },
51
+ spec: { instances: 3 },
52
+ status: { phase: 'Upgrading cluster', currentPrimary: 'pg-1' },
53
+ }
54
+ expect(html(wasUpDown)).toContain('Cluster Down')
55
+ })
56
+
57
+ it('does not raise Cluster Down for a bootstrapping cluster with no primary yet', () => {
58
+ // Reported (phase) but no primary elected and readyInstances omitted — the
59
+ // shape a fresh cluster emits. A red banner here is the false alarm the
60
+ // availability verdict exists to prevent.
61
+ const bootstrapping = {
62
+ apiVersion: 'postgresql.cnpg.io/v1',
63
+ kind: 'Cluster',
64
+ metadata: { name: 'pg', namespace: 'db' },
65
+ spec: { instances: 3 },
66
+ status: { phase: 'Setting up primary' },
67
+ }
68
+ expect(html(bootstrapping)).not.toContain('Cluster Down')
45
69
  })
46
70
 
47
71
  it('renders a failed last backup at the same tier as the badge and detector', () => {
@@ -116,3 +140,45 @@ describe('recovery points must be readable as ages, not raw machine timestamps',
116
140
  expect(html).toContain('2026-01-30 09:40:37 UTC')
117
141
  })
118
142
  })
143
+
144
+ describe('CNPGClusterRenderer — the replica-cluster role tracks live state, not stanza presence', () => {
145
+ const withReplica = (replica: any, name = 'pg-eu') => ({
146
+ apiVersion: 'postgresql.cnpg.io/v1',
147
+ kind: 'Cluster',
148
+ metadata: { name, namespace: 'db' },
149
+ spec: { instances: 3, replica },
150
+ status: { phase: 'Cluster in healthy state', readyInstances: 3 },
151
+ })
152
+
153
+ it('shows the Replica Cluster section for a genuine replica (replica mode enabled)', () => {
154
+ const out = renderToString(<CNPGClusterRenderer data={withReplica({ source: 'pg-us', enabled: true })} />)
155
+ expect(out).toContain('Replica Cluster')
156
+ expect(out).toContain('Role')
157
+ expect(out).toContain('pg-us')
158
+ })
159
+
160
+ it('shows Replica for a distributed-topology cluster whose primary is elsewhere', () => {
161
+ const out = renderToString(
162
+ <CNPGClusterRenderer data={withReplica({ source: 'pg-us', primary: 'pg-us', self: 'pg-eu' })} />,
163
+ )
164
+ expect(out).toContain('Replica Cluster')
165
+ })
166
+
167
+ it('drops the Replica label once a distributed replica is promoted, though the stanza remains', () => {
168
+ // Promotion sets replica.primary to this cluster; the stanza is not removed.
169
+ const out = renderToString(
170
+ <CNPGClusterRenderer data={withReplica({ source: 'pg-us', primary: 'pg-eu', self: 'pg-eu' })} />,
171
+ )
172
+ expect(out).not.toContain('Replica Cluster')
173
+ })
174
+
175
+ it('drops the Replica label once a standalone replica is promoted (replica.enabled off)', () => {
176
+ const out = renderToString(<CNPGClusterRenderer data={withReplica({ source: 'pg-us', enabled: false })} />)
177
+ expect(out).not.toContain('Replica Cluster')
178
+ })
179
+
180
+ it('a normal standalone cluster shows no Replica Cluster section', () => {
181
+ const out = renderToString(<CNPGClusterRenderer data={cluster('Cluster in healthy state', 3)} />)
182
+ expect(out).not.toContain('Replica Cluster')
183
+ })
184
+ })
@@ -23,6 +23,7 @@ import {
23
23
  getCNPGWALArchivingFailure,
24
24
  getCNPGLastBackupFailure,
25
25
  classifyCNPGClusterPhase,
26
+ getCNPGClusterAvailability,
26
27
  CNPG_BARMAN_OBJECTSTORE_GROUP,
27
28
  } from '../resource-utils-cnpg'
28
29
  import { formatAge } from '../resource-utils'
@@ -79,8 +80,11 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
79
80
  const lastBackupFailure = getCNPGLastBackupFailure(data)
80
81
  const phaseBucket = classifyCNPGClusterPhase(phase)
81
82
 
82
- // Problem detection
83
- const isDown = countsKnown && readyInstances === 0
83
+ // Problem detection. isDown reuses the badge's availability verdict so the
84
+ // three surfaces agree: a fully-down cluster CNPG reports by omitting
85
+ // readyInstances (countsKnown false) is unreachable through the raw counts,
86
+ // and hibernation / all-fenced are deliberate not-serving states, not outages.
87
+ const isDown = getCNPGClusterAvailability(data) === 'down'
84
88
  // A shortfall the PHASE already explains is not a separate problem. Mirrors
85
89
  // source_cnpg.go's phaseExplained gate and the badge's ordering, both of
86
90
  // which check the phase before the instance counts: mid-switchover or waiting
@@ -8,7 +8,8 @@ import {
8
8
  getPolicyReportResults,
9
9
  getPolicyReportScope,
10
10
  getPolicyReportSource,
11
- getKyvernoPolicyAction,
11
+ getKyvernoEnforcement,
12
+ getKyvernoPolicyAdmission,
12
13
  getKyvernoPolicyRuleCount,
13
14
  getKyvernoPolicyBackground,
14
15
  getKyvernoPolicyRules,
@@ -246,15 +247,14 @@ export function KyvernoPolicyRenderer({ data, coverage, queued }: KyvernoPolicyR
246
247
  const spec = data.spec || {}
247
248
  const status = data.status || {}
248
249
  const conditions = status.conditions || []
249
- const action = getKyvernoPolicyAction(data)
250
+ const enforcement = getKyvernoEnforcement(data)
251
+ const admission = getKyvernoPolicyAdmission(data)
250
252
  const ruleCount = getKyvernoPolicyRuleCount(data)
251
253
  const background = getKyvernoPolicyBackground(data)
252
254
  const rules = getKyvernoPolicyRules(data)
253
255
  const ruleCountByType = getKyvernoPolicyRuleCountByType(data)
254
256
  const autogenRules = getKyvernoPolicyAutogenRules(data)
255
257
 
256
- const isEnforce = action === 'Enforce'
257
-
258
258
  return (
259
259
  <>
260
260
  {coverage}
@@ -269,15 +269,20 @@ export function KyvernoPolicyRenderer({ data, coverage, queued }: KyvernoPolicyR
269
269
  writing to the cluster. status.rulecount is Kyverno's own count,
270
270
  so this is checkable rather than guessed. */}
271
271
  {(ruleCountByType.validate > 0 || ruleCountByType.verifyImages > 0) && (
272
- <Property label="Failure Action" value={
272
+ <Property label="Enforcement" value={
273
273
  <span className={clsx(
274
274
  'badge',
275
- isEnforce ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400',
275
+ enforcement.blocks
276
+ ? 'bg-red-500/20 text-red-400'
277
+ : enforcement.discrepancy
278
+ ? 'bg-orange-500/20 text-orange-400'
279
+ : 'bg-yellow-500/20 text-yellow-400',
276
280
  )}>
277
- {action}
281
+ {enforcement.label}
278
282
  </span>
279
283
  } />
280
284
  )}
285
+ <Property label="Admission" value={admission ? 'Enabled' : 'Disabled'} />
281
286
  <Property label="Background" value={background ? 'Enabled' : 'Disabled'} />
282
287
  {spec.webhookTimeoutSeconds && (
283
288
  <Property label="Webhook Timeout" value={`${spec.webhookTimeoutSeconds}s`} />
@@ -4,7 +4,7 @@ import { PolicyCoverageSection } from './PolicyCoverageSection'
4
4
  import type { PolicyCoverageResponse, PolicyCoverageRule, PolicyCoverageSubject } from '../../../types/policy'
5
5
 
6
6
  const vpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'ValidatingPolicy', spec })
7
- const mpol = () => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec: {} })
7
+ const mpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec })
8
8
  const legacy = (spec: any) => ({ apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', spec })
9
9
 
10
10
  const counts = (c: Partial<PolicyCoverageResponse['counts']> = {}) =>
@@ -82,6 +82,97 @@ describe('what the section says a policy did', () => {
82
82
  expect(html).not.toContain('Generated')
83
83
  })
84
84
 
85
+ // A legacy Enforce policy with `spec.admission: false` is left out of the
86
+ // admission webhook, so it rejects nothing — the consequence sentence must not
87
+ // promise a rejection it cannot make.
88
+ it('does not promise rejection when admission evaluation is disabled', () => {
89
+ const html = render(
90
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
91
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
92
+ legacy({ validationFailureAction: 'Enforce', admission: false,
93
+ rules: [{ name: 'r', validate: { pattern: {} } }] }),
94
+ )
95
+ expect(html).toContain('Nothing is rejected today')
96
+ expect(html).not.toContain('will be rejected')
97
+ })
98
+
99
+ // The policy already declares Enforce, so the remediation is to re-enable
100
+ // admission — not to "switch this policy to block", which would point at the
101
+ // wrong knob.
102
+ it('names admission, not the action, as the reason nothing is rejected', () => {
103
+ const html = render(
104
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
105
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
106
+ legacy({ validationFailureAction: 'Enforce', admission: false,
107
+ rules: [{ name: 'r', validate: { pattern: {} } }] }),
108
+ )
109
+ expect(html).toContain('admission is disabled for this policy')
110
+ expect(html).toContain('Enabling admission would reject the next update')
111
+ expect(html).not.toContain('Switching this policy to block')
112
+ })
113
+
114
+ // The modern CEL family reaches the same non-blocking state via
115
+ // `spec.evaluation.admission.enabled: false`, and the shared sentence must be
116
+ // honest there too.
117
+ it('names admission for a modern Deny policy with admission disabled', () => {
118
+ const html = render(
119
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
120
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
121
+ vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
122
+ )
123
+ expect(html).toContain('admission is disabled for this policy')
124
+ expect(html).not.toContain('Switching this policy to block')
125
+ })
126
+
127
+ // A JSON-mode policy is evaluated against payloads outside the cluster and
128
+ // never joins admission. Both phrasings would be false: it rejects nothing
129
+ // today, and neither switching the action nor enabling admission changes
130
+ // that. The sentence is withheld rather than guessed at.
131
+ it('states no admission consequence for a JSON-mode policy', () => {
132
+ const failing = coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
133
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] })
134
+
135
+ const admissionOff = render(failing, vpol({
136
+ validationActions: ['Deny'],
137
+ evaluation: { mode: 'JSON', admission: { enabled: false } },
138
+ }))
139
+ expect(admissionOff).not.toContain('admission is disabled for this policy')
140
+ expect(admissionOff).not.toContain('Switching this policy to block')
141
+
142
+ // Same for JSON mode with admission left at its default.
143
+ const admissionDefault = render(failing, vpol({
144
+ validationActions: ['Deny'],
145
+ evaluation: { mode: 'JSON' },
146
+ }))
147
+ expect(admissionDefault).not.toContain('Switching this policy to block')
148
+ expect(admissionDefault).not.toContain('will be rejected')
149
+ })
150
+
151
+ // `getKyvernoValidationActions` applies Kyverno's absent-means-Deny default,
152
+ // which is right for the validating families and wrong for every other kind.
153
+ // A mutating policy with admission off must not be described as a policy that
154
+ // would reject once admission is re-enabled.
155
+ it('does not read a Deny default off a mutating policy with admission disabled', () => {
156
+ const html = render(
157
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
158
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
159
+ mpol({ evaluation: { admission: { enabled: false } } }),
160
+ )
161
+ expect(html).not.toContain('admission is disabled for this policy')
162
+ })
163
+
164
+ // An Audit policy genuinely never blocks, so its remediation IS to switch the
165
+ // action — the admission-off wording must not leak onto it.
166
+ it('still offers to switch the action for an audit policy', () => {
167
+ const html = render(
168
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
169
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
170
+ vpol({ validationActions: ['Audit'] }),
171
+ )
172
+ expect(html).toContain('Switching this policy to block')
173
+ expect(html).not.toContain('admission is disabled')
174
+ })
175
+
85
176
  it('withholds the consequence when the policy’s rules disagree on blocking', () => {
86
177
  const html = render(
87
178
  coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),