@skyhook-io/radar-app 1.10.0 → 1.11.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 +6 -6
- package/src/App.tsx +2 -6
- package/src/api/client.ts +138 -6
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +15 -5
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
- package/src/components/workload/WorkloadView.tsx +55 -7
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Boxes } from 'lucide-react'
|
|
2
|
+
import { CNPGImageCatalogRenderer as BaseImageCatalog } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGDeclarativeRenderer'
|
|
3
|
+
import { Section, RelationshipGroup } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
5
|
+
import { getCNPGImageCatalogEntries } from '../resource-utils-cnpg'
|
|
6
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
7
|
+
import { useCNPGCatalogUsers } from '../../../api/policy'
|
|
8
|
+
|
|
9
|
+
const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Host wrapper adding the reverse lookup: which clusters are pinned to this
|
|
13
|
+
* catalog.
|
|
14
|
+
*
|
|
15
|
+
* This is the diagnosis path. A cluster that references a catalog lacking its
|
|
16
|
+
* major version reports "incomplete or invalid image catalog" and stops; the
|
|
17
|
+
* catalog is where you check what it offers, and from here you need to see who
|
|
18
|
+
* depends on it before changing anything.
|
|
19
|
+
*/
|
|
20
|
+
export function CNPGImageCatalogRenderer({
|
|
21
|
+
data,
|
|
22
|
+
onNavigate,
|
|
23
|
+
}: {
|
|
24
|
+
data: any
|
|
25
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
26
|
+
}) {
|
|
27
|
+
const kind = data?.kind
|
|
28
|
+
const name = data?.metadata?.name ?? ''
|
|
29
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
30
|
+
// A ClusterImageCatalog is cluster-scoped and referenceable from any namespace,
|
|
31
|
+
// so the answer's scope is not this resource's. Read server-side rather than
|
|
32
|
+
// filtering a client-side list: omitting the namespace there means "the
|
|
33
|
+
// caller's namespace view filter", which would answer a question about the
|
|
34
|
+
// whole cluster from whichever namespaces they happen to be showing.
|
|
35
|
+
const scoped = kind === 'ImageCatalog'
|
|
36
|
+
const clusters = useCNPGCatalogUsers(name, scoped ? namespace : '', !!name)
|
|
37
|
+
|
|
38
|
+
const users = clusters.data?.clusters ?? []
|
|
39
|
+
|
|
40
|
+
// A cluster asking for a major this catalog does not carry is the failure the
|
|
41
|
+
// catalog page exists to explain, and it is invisible from the cluster side —
|
|
42
|
+
// there the reference looks fine.
|
|
43
|
+
const majors = new Set(getCNPGImageCatalogEntries(data).map((e) => e.major))
|
|
44
|
+
const unmet = users.filter((c) => !majors.has(c.major as number))
|
|
45
|
+
const met = users.filter((c) => majors.has(c.major as number))
|
|
46
|
+
|
|
47
|
+
const toRef = (c: { namespace: string; name: string }) => ({
|
|
48
|
+
kind: 'Cluster',
|
|
49
|
+
namespace: c.namespace,
|
|
50
|
+
name: c.name,
|
|
51
|
+
group: CNPG_GROUP,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<BaseImageCatalog
|
|
56
|
+
data={data}
|
|
57
|
+
usedBy={
|
|
58
|
+
<Section title="Used By" icon={Boxes} defaultExpanded>
|
|
59
|
+
{clusters.isLoading ? (
|
|
60
|
+
<div className="text-sm text-theme-text-tertiary">Looking for clusters…</div>
|
|
61
|
+
) : users.length === 0 ? (
|
|
62
|
+
clusters.error ? (
|
|
63
|
+
<LookupFailureNote
|
|
64
|
+
errors={[clusters.error]}
|
|
65
|
+
what="which clusters use this catalog"
|
|
66
|
+
/>
|
|
67
|
+
) : (
|
|
68
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
69
|
+
{/* "Changing it affects nothing" is a claim about the cluster,
|
|
70
|
+
and this search does not cover one. A ClusterImageCatalog is
|
|
71
|
+
referenced from any namespace, but the lookup that answers
|
|
72
|
+
this falls back to the reader's namespace view filter, so a
|
|
73
|
+
filtered reader is told an edit is safe on the strength of
|
|
74
|
+
the namespaces they happen to be looking at. The namespaced
|
|
75
|
+
ImageCatalog has no such gap — its users can only be in its
|
|
76
|
+
own namespace, which is exactly what was searched. */}
|
|
77
|
+
{scoped
|
|
78
|
+
? 'No cluster in this namespace is pinned to this catalog. Changing it affects nothing here today.'
|
|
79
|
+
: 'No cluster in view is pinned to this catalog. Clusters in namespaces you are not currently showing are not covered by this check.'}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
) : (
|
|
83
|
+
<div className="space-y-3">
|
|
84
|
+
{met.length > 0 && (
|
|
85
|
+
<RelationshipGroup label="Clusters" refs={met.map(toRef)} onNavigate={onNavigate} />
|
|
86
|
+
)}
|
|
87
|
+
{unmet.length > 0 && (
|
|
88
|
+
<div className="space-y-1.5">
|
|
89
|
+
<RelationshipGroup
|
|
90
|
+
label="Pinned to a version this catalog does not list"
|
|
91
|
+
refs={unmet.map(toRef)}
|
|
92
|
+
onNavigate={onNavigate}
|
|
93
|
+
/>
|
|
94
|
+
{unmet.some((c) => !!c.image) && (
|
|
95
|
+
<div className="text-xs text-theme-text-secondary">
|
|
96
|
+
{`Running now: ${unmet
|
|
97
|
+
.filter((c) => !!c.image)
|
|
98
|
+
.map((c) => `${c.name} on ${c.image}`)
|
|
99
|
+
.join(', ')}`}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
<div className="text-xs text-warning-text">
|
|
103
|
+
{/* What the catalog can prove, and no further. A cluster
|
|
104
|
+
that already resolved an image keeps running on it, so
|
|
105
|
+
"will not start" sends a responder hunting for
|
|
106
|
+
crash-looping pods that are not there — the failure is
|
|
107
|
+
the NEXT time an image has to be resolved. The named
|
|
108
|
+
form only when there is a major to name: a reference
|
|
109
|
+
missing one lands here too, and "asks for PostgreSQL
|
|
110
|
+
undefined" would be worse than the general sentence. */}
|
|
111
|
+
{unmet.length === 1 && typeof unmet[0]?.major === 'number'
|
|
112
|
+
? `${unmet[0]?.name} asks for PostgreSQL ${unmet[0].major}, which this catalog does not list. Whatever image it is running now, the next time it has to resolve one it will fail.`
|
|
113
|
+
: 'These clusters ask for a major version this catalog does not list. Whatever image they are running now, the next time they have to resolve one they will fail.'}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</Section>
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Database } from 'lucide-react'
|
|
2
|
+
import { CNPGObjectStoreRenderer as BaseCNPGObjectStoreRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGObjectStoreRenderer'
|
|
3
|
+
import { Section, RelationshipGroup, CNPG_BARMAN_PLUGIN_NAME } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
5
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { useResources } from '../../../api/client'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host wrapper adding the reverse lookup the package renderer cannot do: which
|
|
10
|
+
* CNPG Clusters back up into this store.
|
|
11
|
+
*
|
|
12
|
+
* The Cluster page already links forward to its ObjectStore. Without this the
|
|
13
|
+
* relationship is one-way, so an operator reading a recovery window keyed by
|
|
14
|
+
* server name has no route to the cluster behind it.
|
|
15
|
+
*/
|
|
16
|
+
export function CNPGObjectStoreRenderer({
|
|
17
|
+
data,
|
|
18
|
+
onNavigate,
|
|
19
|
+
}: {
|
|
20
|
+
data: any
|
|
21
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
22
|
+
}) {
|
|
23
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
24
|
+
const storeName = data?.metadata?.name ?? ''
|
|
25
|
+
|
|
26
|
+
// Clusters reference their store by name within their own namespace, so the
|
|
27
|
+
// search is namespace-scoped rather than cluster-wide.
|
|
28
|
+
const clusters = useResources<any>('clusters', namespace, 'postgresql.cnpg.io', {
|
|
29
|
+
enabled: !!storeName && !!namespace,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Servers the store holds data for, from its own status. Deliberately a
|
|
33
|
+
// separate question from the one below.
|
|
34
|
+
const servers: string[] = Object.keys(data?.status?.serverRecoveryWindow ?? {})
|
|
35
|
+
|
|
36
|
+
const users = (clusters.data ?? []).filter((c: any) => {
|
|
37
|
+
const plugins = c?.spec?.plugins
|
|
38
|
+
if (!Array.isArray(plugins)) return false
|
|
39
|
+
return plugins.some(
|
|
40
|
+
(p: any) =>
|
|
41
|
+
p?.name === CNPG_BARMAN_PLUGIN_NAME &&
|
|
42
|
+
p?.enabled !== false &&
|
|
43
|
+
p?.parameters?.barmanObjectName === storeName,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// A recovery window is keyed by the archive's server name, which defaults to
|
|
48
|
+
// the cluster's name and is overridden by the plugin's own `serverName`
|
|
49
|
+
// parameter — the ObjectStore rejects the field outright ("use the
|
|
50
|
+
// 'serverName' plugin parameter in the Cluster resource"), so the Cluster is
|
|
51
|
+
// the only place it can come from. Matching keys against cluster names alone
|
|
52
|
+
// reports a cluster that renamed its archive as a stranger.
|
|
53
|
+
const clusterForServer = new Map<string, string>()
|
|
54
|
+
for (const c of users) {
|
|
55
|
+
const plugin = (c?.spec?.plugins ?? []).find(
|
|
56
|
+
(p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.parameters?.barmanObjectName === storeName,
|
|
57
|
+
)
|
|
58
|
+
const server = plugin?.parameters?.serverName || c?.metadata?.name
|
|
59
|
+
if (server && c?.metadata?.name) clusterForServer.set(server, c.metadata.name)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A recovery window is only advancing while WAL archiving works. The Cluster
|
|
63
|
+
// owns that condition, and the ObjectStore's own status cannot see it — so a
|
|
64
|
+
// server whose archiving has stopped reports its last successful backup and
|
|
65
|
+
// reads as green here while the recovery point is frozen. The page whose job is
|
|
66
|
+
// "how far back can I restore" is the last place that should have to be
|
|
67
|
+
// cross-checked against another screen.
|
|
68
|
+
const archivingFailing = new Set<string>()
|
|
69
|
+
for (const c of users) {
|
|
70
|
+
const stalled = (c?.status?.conditions ?? []).some(
|
|
71
|
+
(cond: any) => cond?.type === 'ContinuousArchiving' && cond?.status === 'False',
|
|
72
|
+
)
|
|
73
|
+
if (!stalled) continue
|
|
74
|
+
const plugin = (c?.spec?.plugins ?? []).find(
|
|
75
|
+
(p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.parameters?.barmanObjectName === storeName,
|
|
76
|
+
)
|
|
77
|
+
const server = plugin?.parameters?.serverName || c?.metadata?.name
|
|
78
|
+
if (server) archivingFailing.add(server)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Named in the window, traceable to none of the clusters above — the gap the
|
|
82
|
+
// note explains.
|
|
83
|
+
const unlisted = servers.filter((s) => !clusterForServer.has(s))
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<BaseCNPGObjectStoreRenderer
|
|
88
|
+
data={data}
|
|
89
|
+
onNavigate={onNavigate}
|
|
90
|
+
// Server key -> the Cluster that archives under it. Built from the
|
|
91
|
+
// plugin parameters rather than by name, so a cluster that renamed its
|
|
92
|
+
// archive still resolves. Undefined while the lookup is unresolved.
|
|
93
|
+
clusterForServer={clusters.isLoading || clusters.error ? undefined : clusterForServer}
|
|
94
|
+
archivingFailing={clusters.isLoading || clusters.error ? undefined : archivingFailing}
|
|
95
|
+
/>
|
|
96
|
+
<Section title="Used By" icon={Database} defaultExpanded>
|
|
97
|
+
{clusters.isLoading ? (
|
|
98
|
+
<div className="text-sm text-theme-text-tertiary">Looking for clusters…</div>
|
|
99
|
+
) : users.length === 0 ? (
|
|
100
|
+
// Not the same as "nothing uses it" — the caller may not be able to
|
|
101
|
+
// list Clusters here, and a store with no users is worth noticing.
|
|
102
|
+
clusters.error ? (
|
|
103
|
+
<LookupFailureNote
|
|
104
|
+
errors={[clusters.error]}
|
|
105
|
+
what="which clusters use this store"
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
109
|
+
No cluster in this namespace backs up to this store.
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
) : (
|
|
113
|
+
// The house pattern for "these other resources relate to this one":
|
|
114
|
+
// labelled group with a count, ref badges, truncate-then-expand.
|
|
115
|
+
<RelationshipGroup
|
|
116
|
+
label="Archiving here through the plugin"
|
|
117
|
+
refs={users.map((c: any) => ({
|
|
118
|
+
kind: 'Cluster',
|
|
119
|
+
namespace: c.metadata?.namespace ?? '',
|
|
120
|
+
name: c.metadata?.name ?? '',
|
|
121
|
+
group: 'postgresql.cnpg.io',
|
|
122
|
+
}))}
|
|
123
|
+
onNavigate={onNavigate}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
{/* This lookup can only see `spec.plugins`. A cluster still on the
|
|
127
|
+
deprecated in-tree `spec.backup.barmanObjectStore` names a
|
|
128
|
+
destination path and never references this CR, so it CANNOT appear
|
|
129
|
+
above however much data it has here. Read as blast radius — "who
|
|
130
|
+
breaks if I rotate these credentials" — the list would silently omit
|
|
131
|
+
every legacy-path cluster, which mid-migration is most of a fleet.
|
|
132
|
+
The recovery window is the other half of the answer. */}
|
|
133
|
+
{/* Only once the plugin lookup has actually answered. Derived while the
|
|
134
|
+
list is still loading — or after it failed — every server in the
|
|
135
|
+
window looks like an in-tree cluster, which is a claim about their
|
|
136
|
+
configuration made from having no data. */}
|
|
137
|
+
{!clusters.isLoading && !clusters.error && unlisted.length > 0 && (
|
|
138
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
139
|
+
{/* Two causes reach this note and the screen cannot tell them
|
|
140
|
+
apart, so it names both rather than asserting the one that is
|
|
141
|
+
usually true: a cluster on the older in-tree settings never
|
|
142
|
+
references this record, and a cluster that renamed its archive
|
|
143
|
+
leaves the data it already wrote under the previous name. */}
|
|
144
|
+
{`${unlisted.join(', ')} ${unlisted.length === 1 ? 'has' : 'have'} data here but ${
|
|
145
|
+
unlisted.length === 1 ? 'is' : 'are'
|
|
146
|
+
} not listed above. This finds clusters through the barman-cloud plugin, so an archive written by a cluster on the older in-tree backup settings — or under a server name it has since changed — is not traced back to one.`}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</Section>
|
|
150
|
+
</>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router-dom'
|
|
3
|
+
import { PolicyCoverageSection } from '@skyhook-io/k8s-ui/components/resources/renderers/PolicyCoverageSection'
|
|
4
|
+
import type { PolicyCoverageSubject, ResourceRef } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { usePolicyCoverage } from '../../../api/policy'
|
|
6
|
+
|
|
7
|
+
/** Mirrors maxPolicyCoverageSubjectsHard in internal/server/policy_handlers.go —
|
|
8
|
+
* asking for more than the server will send just returns the same list. */
|
|
9
|
+
const COVERAGE_MAX_SUBJECTS = 5000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Host wrapper for the policy coverage section — the inverse lookup that answers
|
|
13
|
+
* "which resources does this policy pass and fail".
|
|
14
|
+
*
|
|
15
|
+
* The package renderers take it as a slot rather than fetching it themselves, so
|
|
16
|
+
* a library consumer that does not wire this endpoint keeps the original policy
|
|
17
|
+
* drawer and nothing breaks.
|
|
18
|
+
*/
|
|
19
|
+
export function KyvernoPolicyCoverage({
|
|
20
|
+
data,
|
|
21
|
+
onNavigate,
|
|
22
|
+
}: {
|
|
23
|
+
data: any
|
|
24
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
25
|
+
}) {
|
|
26
|
+
const name = data?.metadata?.name ?? ''
|
|
27
|
+
// A namespaced Kyverno Policy reports as "namespace/name"; the server tries
|
|
28
|
+
// both shapes, so the namespace is passed through whenever the policy has one.
|
|
29
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
30
|
+
// The server bounds each rule's subject list so an ordinary drawer open stays
|
|
31
|
+
// small. Asking for the rest raises that bound once, up to the server's own
|
|
32
|
+
// ceiling — past which the response says what it could not send.
|
|
33
|
+
const [limit, setLimit] = useState<number | undefined>(undefined)
|
|
34
|
+
|
|
35
|
+
// The header's namespace filter is applied SERVER-side from session state, so
|
|
36
|
+
// the same URL returns a different body once it changes and nothing in the
|
|
37
|
+
// request distinguishes the two. Without it in the cache key, a policy opened
|
|
38
|
+
// under "All namespaces" keeps serving that body after the filter narrows —
|
|
39
|
+
// the view then shows other people's namespaces under your scope, and which
|
|
40
|
+
// behaviour you get depends on where you navigated from.
|
|
41
|
+
const viewFilter = useLocation().search
|
|
42
|
+
const query = usePolicyCoverage(name, namespace || undefined, !!name, limit, viewFilter)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<PolicyCoverageSection
|
|
46
|
+
resource={data}
|
|
47
|
+
data={query.data ?? null}
|
|
48
|
+
loading={query.isLoading}
|
|
49
|
+
error={query.error as Error | null}
|
|
50
|
+
onLoadMore={limit ? undefined : () => setLimit(COVERAGE_MAX_SUBJECTS)}
|
|
51
|
+
loadingMore={query.isFetching}
|
|
52
|
+
onSelectSubject={
|
|
53
|
+
onNavigate
|
|
54
|
+
? (subject: PolicyCoverageSubject) =>
|
|
55
|
+
onNavigate({
|
|
56
|
+
kind: subject.kind,
|
|
57
|
+
namespace: subject.namespace ?? '',
|
|
58
|
+
name: subject.name,
|
|
59
|
+
group: subject.group,
|
|
60
|
+
})
|
|
61
|
+
: undefined
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
// The denial is the state under test, so the fetch is stubbed rather than run.
|
|
5
|
+
const forbidden = Object.assign(new Error('forbidden'), { status: 403 })
|
|
6
|
+
let queuedResult: { data?: unknown; error?: unknown } = {}
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../api/policy', () => ({
|
|
9
|
+
usePolicyQueued: () => queuedResult,
|
|
10
|
+
}))
|
|
11
|
+
vi.mock('../../../api/client', () => ({
|
|
12
|
+
isForbiddenError: (e: any) => e?.status === 403,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { KyvernoPolicyQueued } = await import('./KyvernoPolicyQueued')
|
|
16
|
+
|
|
17
|
+
const generating = {
|
|
18
|
+
kind: 'ClusterPolicy',
|
|
19
|
+
metadata: { name: 'gen-companion' },
|
|
20
|
+
spec: { rules: [{ name: 'g', generate: {} }] },
|
|
21
|
+
}
|
|
22
|
+
const validating = {
|
|
23
|
+
kind: 'ClusterPolicy',
|
|
24
|
+
metadata: { name: 'require-labels' },
|
|
25
|
+
spec: { rules: [{ name: 'v', validate: {} }] },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A denial here is cluster-static, so disclosing it everywhere would be noise on
|
|
30
|
+
* the majority of policies that only validate. Staying silent about it on a
|
|
31
|
+
* policy that DOES queue is worse: a stuck backlog then looks exactly like a
|
|
32
|
+
* healthy policy with nothing pending, on the page someone checks to find out.
|
|
33
|
+
*/
|
|
34
|
+
describe('KyvernoPolicyQueued — a denial you cannot see', () => {
|
|
35
|
+
it('says it cannot check when the policy actually queues work', () => {
|
|
36
|
+
queuedResult = { error: forbidden }
|
|
37
|
+
const html = renderToString(<KyvernoPolicyQueued data={generating} />)
|
|
38
|
+
expect(html).toContain('permission')
|
|
39
|
+
expect(html).toContain('Queued Work')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('stays silent for a policy that never queues anything', () => {
|
|
43
|
+
queuedResult = { error: forbidden }
|
|
44
|
+
expect(renderToString(<KyvernoPolicyQueued data={validating} />)).toBe('')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('stays silent when there is genuinely nothing queued', () => {
|
|
48
|
+
queuedResult = { data: { requests: 0 } }
|
|
49
|
+
expect(renderToString(<KyvernoPolicyQueued data={generating} />)).toBe('')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// A fault is not a denial: it is neither permanent nor beyond acting on, so it
|
|
53
|
+
// is loud regardless of what the policy does.
|
|
54
|
+
it('reports a genuine fault on any policy', () => {
|
|
55
|
+
queuedResult = { error: new Error('could not read queued work') }
|
|
56
|
+
const html = renderToString(<KyvernoPolicyQueued data={validating} />)
|
|
57
|
+
expect(html).toContain('could not read queued work')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { policyQueuesWork, queueBanner, requestBelongsTo } from './KyvernoPolicyQueued'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Kyverno permits a namespaced Policy and a ClusterPolicy to share a name, and
|
|
6
|
+
* records the first qualified and the second bare. Accepting the bare form as a
|
|
7
|
+
* fallback for a namespaced policy shows it someone else's backlog — the same
|
|
8
|
+
* collision the coverage lookup refuses, reintroduced here once already.
|
|
9
|
+
*/
|
|
10
|
+
describe('requestBelongsTo', () => {
|
|
11
|
+
const req = (policy: string) => ({ spec: { policy } })
|
|
12
|
+
|
|
13
|
+
it('matches a cluster-scoped policy on the bare name only', () => {
|
|
14
|
+
expect(requestBelongsTo(req('require-labels'), 'require-labels', '')).toBe(true)
|
|
15
|
+
expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', '')).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('matches a namespaced policy on the qualified name only', () => {
|
|
19
|
+
expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', 'team-a')).toBe(true)
|
|
20
|
+
// The bug: this is a ClusterPolicy's request and must not appear here.
|
|
21
|
+
expect(requestBelongsTo(req('require-labels'), 'require-labels', 'team-a')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not match another namespace', () => {
|
|
25
|
+
expect(requestBelongsTo(req('team-b/require-labels'), 'require-labels', 'team-a')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('matches nothing when either side is missing', () => {
|
|
29
|
+
expect(requestBelongsTo({}, 'require-labels', '')).toBe(false)
|
|
30
|
+
expect(requestBelongsTo(req('require-labels'), '', '')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The banner is the only thing on this page that makes a claim rather than a
|
|
36
|
+
* count, so the claim has to be the one the numbers support.
|
|
37
|
+
*/
|
|
38
|
+
describe('queueBanner', () => {
|
|
39
|
+
it('says work stopped only when something has actually sat still', () => {
|
|
40
|
+
const b = queueBanner(3, 12, '12m')
|
|
41
|
+
expect(b?.title).toBe('Queued work has not moved for 12m')
|
|
42
|
+
expect(b?.message).toContain('cannot keep up')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// The bug: this branch fires when NOTHING is older than the stall threshold,
|
|
46
|
+
// which is the case most likely to be a burst draining normally. The body has
|
|
47
|
+
// to agree with the headline — diagnosing a stalled controller underneath a
|
|
48
|
+
// headline that only reports a size puts the claim straight back.
|
|
49
|
+
it('does not tell you a moving backlog is not being processed', () => {
|
|
50
|
+
const b = queueBanner(30, 1, '1m')
|
|
51
|
+
expect(b?.title).toBe('30 requests are queued')
|
|
52
|
+
expect(b?.title).not.toContain('not being processed')
|
|
53
|
+
expect(b?.message).toContain('may be a burst still draining')
|
|
54
|
+
expect(b?.message).not.toContain('cannot keep up')
|
|
55
|
+
expect(b?.message).not.toContain('grows rather than drains')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays quiet for a queue doing what a queue does', () => {
|
|
59
|
+
expect(queueBanner(3, 1, '1m')).toBeNull()
|
|
60
|
+
expect(queueBanner(0, 0, '')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('prefers the measured stall over the size when both apply', () => {
|
|
64
|
+
expect(queueBanner(40, 30, '30m')?.title).toContain('has not moved')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A denial is only worth disclosing where it can cost something. Silence on a
|
|
70
|
+
* policy that queues nothing keeps the page clean; silence on one that DOES
|
|
71
|
+
* queue hides a backlog behind a page that looks healthy.
|
|
72
|
+
*/
|
|
73
|
+
describe('policyQueuesWork', () => {
|
|
74
|
+
it('is true for a policy that generates resources', () => {
|
|
75
|
+
expect(policyQueuesWork({ spec: { rules: [{ name: 'g', generate: {} }] } })).toBe(true)
|
|
76
|
+
expect(policyQueuesWork({ kind: 'GeneratingPolicy', spec: {} })).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is true for a policy that mutates resources which already exist', () => {
|
|
80
|
+
expect(policyQueuesWork({ spec: { rules: [{ mutate: { targets: [{ kind: 'Pod' }] } }] } })).toBe(true)
|
|
81
|
+
expect(policyQueuesWork({ spec: { evaluation: { mutateExisting: { enabled: true } } } })).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// The common case, and the reason a blanket note would be noise.
|
|
85
|
+
it('is false for a validate-only policy', () => {
|
|
86
|
+
expect(policyQueuesWork({ spec: { rules: [{ name: 'v', validate: {} }] } })).toBe(false)
|
|
87
|
+
expect(policyQueuesWork({ kind: 'ValidatingPolicy', spec: {} })).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// A mutate rule with no targets runs at admission only — nothing is queued.
|
|
91
|
+
it('is false for admission-time mutation', () => {
|
|
92
|
+
expect(policyQueuesWork({ spec: { rules: [{ mutate: { patchStrategicMerge: {} } }] } })).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('handles a policy with no spec at all', () => {
|
|
96
|
+
expect(policyQueuesWork({})).toBe(false)
|
|
97
|
+
expect(policyQueuesWork(undefined)).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|