@skyhook-io/k8s-ui 1.12.0 → 1.13.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/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +410 -5
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +12 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +3 -0
- package/src/types/core.ts +29 -0
- package/src/types/policy.ts +153 -2
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { LookupFailureNote } from './LookupFailureNote'
|
|
4
|
+
|
|
5
|
+
function err(message: string, status?: number): Error {
|
|
6
|
+
return Object.assign(new Error(message), status === undefined ? {} : { status })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('LookupFailureNote', () => {
|
|
10
|
+
it('says nothing when every lookup came back', () => {
|
|
11
|
+
expect(renderToString(<LookupFailureNote errors={[]} what="x" />)).toBe('')
|
|
12
|
+
expect(renderToString(<LookupFailureNote errors={[null, undefined]} what="x" />)).toBe('')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// Being unable to see something is expected and not actionable; a fault is
|
|
16
|
+
// neither. The two must not read alike.
|
|
17
|
+
it('renders a permission answer as a calm note, not a red error', () => {
|
|
18
|
+
const html = renderToString(
|
|
19
|
+
<LookupFailureNote errors={[err('forbidden', 403)]} what="which clusters use this store" />,
|
|
20
|
+
)
|
|
21
|
+
expect(html).toContain('permission')
|
|
22
|
+
expect(html).not.toContain('text-red-400')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps a genuine fault loud, with the reason', () => {
|
|
26
|
+
const html = renderToString(
|
|
27
|
+
<LookupFailureNote errors={[err('Failed to fetch')]} what="what replicates here" />,
|
|
28
|
+
)
|
|
29
|
+
expect(html).toContain('text-red-400')
|
|
30
|
+
expect(html).toContain('Failed to fetch')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// A fault is actionable and a permission answer is not, so the actionable one
|
|
34
|
+
// is the one worth showing when both happened.
|
|
35
|
+
it('prefers the fault when a lookup was both forbidden and broken', () => {
|
|
36
|
+
const html = renderToString(
|
|
37
|
+
<LookupFailureNote errors={[err('forbidden', 403), err('boom', 500)]} what="x" />,
|
|
38
|
+
)
|
|
39
|
+
expect(html).toContain('boom')
|
|
40
|
+
expect(html).toContain('text-red-400')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// The case that loses counts: rows are on screen, so the failure means the
|
|
44
|
+
// list is short — not that nothing was found.
|
|
45
|
+
it('says the list is incomplete rather than unchecked when rows are showing', () => {
|
|
46
|
+
const html = renderToString(
|
|
47
|
+
<LookupFailureNote errors={[err('boom', 500)]} what="x" incomplete />,
|
|
48
|
+
)
|
|
49
|
+
expect(html).toContain('This may be incomplete')
|
|
50
|
+
expect(html).toContain('boom')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does the same for a partial permission answer', () => {
|
|
54
|
+
const html = renderToString(
|
|
55
|
+
<LookupFailureNote errors={[err('forbidden', 403)]} what="x" incomplete />,
|
|
56
|
+
)
|
|
57
|
+
expect(html).toContain('may be incomplete')
|
|
58
|
+
expect(html).not.toContain('text-red-400')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isForbiddenError } from '../../../types/fetch-error'
|
|
2
|
+
|
|
3
|
+
interface LookupFailureNoteProps {
|
|
4
|
+
/** Nulls are ignored, so callers can pass `[a.error, b.error]` from their hooks. */
|
|
5
|
+
errors: unknown[]
|
|
6
|
+
/** Tail of a sentence: "which clusters use this store". */
|
|
7
|
+
what: string
|
|
8
|
+
/** Rows are already on screen, so the failure shortened the list rather than
|
|
9
|
+
* emptying it. */
|
|
10
|
+
incomplete?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A reverse lookup that did not come back, told apart from one that found
|
|
15
|
+
* nothing. Same split as RBACErrorSection and sharing its predicate: a denial is
|
|
16
|
+
* expected and reads calm, a fault stays loud.
|
|
17
|
+
*/
|
|
18
|
+
export function LookupFailureNote({ errors, what, incomplete }: LookupFailureNoteProps) {
|
|
19
|
+
const real = errors.filter(Boolean)
|
|
20
|
+
if (real.length === 0) return null
|
|
21
|
+
|
|
22
|
+
// A fault outranks a denial: only one of the two is actionable.
|
|
23
|
+
const fault = real.find((e) => !isForbiddenError(e))
|
|
24
|
+
if (!fault) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
27
|
+
{incomplete
|
|
28
|
+
? `This may be incomplete: you don’t have permission to check ${what}.`
|
|
29
|
+
: `You don’t have permission to check ${what}.`}
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const message = fault instanceof Error ? fault.message : String(fault)
|
|
35
|
+
return (
|
|
36
|
+
<div className="text-xs text-red-400">
|
|
37
|
+
{incomplete
|
|
38
|
+
? `This may be incomplete — could not check ${what}: ${message}`
|
|
39
|
+
: `Could not check ${what}: ${message}`}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { clsx } from 'clsx'
|
|
2
|
+
import { Badge } from '../../ui/Badge'
|
|
2
3
|
import { Tooltip } from '../../ui/Tooltip'
|
|
4
|
+
import {
|
|
5
|
+
NETWORK_POLICY_PEER_DOTS,
|
|
6
|
+
NETWORK_POLICY_PEER_STYLES,
|
|
7
|
+
type NetworkPolicyPeerType,
|
|
8
|
+
} from './network-policy-peer-styles'
|
|
3
9
|
|
|
4
10
|
interface NetworkPolicyDiagramProps {
|
|
5
11
|
spec: any
|
|
12
|
+
staged?: boolean
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
/**
|
|
@@ -11,7 +18,7 @@ interface NetworkPolicyDiagramProps {
|
|
|
11
18
|
* Each rule is a horizontal band. Peers within a rule are OR'd (stacked).
|
|
12
19
|
* Pure CSS/SVG — no ReactFlow needed for this static diagram.
|
|
13
20
|
*/
|
|
14
|
-
export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
21
|
+
export function NetworkPolicyDiagram({ spec, staged = false }: NetworkPolicyDiagramProps) {
|
|
15
22
|
const podSelector = spec.podSelector || {}
|
|
16
23
|
const matchLabels = podSelector.matchLabels || {}
|
|
17
24
|
const policyTypes: string[] = spec.policyTypes || []
|
|
@@ -29,6 +36,13 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
29
36
|
|
|
30
37
|
return (
|
|
31
38
|
<div className="card-inner-lg space-y-3">
|
|
39
|
+
{staged && (
|
|
40
|
+
<div className="flex items-center gap-2 text-[11px] text-theme-text-tertiary">
|
|
41
|
+
<Badge severity="warning" size="sm">Staged preview</Badge>
|
|
42
|
+
<span>Dashed paths are evaluated but not enforced</span>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
32
46
|
{/* Ingress flows */}
|
|
33
47
|
{hasIngress && (
|
|
34
48
|
<div className="space-y-2">
|
|
@@ -43,6 +57,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
43
57
|
ports={[]}
|
|
44
58
|
direction="ingress"
|
|
45
59
|
denied
|
|
60
|
+
staged={staged}
|
|
46
61
|
/>
|
|
47
62
|
) : (
|
|
48
63
|
ingress?.map((rule, i) => (
|
|
@@ -52,6 +67,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
52
67
|
target={targetLabel}
|
|
53
68
|
ports={extractPorts(rule.ports)}
|
|
54
69
|
direction="ingress"
|
|
70
|
+
staged={staged}
|
|
55
71
|
/>
|
|
56
72
|
))
|
|
57
73
|
)}
|
|
@@ -72,6 +88,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
72
88
|
ports={[]}
|
|
73
89
|
direction="egress"
|
|
74
90
|
denied
|
|
91
|
+
staged={staged}
|
|
75
92
|
/>
|
|
76
93
|
) : (
|
|
77
94
|
egress?.map((rule, i) => (
|
|
@@ -81,6 +98,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
81
98
|
target={targetLabel}
|
|
82
99
|
ports={extractPorts(rule.ports)}
|
|
83
100
|
direction="egress"
|
|
101
|
+
staged={staged}
|
|
84
102
|
/>
|
|
85
103
|
))
|
|
86
104
|
)}
|
|
@@ -97,7 +115,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
|
|
|
97
115
|
interface PeerInfo {
|
|
98
116
|
label: string
|
|
99
117
|
sublabel?: string
|
|
100
|
-
type:
|
|
118
|
+
type: NetworkPolicyPeerType
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
function extractPeers(peers: any[] | undefined): PeerInfo[] {
|
|
@@ -155,37 +173,20 @@ function formatSelector(selector: any): string {
|
|
|
155
173
|
return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
156
174
|
}
|
|
157
175
|
|
|
158
|
-
// Color coding for peer types
|
|
159
|
-
const PEER_STYLES = {
|
|
160
|
-
pod: 'border-emerald-500/30 bg-emerald-500/8',
|
|
161
|
-
namespace: 'border-sky-500/30 bg-sky-500/8',
|
|
162
|
-
cidr: 'border-amber-500/30 bg-amber-500/8',
|
|
163
|
-
combined: 'border-emerald-500/30 bg-emerald-500/8',
|
|
164
|
-
all: 'border-theme-border bg-theme-elevated/50',
|
|
165
|
-
deny: 'border-red-500/30 bg-red-500/8',
|
|
166
|
-
} as const
|
|
167
|
-
|
|
168
|
-
const PEER_DOT = {
|
|
169
|
-
pod: 'bg-emerald-500',
|
|
170
|
-
namespace: 'bg-sky-500',
|
|
171
|
-
cidr: 'bg-amber-500',
|
|
172
|
-
combined: 'bg-emerald-500',
|
|
173
|
-
all: 'bg-theme-text-tertiary',
|
|
174
|
-
deny: 'bg-red-500',
|
|
175
|
-
} as const
|
|
176
|
-
|
|
177
176
|
function FlowRow({
|
|
178
177
|
sources,
|
|
179
178
|
target,
|
|
180
179
|
ports,
|
|
181
180
|
direction,
|
|
182
181
|
denied = false,
|
|
182
|
+
staged = false,
|
|
183
183
|
}: {
|
|
184
184
|
sources: PeerInfo[]
|
|
185
185
|
target: string
|
|
186
186
|
ports: string[]
|
|
187
187
|
direction: 'ingress' | 'egress'
|
|
188
188
|
denied?: boolean
|
|
189
|
+
staged?: boolean
|
|
189
190
|
}) {
|
|
190
191
|
const isIngress = direction === 'ingress'
|
|
191
192
|
|
|
@@ -200,11 +201,11 @@ function FlowRow({
|
|
|
200
201
|
<div
|
|
201
202
|
className={clsx(
|
|
202
203
|
'rounded-md border px-2 py-1.5 min-w-0 overflow-hidden',
|
|
203
|
-
|
|
204
|
+
NETWORK_POLICY_PEER_STYLES[peer.type],
|
|
204
205
|
)}
|
|
205
206
|
>
|
|
206
207
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
207
|
-
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0',
|
|
208
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', NETWORK_POLICY_PEER_DOTS[peer.type])} />
|
|
208
209
|
<span className={clsx('text-[11px] font-medium truncate min-w-0', denied && 'line-through text-red-400')}>
|
|
209
210
|
{peer.label}
|
|
210
211
|
</span>
|
|
@@ -245,14 +246,14 @@ function FlowRow({
|
|
|
245
246
|
x1="2" y1="10" x2="32" y2="10"
|
|
246
247
|
stroke={denied ? '#ef4444' : direction === 'ingress' ? '#3b82f6' : '#a855f7'}
|
|
247
248
|
strokeWidth="1.5"
|
|
248
|
-
strokeDasharray={denied ? '3 2' : undefined}
|
|
249
|
+
strokeDasharray={staged ? '4 3' : denied ? '3 2' : undefined}
|
|
249
250
|
markerEnd={`url(#arrow-${direction}-${denied ? 'denied' : 'ok'})`}
|
|
250
251
|
/>
|
|
251
252
|
</svg>
|
|
252
253
|
{ports.length > 0 && (
|
|
253
|
-
<div className="flex flex-
|
|
254
|
+
<div className="flex flex-col items-center gap-0.5 mt-0.5">
|
|
254
255
|
{ports.map((p, i) => (
|
|
255
|
-
<span key={i} className="text-[8px] text-theme-text-tertiary font-mono leading-none">{p}</span>
|
|
256
|
+
<span key={i} className="whitespace-nowrap text-[8px] text-theme-text-tertiary font-mono leading-none">{p}</span>
|
|
256
257
|
))}
|
|
257
258
|
</div>
|
|
258
259
|
)}
|
|
@@ -5,9 +5,10 @@ import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
|
|
|
5
5
|
|
|
6
6
|
interface NetworkPolicyRendererProps {
|
|
7
7
|
data: any
|
|
8
|
+
staged?: boolean
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function NetworkPolicyRenderer({ data }: NetworkPolicyRendererProps) {
|
|
11
|
+
export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRendererProps) {
|
|
11
12
|
const spec = data.spec || {}
|
|
12
13
|
const podSelector = spec.podSelector || {}
|
|
13
14
|
const policyTypes: string[] = spec.policyTypes || []
|
|
@@ -23,7 +24,7 @@ export function NetworkPolicyRenderer({ data }: NetworkPolicyRendererProps) {
|
|
|
23
24
|
<>
|
|
24
25
|
{hasDiagramContent && (
|
|
25
26
|
<Section title="Policy Flow" icon={GitFork} defaultExpanded>
|
|
26
|
-
<NetworkPolicyDiagram spec={spec} />
|
|
27
|
+
<NetworkPolicyDiagram spec={spec} staged={staged} />
|
|
27
28
|
</Section>
|
|
28
29
|
)}
|
|
29
30
|
|
|
@@ -125,7 +126,9 @@ function IngressEgressRuleCard({
|
|
|
125
126
|
)}
|
|
126
127
|
|
|
127
128
|
{peers.length === 0 && ports.length === 0 && (
|
|
128
|
-
<div className="text-xs text-theme-text-tertiary">
|
|
129
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
130
|
+
{direction === 'from' ? 'All sources' : 'All destinations'}
|
|
131
|
+
</div>
|
|
129
132
|
)}
|
|
130
133
|
</div>
|
|
131
134
|
)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { PolicyCoverageSection } from './PolicyCoverageSection'
|
|
4
|
+
import type { PolicyCoverageResponse, PolicyCoverageRule, PolicyCoverageSubject } from '../../../types/policy'
|
|
5
|
+
|
|
6
|
+
const vpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'ValidatingPolicy', spec })
|
|
7
|
+
const mpol = () => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec: {} })
|
|
8
|
+
const legacy = (spec: any) => ({ apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', spec })
|
|
9
|
+
|
|
10
|
+
const counts = (c: Partial<PolicyCoverageResponse['counts']> = {}) =>
|
|
11
|
+
({ pass: 0, fail: 0, warn: 0, error: 0, skip: 0, ...c })
|
|
12
|
+
|
|
13
|
+
const subject = (name: string, result: string, namespace = 'app'): PolicyCoverageSubject =>
|
|
14
|
+
({ kind: 'Pod', namespace, name, result })
|
|
15
|
+
|
|
16
|
+
const rule = (r: Partial<PolicyCoverageRule> = {}): PolicyCoverageRule =>
|
|
17
|
+
({ rule: 'check', counts: counts(), subjects: [], total: 0, ...r })
|
|
18
|
+
|
|
19
|
+
const coverage = (o: Partial<PolicyCoverageResponse> = {}): PolicyCoverageResponse => ({
|
|
20
|
+
evaluated: true,
|
|
21
|
+
status: 'ready',
|
|
22
|
+
liveUpdates: true,
|
|
23
|
+
policy: 'p',
|
|
24
|
+
counts: counts(),
|
|
25
|
+
scopeNamespaces: 1,
|
|
26
|
+
withheldNamespaces: 0,
|
|
27
|
+
examined: 0,
|
|
28
|
+
rules: [],
|
|
29
|
+
...o,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const render = (data: PolicyCoverageResponse, resource: any = vpol(), extra = {}) =>
|
|
33
|
+
renderToString(<PolicyCoverageSection resource={resource} data={data} {...extra} />)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The wording is the feature here, so these assert on what the section actually
|
|
37
|
+
* renders. Kyverno reports the same `result: fail` for every family and does not
|
|
38
|
+
* mean the same thing by it, and several of the sentences claim more than the
|
|
39
|
+
* data behind them if they are built from the wrong number.
|
|
40
|
+
*/
|
|
41
|
+
describe('what the section says a policy did', () => {
|
|
42
|
+
it('states the consequence of enforcing without claiming what exists is blocked', () => {
|
|
43
|
+
const html = render(
|
|
44
|
+
coverage({ examined: 7, subjects: 7, subjectsFailing: 7, counts: counts({ fail: 7 }),
|
|
45
|
+
rules: [rule({ counts: counts({ fail: 7 }), subjects: [subject('a', 'fail')], total: 7 })] }),
|
|
46
|
+
vpol({ validationActions: ['Deny'] }),
|
|
47
|
+
)
|
|
48
|
+
expect(html).toContain('already exist')
|
|
49
|
+
expect(html).toContain('will be rejected')
|
|
50
|
+
expect(html).not.toMatch(/would be blocked|blocks these/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('frames an audit policy as a dry run, not a current block', () => {
|
|
54
|
+
const html = render(
|
|
55
|
+
coverage({ examined: 3, subjects: 3, subjectsFailing: 3, counts: counts({ fail: 3 }),
|
|
56
|
+
rules: [rule({ counts: counts({ fail: 3 }), subjects: [subject('a', 'fail')], total: 3 })] }),
|
|
57
|
+
vpol({ validationActions: ['Audit'] }),
|
|
58
|
+
)
|
|
59
|
+
expect(html).toContain('Nothing is rejected today')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// A mutating policy's fail carries "mutation is not applied" — nothing
|
|
63
|
+
// violated anything, so calling it a failure misreports compliance.
|
|
64
|
+
it('does not call an unapplied mutation a failure', () => {
|
|
65
|
+
const html = render(
|
|
66
|
+
coverage({ examined: 2, subjects: 2, counts: counts({ fail: 2 }),
|
|
67
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
68
|
+
mpol(),
|
|
69
|
+
)
|
|
70
|
+
expect(html).toContain('not mutated')
|
|
71
|
+
expect(html).not.toContain('Fail')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// The subject of a generate result is the trigger, never the object produced.
|
|
75
|
+
it('calls a generate rule’s pass a trigger, not a generated object', () => {
|
|
76
|
+
const html = render(
|
|
77
|
+
coverage({ examined: 1, subjects: 1, counts: counts({ pass: 1 }),
|
|
78
|
+
rules: [rule({ rule: 'gen', counts: counts({ pass: 1 }), subjects: [subject('cm', 'pass')], total: 1 })] }),
|
|
79
|
+
legacy({ rules: [{ name: 'gen', generate: {} }] }),
|
|
80
|
+
)
|
|
81
|
+
expect(html).toContain('triggered generation')
|
|
82
|
+
expect(html).not.toContain('Generated')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('withholds the consequence when the policy’s rules disagree on blocking', () => {
|
|
86
|
+
const html = render(
|
|
87
|
+
coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
|
|
88
|
+
rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
|
|
89
|
+
legacy({ rules: [
|
|
90
|
+
{ name: 'a', validate: { failureAction: 'Enforce' } },
|
|
91
|
+
{ name: 'b', validate: { failureAction: 'Audit' } },
|
|
92
|
+
] }),
|
|
93
|
+
)
|
|
94
|
+
expect(html).not.toContain('will be rejected')
|
|
95
|
+
expect(html).not.toContain('already exist')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('counts describe the cluster, lists describe the view', () => {
|
|
100
|
+
it('separates checks from resources when one resource was checked twice', () => {
|
|
101
|
+
const html = render(coverage({
|
|
102
|
+
examined: 2, subjects: 1, counts: counts({ pass: 1, fail: 1 }),
|
|
103
|
+
rules: [rule({ counts: counts({ fail: 1 }), subjects: [subject('only-one', 'fail')], total: 1 })],
|
|
104
|
+
}))
|
|
105
|
+
expect(html).toContain('2 checks on 1 resource')
|
|
106
|
+
expect(html).not.toContain('2 resources examined')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('says resources when each was checked once', () => {
|
|
110
|
+
const html = render(coverage({
|
|
111
|
+
examined: 34, subjects: 34, counts: counts({ pass: 34 }),
|
|
112
|
+
rules: [rule({ counts: counts({ pass: 34 }), subjects: [subject('a', 'pass')], total: 34 })],
|
|
113
|
+
}))
|
|
114
|
+
expect(html).toContain('34 resources examined')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('claims "all" passing only when nothing is outside the view', () => {
|
|
118
|
+
const all = render(coverage({
|
|
119
|
+
examined: 3, subjects: 3, counts: counts({ pass: 3 }),
|
|
120
|
+
rules: [rule({ counts: counts({ pass: 3 }), subjects: [subject('a', 'pass')], total: 3 })],
|
|
121
|
+
}))
|
|
122
|
+
expect(all).toContain('All 3 resources passing')
|
|
123
|
+
|
|
124
|
+
const filtered = render(coverage({
|
|
125
|
+
examined: 34, subjects: 34, counts: counts({ pass: 34 }),
|
|
126
|
+
rules: [rule({ counts: counts({ pass: 34 }), subjects: [subject('a', 'pass')], total: 34, hiddenByFilter: 27 })],
|
|
127
|
+
}))
|
|
128
|
+
expect(filtered).toContain('7 resources passing in view')
|
|
129
|
+
expect(filtered).not.toContain('All 34')
|
|
130
|
+
expect(filtered).toContain('27 hidden by your namespace filter')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Only non-passing subjects are listed, so a hidden pass leaves nothing to
|
|
134
|
+
// claim — "All 0 resources passing" is not a statement worth making.
|
|
135
|
+
it('makes no passing claim when the filter hid the only subject', () => {
|
|
136
|
+
const html = render(coverage({
|
|
137
|
+
examined: 1, subjects: 1, counts: counts({ pass: 1 }),
|
|
138
|
+
rules: [rule({ counts: counts({ pass: 1 }), subjects: [], total: 1, hiddenByFilter: 1 })],
|
|
139
|
+
}))
|
|
140
|
+
expect(html).not.toContain('All 0')
|
|
141
|
+
expect(html).toContain('1 hidden by your namespace filter')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// The counts exclude what a family or a namespace held back, so "All" would be
|
|
145
|
+
// quantifying over a set the page itself goes on to say was short.
|
|
146
|
+
it('does not claim all passed when a report family was withheld', () => {
|
|
147
|
+
const html = render(coverage({
|
|
148
|
+
examined: 5, subjects: 5, counts: counts({ pass: 5 }),
|
|
149
|
+
withheldByFamily: 2, unreadableFamilies: ['openreports.io'],
|
|
150
|
+
rules: [rule({ counts: counts({ pass: 5 }), subjects: [], total: 5 })],
|
|
151
|
+
}))
|
|
152
|
+
expect(html).toContain('5 resources passing')
|
|
153
|
+
expect(html).not.toContain('All 5 resources passing')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps the plain claim when nothing was held back', () => {
|
|
157
|
+
const html = render(coverage({
|
|
158
|
+
examined: 5, subjects: 5, counts: counts({ pass: 5 }),
|
|
159
|
+
rules: [rule({ counts: counts({ pass: 5 }), subjects: [], total: 5 })],
|
|
160
|
+
}))
|
|
161
|
+
expect(html).toContain('All 5 resources passing')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('names the hidden rows that were failing', () => {
|
|
165
|
+
const html = render(coverage({
|
|
166
|
+
examined: 1, subjects: 1, counts: counts({ fail: 1 }),
|
|
167
|
+
rules: [rule({ counts: counts({ fail: 1 }), subjects: [], total: 1, hiddenByFilter: 1, hiddenNotable: 1 })],
|
|
168
|
+
}))
|
|
169
|
+
expect(html).toContain('1 of them failing')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Kyverno files engine errors under a rule of their own. Calling those hidden
|
|
173
|
+
// rows by the family's fail word made the footers claim more failures than the
|
|
174
|
+
// summary above them had counted.
|
|
175
|
+
it('does not call a hidden engine error a failure of the rule', () => {
|
|
176
|
+
const html = render(coverage({
|
|
177
|
+
examined: 18, subjects: 18, counts: counts({ error: 18 }),
|
|
178
|
+
rules: [rule({
|
|
179
|
+
rule: 'evaluation', counts: counts({ error: 18 }), subjects: [], total: 18,
|
|
180
|
+
hiddenByFilter: 14, hiddenNotable: 14,
|
|
181
|
+
})],
|
|
182
|
+
}), mpol())
|
|
183
|
+
expect(html).toContain('14 of them could not be evaluated')
|
|
184
|
+
expect(html).not.toContain('14 of them not mutated')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('still uses the family word when the hidden rows really did fail', () => {
|
|
188
|
+
const html = render(coverage({
|
|
189
|
+
examined: 4, subjects: 4, counts: counts({ fail: 3, error: 1 }),
|
|
190
|
+
rules: [rule({
|
|
191
|
+
counts: counts({ fail: 3, error: 1 }), subjects: [], total: 4,
|
|
192
|
+
hiddenByFilter: 3, hiddenNotable: 3,
|
|
193
|
+
})],
|
|
194
|
+
}), mpol())
|
|
195
|
+
expect(html).toContain('3 of them not mutated')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('a bounded list is not a dead end', () => {
|
|
200
|
+
const many = (n: number, result: string) =>
|
|
201
|
+
Array.from({ length: n }, (_, i) => subject(`s${i}`, result))
|
|
202
|
+
|
|
203
|
+
it('offers to load the rest when the cap removed listable rows', () => {
|
|
204
|
+
const html = render(
|
|
205
|
+
coverage({ examined: 251, subjects: 251, counts: counts({ fail: 251 }),
|
|
206
|
+
rules: [rule({ counts: counts({ fail: 251 }), subjects: many(200, 'fail'), total: 251, truncated: true })] }),
|
|
207
|
+
vpol(), { onLoadMore: () => {} },
|
|
208
|
+
)
|
|
209
|
+
expect(html).toContain('only the first 200 are available here')
|
|
210
|
+
expect(html).toContain('Load the rest')
|
|
211
|
+
expect(html).toContain('Show 200 of 251')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// A rule where everything passes is capped just as readily, and its list
|
|
215
|
+
// carries the same offer. Only the label is assertable here: the rows and the
|
|
216
|
+
// control sit behind a disclosure this renderer opens on click, and the repo
|
|
217
|
+
// tests renderers with renderToString, which cannot open one.
|
|
218
|
+
it('summarises a capped passing list as a disclosure rather than a total', () => {
|
|
219
|
+
const html = render(
|
|
220
|
+
coverage({ examined: 251, subjects: 251, counts: counts({ pass: 251 }),
|
|
221
|
+
rules: [rule({ counts: counts({ pass: 251 }), subjects: many(199, 'pass'), total: 251, truncated: true })] }),
|
|
222
|
+
vpol(), { onLoadMore: () => {} },
|
|
223
|
+
)
|
|
224
|
+
expect(html).toContain('All 251 resources passing')
|
|
225
|
+
expect(html).toContain('<button')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Rows the filter withheld are not the cap's doing, and no higher limit
|
|
229
|
+
// returns them.
|
|
230
|
+
it('does not blame the cap for what the namespace filter withheld', () => {
|
|
231
|
+
const html = render(coverage({
|
|
232
|
+
examined: 1, subjects: 1, counts: counts({ fail: 1 }),
|
|
233
|
+
rules: [rule({ counts: counts({ fail: 1 }), subjects: [], total: 1, hiddenByFilter: 1, hiddenNotable: 1 })],
|
|
234
|
+
}), vpol(), { onLoadMore: () => {} })
|
|
235
|
+
expect(html).not.toContain('Load the rest')
|
|
236
|
+
expect(html).not.toContain('only the first')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does not announce a cap that only removed passing rows', () => {
|
|
240
|
+
const html = render(
|
|
241
|
+
coverage({ examined: 251, subjects: 251, counts: counts({ pass: 250, fail: 1 }),
|
|
242
|
+
rules: [rule({ counts: counts({ pass: 250, fail: 1 }), subjects: [subject('bad', 'fail')], total: 251, truncated: true })] }),
|
|
243
|
+
vpol(), { onLoadMore: () => {} },
|
|
244
|
+
)
|
|
245
|
+
expect(html).not.toContain('only the first 1 are available')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('an engine error is not a verdict', () => {
|
|
250
|
+
const allErrors = coverage({
|
|
251
|
+
examined: 1, subjects: 1, subjectsFailing: 0, counts: counts({ error: 1 }),
|
|
252
|
+
rules: [rule({ rule: 'evaluation', counts: counts({ error: 1 }), subjects: [subject('a', 'error')], total: 1 })],
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does not promise rejection for rules that never ran', () => {
|
|
256
|
+
const html = render(allErrors, vpol({ validationActions: ['Deny'] }))
|
|
257
|
+
expect(html).not.toContain('will be rejected')
|
|
258
|
+
expect(html).toContain('could not be evaluated')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// The policy has to declare Ignore to reach this wording at all: an absent
|
|
262
|
+
// failurePolicy means Fail per the CRD, so the default is the fail-closed
|
|
263
|
+
// sentence, not this one.
|
|
264
|
+
it('agrees in number', () => {
|
|
265
|
+
const ignores = vpol({ failurePolicy: 'Ignore' })
|
|
266
|
+
expect(render(allErrors, ignores)).toContain('1 could not be evaluated and is being allowed through unchecked')
|
|
267
|
+
const two = coverage({
|
|
268
|
+
examined: 2, subjects: 2, counts: counts({ error: 2 }),
|
|
269
|
+
rules: [rule({ rule: 'evaluation', counts: counts({ error: 2 }), subjects: [subject('a', 'error')], total: 2 })],
|
|
270
|
+
})
|
|
271
|
+
expect(render(two, ignores)).toContain('2 could not be evaluated and are being allowed through unchecked')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('says a fail-closed policy rejects on the error instead', () => {
|
|
275
|
+
const html = render(allErrors, vpol({ failurePolicy: 'Fail', validationActions: ['Deny'] }))
|
|
276
|
+
expect(html).toContain('fails closed')
|
|
277
|
+
expect(html).toContain('that admission is')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// `evaluation` is Kyverno's own marker, not a rule the policy declares.
|
|
281
|
+
it('does not present the engine’s marker as a rule the policy contains', () => {
|
|
282
|
+
const html = render(allErrors)
|
|
283
|
+
expect(html).toContain('Engine errors')
|
|
284
|
+
expect(html).not.toMatch(/>evaluation</)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('absence is only claimed when the whole answer arrived', () => {
|
|
289
|
+
it('says nothing has been checked when nothing was withheld', () => {
|
|
290
|
+
expect(render(coverage())).toContain('Nothing has been checked against this policy yet')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('does not claim that when results were withheld', () => {
|
|
294
|
+
const html = render(coverage({ withheldNamespaces: 2 }))
|
|
295
|
+
expect(html).not.toContain('Nothing has been checked')
|
|
296
|
+
expect(html).toContain('No results to show here')
|
|
297
|
+
expect(html).toContain('2 namespaces are not shown')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('attributes a family the caller cannot read to their permissions', () => {
|
|
301
|
+
const html = render(coverage({ withheldByFamily: 3, unreadableFamilies: ['openreports.io'] }))
|
|
302
|
+
expect(html).toContain('openreports.io')
|
|
303
|
+
expect(html).toContain('3 results were')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('names another engine rather than presenting its findings as this policy’s', () => {
|
|
307
|
+
const html = render(coverage({
|
|
308
|
+
examined: 1, subjects: 1, counts: counts({ fail: 1 }), engines: ['kyverno', 'trivy'],
|
|
309
|
+
rules: [rule({ counts: counts({ fail: 1 }), subjects: [subject('a', 'fail')], total: 1 })],
|
|
310
|
+
}))
|
|
311
|
+
expect(html).toContain('trivy')
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* An engine error is either rejected or waved through, and which one is the
|
|
317
|
+
* whole point of the sentence. `spec.failurePolicy` defaults to Fail per the
|
|
318
|
+
* published CRD and most policies never set it, so reading absent as anything
|
|
319
|
+
* else states the opposite of what the cluster does.
|
|
320
|
+
*/
|
|
321
|
+
describe('what an engine error costs', () => {
|
|
322
|
+
const errored = coverage({
|
|
323
|
+
examined: 2, subjects: 2, counts: counts({ error: 2 }),
|
|
324
|
+
rules: [rule({ counts: counts({ error: 2 }), subjects: [], total: 2 })],
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('treats an unset failurePolicy as fail-closed, like the CRD does', () => {
|
|
328
|
+
const html = render(errored, vpol({}))
|
|
329
|
+
expect(html).toContain('rejected for an engine error')
|
|
330
|
+
expect(html).not.toContain('allowed through unchecked')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('says so plainly when the policy declares Ignore', () => {
|
|
334
|
+
const html = render(errored, vpol({ failurePolicy: 'Ignore' }))
|
|
335
|
+
expect(html).toContain('allowed through unchecked')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// The legacy family keeps the field somewhere else entirely.
|
|
339
|
+
it('honours a legacy policy that opted out via webhookConfiguration', () => {
|
|
340
|
+
const html = render(errored, legacy({ webhookConfiguration: { failurePolicy: 'Ignore' }, rules: [] }))
|
|
341
|
+
expect(html).toContain('allowed through unchecked')
|
|
342
|
+
expect(html).not.toContain('fails closed')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('agrees with an explicit Fail', () => {
|
|
346
|
+
expect(render(errored, vpol({ failurePolicy: 'Fail' }))).toContain('rejected for an engine error')
|
|
347
|
+
})
|
|
348
|
+
})
|