@skyhook-io/k8s-ui 1.13.4 → 1.14.0
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 +4 -4
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
- package/src/components/resources/resource-utils-kueue.ts +4 -2
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/ui/SelectMenu.tsx +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@monaco-editor/react": "^4.7.0",
|
|
71
71
|
"html-to-image": "^1.11.0",
|
|
72
72
|
"monaco-yaml": "5.5.1",
|
|
73
|
-
"react-virtuoso": "^4.18.
|
|
73
|
+
"react-virtuoso": "^4.18.12",
|
|
74
74
|
"shiki": "^4.4.2"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
@@ -98,12 +98,12 @@
|
|
|
98
98
|
"clsx": "^2.1.1",
|
|
99
99
|
"diff": "^9.0.0",
|
|
100
100
|
"elkjs": "^0.11.1",
|
|
101
|
-
"lucide-react": "^1.
|
|
101
|
+
"lucide-react": "^1.37.0",
|
|
102
102
|
"monaco-editor": "0.55.1",
|
|
103
103
|
"react": "^19.2.8",
|
|
104
104
|
"react-dom": "^19.2.8",
|
|
105
105
|
"typescript": "^6.0.2",
|
|
106
|
-
"vitest": "^4.1.
|
|
106
|
+
"vitest": "^4.1.11",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
108
108
|
}
|
|
109
109
|
}
|
|
@@ -1116,14 +1116,14 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1116
1116
|
{ key: 'name', label: 'Name' },
|
|
1117
1117
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
1118
1118
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1119
|
-
{ key: 'action', label: 'Action', width: 'w-
|
|
1119
|
+
{ key: 'action', label: 'Action', width: 'w-36', tooltip: 'Enforcement at admission — Enforce blocks, Audit reports; Background only or Inactive when admission is disabled' },
|
|
1120
1120
|
{ key: 'rules', label: 'Rules', width: 'w-16' },
|
|
1121
1121
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1122
1122
|
],
|
|
1123
1123
|
clusterpolicies: [
|
|
1124
1124
|
{ key: 'name', label: 'Name' },
|
|
1125
1125
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1126
|
-
{ key: 'action', label: 'Action', width: 'w-
|
|
1126
|
+
{ key: 'action', label: 'Action', width: 'w-36', tooltip: 'Enforcement at admission — Enforce blocks, Audit reports; Background only or Inactive when admission is disabled' },
|
|
1127
1127
|
{ key: 'rules', label: 'Rules', width: 'w-16' },
|
|
1128
1128
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1129
1129
|
],
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
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
|
-
{/*
|
|
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="
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
272
|
+
<Property label="Enforcement" value={
|
|
273
273
|
<span className={clsx(
|
|
274
274
|
'badge',
|
|
275
|
-
|
|
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
|
-
{
|
|
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 }),
|
|
@@ -11,9 +11,17 @@ import {
|
|
|
11
11
|
getKyvernoEnforcementPosture,
|
|
12
12
|
getKyvernoResourceRules,
|
|
13
13
|
getKyvernoEffectiveFailurePolicy,
|
|
14
|
+
getKyvernoValidationActions,
|
|
15
|
+
getKyvernoEvaluationMode,
|
|
16
|
+
isKyvernoAdmissionEnabled,
|
|
14
17
|
type KyvernoPolicyFamily,
|
|
15
18
|
} from '../resource-utils-kyverno-modern'
|
|
16
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
getKyvernoEnforcement,
|
|
21
|
+
getKyvernoPolicyAction,
|
|
22
|
+
getKyvernoPolicyAdmission,
|
|
23
|
+
isKyvernoEnforceAction,
|
|
24
|
+
} from '../resource-utils-kyverno'
|
|
17
25
|
import type {
|
|
18
26
|
PolicyCoverageResponse,
|
|
19
27
|
PolicyCoverageRule,
|
|
@@ -160,7 +168,7 @@ function legacyActionsAgree(resource: any): boolean {
|
|
|
160
168
|
: []),
|
|
161
169
|
].filter(Boolean)
|
|
162
170
|
const effective = declared.length > 0 ? declared : [spec]
|
|
163
|
-
return effective.
|
|
171
|
+
return effective.some(isKyvernoEnforceAction)
|
|
164
172
|
})
|
|
165
173
|
return answers.every((a: boolean) => a === answers[0])
|
|
166
174
|
}
|
|
@@ -229,17 +237,40 @@ function policyFamilyFor(
|
|
|
229
237
|
|
|
230
238
|
/**
|
|
231
239
|
* The families answer from different fields: legacy carries
|
|
232
|
-
* `spec.validationFailureAction` (or a per-rule `validate.failureAction`)
|
|
233
|
-
*
|
|
234
|
-
*
|
|
240
|
+
* `spec.validationFailureAction` (or a per-rule `validate.failureAction`) gated
|
|
241
|
+
* by `spec.admission`, while `getKyvernoEnforcementPosture` reads
|
|
242
|
+
* `spec.validationActions` and `spec.evaluation.admission.enabled`, both absent
|
|
243
|
+
* on legacy — which makes an Enforce policy read as audit-only.
|
|
235
244
|
*/
|
|
236
245
|
function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
|
|
237
246
|
if (isLegacyKyvernoPolicy(resource)) {
|
|
238
|
-
return
|
|
247
|
+
return getKyvernoEnforcement(resource).blocks
|
|
239
248
|
}
|
|
240
249
|
return getKyvernoEnforcementPosture(resource, family).blocks
|
|
241
250
|
}
|
|
242
251
|
|
|
252
|
+
/**
|
|
253
|
+
* A policy declares a rejecting action yet does not block, because admission
|
|
254
|
+
* evaluation is switched off. The distinction changes the remediation the
|
|
255
|
+
* consequence sentence offers — re-enable admission, not switch the action —
|
|
256
|
+
* so it must not be conflated with an audit policy that simply never blocks.
|
|
257
|
+
*
|
|
258
|
+
* Only the validating families carry `validationActions`, and
|
|
259
|
+
* `getKyvernoValidationActions` applies Kyverno's absent-means-Deny default,
|
|
260
|
+
* so asking a mutating or generating policy would read Deny off a field that
|
|
261
|
+
* kind never has. The same guard `getKyvernoEnforcementPosture` uses.
|
|
262
|
+
*/
|
|
263
|
+
function declaresBlockingButAdmissionOff(
|
|
264
|
+
resource: any,
|
|
265
|
+
family: KyvernoPolicyFamily | undefined,
|
|
266
|
+
): boolean {
|
|
267
|
+
if (isLegacyKyvernoPolicy(resource)) {
|
|
268
|
+
return getKyvernoPolicyAction(resource) === 'Enforce' && !getKyvernoPolicyAdmission(resource)
|
|
269
|
+
}
|
|
270
|
+
if (family !== 'validating' && family !== 'imageValidating') return false
|
|
271
|
+
return getKyvernoValidationActions(resource).includes('Deny') && !isKyvernoAdmissionEnabled(resource)
|
|
272
|
+
}
|
|
273
|
+
|
|
243
274
|
/**
|
|
244
275
|
* Whether the consequence sentence can be stated at all.
|
|
245
276
|
*
|
|
@@ -250,6 +281,11 @@ function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined)
|
|
|
250
281
|
*/
|
|
251
282
|
function canStateConsequence(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
|
|
252
283
|
if (!isLegacyKyvernoPolicy(resource)) {
|
|
284
|
+
// A JSON-mode policy is evaluated against payloads outside the cluster and
|
|
285
|
+
// never joins admission, so every phrasing of the sentence is false: it
|
|
286
|
+
// rejects nothing today, and neither switching the action nor enabling
|
|
287
|
+
// admission would change that. The posture badge already says "JSON mode".
|
|
288
|
+
if (getKyvernoEvaluationMode(resource) === 'JSON') return false
|
|
253
289
|
return family === 'validating' || family === 'imageValidating' || family === undefined
|
|
254
290
|
}
|
|
255
291
|
const rules: any[] = resource?.spec?.rules ?? []
|
|
@@ -273,6 +309,7 @@ function enforcementConsequence(
|
|
|
273
309
|
failing: number,
|
|
274
310
|
blocks: boolean,
|
|
275
311
|
matchesUpdate: boolean,
|
|
312
|
+
admissionOff = false,
|
|
276
313
|
): string {
|
|
277
314
|
// Deliberately kind-neutral. A policy can match ConfigMaps, Secrets or
|
|
278
315
|
// ClusterRoles as readily as Pods, and none of those "run" — the fact being
|
|
@@ -288,11 +325,24 @@ function enforcementConsequence(
|
|
|
288
325
|
: `${exists} and ${one ? 'stays' : 'stay'} as ${itIs} — this policy only checks creation, so ${itIs} rejected only if recreated.`
|
|
289
326
|
}
|
|
290
327
|
|
|
328
|
+
const target = one ? 'this resource' : `any of these ${failing}`
|
|
329
|
+
|
|
330
|
+
// The policy already declares a rejecting action; it does not block only
|
|
331
|
+
// because admission evaluation is disabled. Telling the operator to change
|
|
332
|
+
// the action would point at the wrong knob — the fix is to re-enable
|
|
333
|
+
// admission.
|
|
334
|
+
if (admissionOff) {
|
|
335
|
+
return matchesUpdate
|
|
336
|
+
? `Nothing is rejected today because admission is disabled for this policy. Enabling admission would reject the next update to ${target}.`
|
|
337
|
+
: `Nothing is rejected today because admission is disabled for this policy. Enabling admission would leave ${
|
|
338
|
+
one ? 'this resource as it is' : `these ${failing} as they are`
|
|
339
|
+
} — only newly created resources would be rejected.`
|
|
340
|
+
}
|
|
341
|
+
|
|
291
342
|
// "Set to enforce" is the legacy family's vocabulary; the modern API spells
|
|
292
343
|
// the same thing `validationActions: [Deny]`, and the posture badge above
|
|
293
344
|
// already says "Deny". Describing the behaviour rather than the field keeps
|
|
294
345
|
// one sentence correct for both.
|
|
295
|
-
const target = one ? 'this resource' : `any of these ${failing}`
|
|
296
346
|
return matchesUpdate
|
|
297
347
|
? `Nothing is rejected today. Switching this policy to block would reject the next update to ${target}.`
|
|
298
348
|
: `Nothing is rejected today. Switching this policy to block would leave ${
|
|
@@ -469,8 +519,14 @@ function Consequence({
|
|
|
469
519
|
const failing = data.subjectsFailing ?? data.counts.fail
|
|
470
520
|
|
|
471
521
|
if (failing > 0 && canStateConsequence(resource, family)) {
|
|
522
|
+
const blocks = blocksAdmission(resource, family)
|
|
472
523
|
lines.push(
|
|
473
|
-
enforcementConsequence(
|
|
524
|
+
enforcementConsequence(
|
|
525
|
+
failing,
|
|
526
|
+
blocks,
|
|
527
|
+
matchesUpdates(resource),
|
|
528
|
+
!blocks && declaresBlockingButAdmissionOff(resource, family),
|
|
529
|
+
),
|
|
474
530
|
)
|
|
475
531
|
}
|
|
476
532
|
|