@skyhook-io/k8s-ui 1.11.0 → 1.12.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 +1 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- package/src/components/resources/ResourcesView.tsx +2 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +24 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
- package/src/components/resources/renderers/PolicySection.tsx +170 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +63 -0
- package/src/components/resources/resource-utils-cnpg.ts +60 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +42 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { PolicySection } from './PolicySection'
|
|
4
|
+
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
5
|
+
|
|
6
|
+
const base: PolicyResourceResponse = {
|
|
7
|
+
evaluated: true,
|
|
8
|
+
status: 'ready',
|
|
9
|
+
liveUpdates: true,
|
|
10
|
+
counts: { pass: 0, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
11
|
+
findings: [],
|
|
12
|
+
}
|
|
13
|
+
const resp = (over: Partial<PolicyResourceResponse>): PolicyResourceResponse => ({ ...base, ...over })
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
// The unhappy paths carry this section. An empty finding list means several
|
|
17
|
+
// unrelated things, and rendering them all as blank space would tell an
|
|
18
|
+
// operator they are compliant in the cases where nothing was checked.
|
|
19
|
+
describe('PolicySection — what an empty result means', () => {
|
|
20
|
+
it('says which rules failed when there are violations', () => {
|
|
21
|
+
const html = renderToString(<PolicySection data={resp({
|
|
22
|
+
counts: { pass: 2, fail: 1, warn: 0, error: 0, skip: 0 },
|
|
23
|
+
findings: [{ policy: 'require-run-as-nonroot', result: 'fail', message: 'must set runAsNonRoot' }],
|
|
24
|
+
})} />)
|
|
25
|
+
expect(html).toContain('require-run-as-nonroot')
|
|
26
|
+
expect(html).toContain('must set runAsNonRoot')
|
|
27
|
+
// The passing checks are counted, not listed — otherwise two failures drown
|
|
28
|
+
// in a wall of green on a heavily-policed workload.
|
|
29
|
+
expect(html).toContain('2 other checks passing')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('distinguishes "everything passed" from "nothing was checked"', () => {
|
|
33
|
+
const passing = renderToString(<PolicySection data={resp({
|
|
34
|
+
counts: { pass: 5, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
35
|
+
})} />)
|
|
36
|
+
expect(passing).toContain('All 5 checks passing')
|
|
37
|
+
|
|
38
|
+
const unchecked = renderToString(<PolicySection data={resp({})} />)
|
|
39
|
+
expect(unchecked).toContain('No policy applies')
|
|
40
|
+
expect(unchecked).not.toContain('passing')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('never claims compliance when the results could not be read', () => {
|
|
44
|
+
const html = renderToString(<PolicySection data={resp({
|
|
45
|
+
evaluated: false, status: 'deferred', reasonCode: 'rbac_denied',
|
|
46
|
+
})} />)
|
|
47
|
+
expect(html).toContain('has not been checked')
|
|
48
|
+
expect(html).toContain('rbac_denied')
|
|
49
|
+
expect(html).not.toContain('passing')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('says results are still loading during warmup rather than reporting clean', () => {
|
|
53
|
+
const html = renderToString(<PolicySection data={resp({ evaluated: false, status: 'warmup' })} />)
|
|
54
|
+
expect(html).toContain('still loading')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders nothing at all when no policy engine is installed', () => {
|
|
58
|
+
// A disclaimer on every cluster without a policy engine is pure noise.
|
|
59
|
+
expect(renderToString(<PolicySection data={resp({ evaluated: false, status: 'not_installed' })} />)).toBe('')
|
|
60
|
+
expect(renderToString(<PolicySection data={null} />)).toBe('')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('PolicySection — partial coverage', () => {
|
|
65
|
+
it('admits when some report families were unreadable, even while passing', () => {
|
|
66
|
+
// "All checks passing" plus an unreadable family is a claim of coverage we
|
|
67
|
+
// do not have.
|
|
68
|
+
const html = renderToString(<PolicySection data={resp({
|
|
69
|
+
counts: { pass: 3, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
70
|
+
deniedGroups: ['openreports.io'],
|
|
71
|
+
})} />)
|
|
72
|
+
expect(html).toContain('All 3 checks passing')
|
|
73
|
+
expect(html).toContain('may be incomplete')
|
|
74
|
+
expect(html).toContain('openreports.io')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('admits when the results are frozen', () => {
|
|
78
|
+
const html = renderToString(<PolicySection data={resp({
|
|
79
|
+
counts: { pass: 1, fail: 0, warn: 0, error: 0, skip: 0 }, liveUpdates: false,
|
|
80
|
+
})} />)
|
|
81
|
+
expect(html).toContain('not updating live')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('PolicySection — request states', () => {
|
|
86
|
+
it('shows loading rather than an empty result', () => {
|
|
87
|
+
expect(renderToString(<PolicySection data={null} loading />)).toContain('Loading policy results')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('treats a permission denial as an expected state, not a red failure', () => {
|
|
91
|
+
const denied = Object.assign(new Error('forbidden'), { status: 403 })
|
|
92
|
+
const html = renderToString(<PolicySection data={null} error={denied} />)
|
|
93
|
+
expect(html).toContain('don’t have permission')
|
|
94
|
+
expect(html).not.toContain('text-red')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps a genuine fault loud', () => {
|
|
98
|
+
const html = renderToString(<PolicySection data={null} error={new Error('connection reset')} />)
|
|
99
|
+
expect(html).toContain('connection reset')
|
|
100
|
+
expect(html).toContain('text-red')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Section } from '../../ui/drawer-components'
|
|
4
|
+
import { SEVERITY_BADGE } from '../../../utils/badge-colors'
|
|
5
|
+
import { isForbiddenError } from '../../../types/fetch-error'
|
|
6
|
+
import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
|
|
7
|
+
|
|
8
|
+
interface PolicySectionProps {
|
|
9
|
+
data: PolicyResourceResponse | null
|
|
10
|
+
loading?: boolean
|
|
11
|
+
error?: Error | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TITLE = 'Policy'
|
|
15
|
+
|
|
16
|
+
// Result → badge tone. `error` means the engine could not evaluate the rule,
|
|
17
|
+
// which is not the same as the rule failing, so it does not share fail's red.
|
|
18
|
+
function resultTone(result: string): { badge: string; label: string } {
|
|
19
|
+
switch (result.toLowerCase()) {
|
|
20
|
+
case 'fail':
|
|
21
|
+
return { badge: SEVERITY_BADGE.error, label: 'Fail' }
|
|
22
|
+
case 'warn':
|
|
23
|
+
return { badge: SEVERITY_BADGE.warning, label: 'Warn' }
|
|
24
|
+
case 'error':
|
|
25
|
+
return { badge: SEVERITY_BADGE.alert, label: 'Error' }
|
|
26
|
+
case 'skip':
|
|
27
|
+
return { badge: SEVERITY_BADGE.neutral, label: 'Skipped' }
|
|
28
|
+
default:
|
|
29
|
+
return { badge: SEVERITY_BADGE.neutral, label: result }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* PolicySection reports what the cluster's policy engine says about this one
|
|
35
|
+
* resource.
|
|
36
|
+
*
|
|
37
|
+
* The unhappy paths carry the weight here. An empty finding list means one of
|
|
38
|
+
* several unrelated things, and rendering them all as blank space would tell an
|
|
39
|
+
* operator they are compliant in the cases where nothing was actually checked —
|
|
40
|
+
* the failure mode this section exists to avoid. Each state therefore says which
|
|
41
|
+
* one it is, in its own words, and the red treatment is reserved for genuine
|
|
42
|
+
* faults. Same division the RBAC sections already draw.
|
|
43
|
+
*/
|
|
44
|
+
export function PolicySection({ data, loading, error }: PolicySectionProps) {
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (
|
|
47
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
48
|
+
<div className="text-sm text-theme-text-tertiary">Loading policy results…</div>
|
|
49
|
+
</Section>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (error) {
|
|
54
|
+
// A denial is an expected state on a namespace-scoped account, not a fault.
|
|
55
|
+
if (isForbiddenError(error)) {
|
|
56
|
+
return (
|
|
57
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
58
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
59
|
+
You don’t have permission to view policy results in this namespace.
|
|
60
|
+
</div>
|
|
61
|
+
</Section>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<Section title={TITLE} icon={ShieldAlert}>
|
|
66
|
+
<div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
|
|
67
|
+
</Section>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// No engine installed — the section is genuinely not applicable, so it is
|
|
72
|
+
// absent rather than showing a reassuring "nothing to report". A disclaimer on
|
|
73
|
+
// every cluster without a policy engine would be noise.
|
|
74
|
+
if (!data || data.status === 'not_installed') return null
|
|
75
|
+
|
|
76
|
+
if (!data.evaluated) {
|
|
77
|
+
return (
|
|
78
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
79
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
80
|
+
{(data.status === 'warmup'
|
|
81
|
+
? 'Policy results are still loading from the cluster.'
|
|
82
|
+
: 'Policy results are unavailable, so this resource has not been checked.') +
|
|
83
|
+
(data.reasonCode ? ` (${data.reasonCode})` : '')}
|
|
84
|
+
</div>
|
|
85
|
+
</Section>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { counts, findings } = data
|
|
90
|
+
const checked = counts.pass + counts.fail + counts.warn + counts.error + counts.skip
|
|
91
|
+
|
|
92
|
+
// Evaluated, but no policy selected this resource at all. Distinct from
|
|
93
|
+
// "passing everything": nothing examined it, so there is nothing to pass.
|
|
94
|
+
if (checked === 0) {
|
|
95
|
+
return (
|
|
96
|
+
<Section title={TITLE} icon={ShieldQuestion}>
|
|
97
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
98
|
+
No policy applies to this resource.
|
|
99
|
+
</div>
|
|
100
|
+
<PartialCoverageNote data={data} />
|
|
101
|
+
</Section>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const failing = findings.length
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Section
|
|
109
|
+
title={TITLE}
|
|
110
|
+
icon={failing > 0 ? ShieldAlert : ShieldCheck}
|
|
111
|
+
defaultExpanded={failing > 0}
|
|
112
|
+
>
|
|
113
|
+
{failing === 0 ? (
|
|
114
|
+
<div className="text-sm text-theme-text-secondary">
|
|
115
|
+
{`All ${counts.pass} ${counts.pass === 1 ? 'check' : 'checks'} passing.`}
|
|
116
|
+
</div>
|
|
117
|
+
) : (
|
|
118
|
+
<div className="space-y-2">
|
|
119
|
+
{findings.map((f, i) => (
|
|
120
|
+
<PolicyFindingRow key={`${f.policy}/${f.rule}/${i}`} finding={f} />
|
|
121
|
+
))}
|
|
122
|
+
{counts.pass > 0 && (
|
|
123
|
+
<div className="text-xs text-theme-text-tertiary pt-1">
|
|
124
|
+
{`${counts.pass} other ${counts.pass === 1 ? 'check' : 'checks'} passing`}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
<PartialCoverageNote data={data} />
|
|
130
|
+
</Section>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function PolicyFindingRow({ finding }: { finding: PolicyResourceFinding }) {
|
|
135
|
+
const tone = resultTone(finding.result)
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex items-start gap-2 min-w-0">
|
|
138
|
+
<span className={clsx('badge shrink-0', tone.badge)}>{tone.label}</span>
|
|
139
|
+
<div className="min-w-0">
|
|
140
|
+
<div className="text-sm text-theme-text-primary truncate" title={finding.policy}>
|
|
141
|
+
{finding.policy}
|
|
142
|
+
{finding.rule && finding.rule !== finding.policy && (
|
|
143
|
+
<span className="text-theme-text-tertiary"> · {finding.rule}</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
{finding.message && (
|
|
147
|
+
<div className="text-xs text-theme-text-secondary">{finding.message}</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A result can be real and incomplete at the same time: some report families
|
|
156
|
+
* may be unreadable by this identity, or the index may be frozen. Saying
|
|
157
|
+
* "all checks passing" without mentioning that claims coverage we do not have.
|
|
158
|
+
*/
|
|
159
|
+
function PartialCoverageNote({ data }: { data: PolicyResourceResponse }) {
|
|
160
|
+
const denied = data.deniedGroups ?? []
|
|
161
|
+
if (denied.length === 0 && data.liveUpdates) return null
|
|
162
|
+
return (
|
|
163
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-tertiary">
|
|
164
|
+
{denied.length > 0 && (
|
|
165
|
+
<div>{`Some policy results could not be read (${denied.join(', ')}), so this list may be incomplete.`}</div>
|
|
166
|
+
)}
|
|
167
|
+
{!data.liveUpdates && <div>These results are not updating live.</div>}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { Server, ExternalLink, Scale, Minus, Plus, Loader2, Shield } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { PolicySection } from './PolicySection'
|
|
5
|
+
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
4
6
|
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
5
7
|
import { DialogPortal } from '../../ui/DialogPortal'
|
|
6
8
|
import { Tooltip } from '../../ui/Tooltip'
|
|
@@ -40,6 +42,14 @@ interface WorkloadRendererProps {
|
|
|
40
42
|
rbacData?: RBACSubjectResponse | null
|
|
41
43
|
rbacLoading?: boolean
|
|
42
44
|
rbacError?: Error | null
|
|
45
|
+
/**
|
|
46
|
+
* Policy findings for this workload. Undefined means the host didn't wire the
|
|
47
|
+
* fetch, so the section is omitted entirely — consumers that skip it get the
|
|
48
|
+
* original sections and nothing breaks.
|
|
49
|
+
*/
|
|
50
|
+
policyData?: PolicyResourceResponse | null
|
|
51
|
+
policyLoading?: boolean
|
|
52
|
+
policyError?: Error | null
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
// Check if the workload is actively progressing (scaling, rolling update)
|
|
@@ -140,7 +150,7 @@ function compactHPASummary(diagnosis: HPADiagnosis): string {
|
|
|
140
150
|
return diagnosis.summary
|
|
141
151
|
}
|
|
142
152
|
|
|
143
|
-
export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
|
|
153
|
+
export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, onRequestRefresh, rbacData, rbacLoading, rbacError, policyData, policyLoading, policyError }: WorkloadRendererProps) {
|
|
144
154
|
const status = data.status || {}
|
|
145
155
|
const spec = data.spec || {}
|
|
146
156
|
const metadata = data.metadata || {}
|
|
@@ -372,6 +382,10 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
372
382
|
</div>
|
|
373
383
|
</DialogPortal>
|
|
374
384
|
|
|
385
|
+
{policyData !== undefined && (
|
|
386
|
+
<PolicySection data={policyData} loading={policyLoading} error={policyError} />
|
|
387
|
+
)}
|
|
388
|
+
|
|
375
389
|
<Section title="Strategy">
|
|
376
390
|
<PropertyList>
|
|
377
391
|
{isDaemonSet || isStatefulSet ? (
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import {
|
|
5
|
+
getPolicyReportScope,
|
|
5
6
|
getPolicyReportStatus,
|
|
6
7
|
getPolicyReportSummary,
|
|
7
8
|
getKyvernoPolicyStatus,
|
|
@@ -19,6 +20,14 @@ export function PolicyReportCell({ resource, column }: { resource: any; column:
|
|
|
19
20
|
</span>
|
|
20
21
|
)
|
|
21
22
|
}
|
|
23
|
+
// Kyverno names a per-resource report after the subject's UID, so the Name
|
|
24
|
+
// column reads as a bare UUID. Without the subject the whole table is
|
|
25
|
+
// unidentifiable rows with counts beside them — you cannot tell which of
|
|
26
|
+
// your workloads a failing report belongs to without opening every one.
|
|
27
|
+
case 'scope': {
|
|
28
|
+
const scope = getPolicyReportScope(resource)
|
|
29
|
+
return <span className="truncate text-theme-text-primary" title={scope}>{scope}</span>
|
|
30
|
+
}
|
|
22
31
|
case 'pass': {
|
|
23
32
|
const summary = getPolicyReportSummary(resource)
|
|
24
33
|
return <span className={clsx('text-sm', summary.pass > 0 ? 'text-green-400' : 'text-theme-text-tertiary')}>{summary.pass}</span>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { PolicyReportCell } from './kyverno-cells'
|
|
4
|
+
import { getPolicyReportScope } from '../resource-utils-kyverno'
|
|
5
|
+
|
|
6
|
+
// Kyverno names each per-resource report after the subject's UID, so the Name
|
|
7
|
+
// column is a bare UUID. Without the subject, the table is a list of
|
|
8
|
+
// unidentifiable rows with counts beside them.
|
|
9
|
+
describe('PolicyReport subject column', () => {
|
|
10
|
+
const report = {
|
|
11
|
+
metadata: { name: '4c1d2f8a-91e3-4b7c-9f11-2a6e0c5d7b83', namespace: 'payments' },
|
|
12
|
+
scope: { kind: 'Deployment', namespace: 'payments', name: 'checkout-api' },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
it('names the workload the report is about', () => {
|
|
16
|
+
const html = renderToString(<PolicyReportCell resource={report} column="scope" />)
|
|
17
|
+
expect(html).toContain('checkout-api')
|
|
18
|
+
expect(html).toContain('Deployment')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('degrades to a dash rather than an empty cell when there is no subject', () => {
|
|
22
|
+
// Cluster-scoped reports and some producers omit scope entirely.
|
|
23
|
+
expect(getPolicyReportScope({ metadata: { name: 'x' } })).toBe('-')
|
|
24
|
+
const html = renderToString(<PolicyReportCell resource={{ metadata: { name: 'x' } }} column="scope" />)
|
|
25
|
+
expect(html).toContain('-')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
getCNPGClusterStatus,
|
|
5
5
|
getCNPGBackupStatus,
|
|
6
6
|
getCNPGPoolerStatus,
|
|
7
|
+
isCNPGPoolerPaused,
|
|
8
|
+
getCNPGVolumeHealth,
|
|
7
9
|
getCNPGClusterInstancesReportedState,
|
|
8
10
|
getCNPGClusterBackupConfig,
|
|
9
11
|
getCNPGClusterBarmanPlugin,
|
|
@@ -599,3 +601,64 @@ describe('an absent count is unknown, never zero', () => {
|
|
|
599
601
|
.toMatchObject({ text: 'Scheduled', level: 'healthy' })
|
|
600
602
|
})
|
|
601
603
|
})
|
|
604
|
+
|
|
605
|
+
describe('paused Pooler', () => {
|
|
606
|
+
// PgBouncer PAUSE holds client connections instead of serving them, while
|
|
607
|
+
// every pod stays scheduled and Ready. Reading only the pod counts rendered
|
|
608
|
+
// a pooler that serves nothing in healthy green.
|
|
609
|
+
const paused = { spec: { instances: 2, pgbouncer: { paused: true } }, status: { instances: 2 } }
|
|
610
|
+
|
|
611
|
+
it('does not read as healthy', () => {
|
|
612
|
+
expect(getCNPGPoolerStatus(paused)).toMatchObject({ text: 'Paused', level: 'degraded' })
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('treats pausing as intent, not a fault', () => {
|
|
616
|
+
// Same tier as a paused Velero Schedule: amber, not red.
|
|
617
|
+
expect(getCNPGPoolerStatus(paused).level).not.toBe('unhealthy')
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('lets a real fault outrank the pause', () => {
|
|
621
|
+
const pausedAndDown = { spec: { instances: 2, pgbouncer: { paused: true } }, status: { instances: 0 } }
|
|
622
|
+
expect(getCNPGPoolerStatus(pausedAndDown)).toMatchObject({ text: 'Not Scheduled', level: 'unhealthy' })
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('only an explicit true pauses', () => {
|
|
626
|
+
expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: {} }, status: { instances: 2 } }))
|
|
627
|
+
.toMatchObject({ text: 'Scheduled', level: 'healthy' })
|
|
628
|
+
expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: { paused: false } }, status: { instances: 2 } }))
|
|
629
|
+
.toMatchObject({ text: 'Scheduled', level: 'healthy' })
|
|
630
|
+
expect(isCNPGPoolerPaused({ spec: { pgbouncer: { paused: true } } })).toBe(true)
|
|
631
|
+
expect(isCNPGPoolerPaused({ spec: {} })).toBe(false)
|
|
632
|
+
})
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
describe('volume health', () => {
|
|
636
|
+
// Field names and shape verified against a live CloudNativePG 1.27 CRD and a
|
|
637
|
+
// running cluster: pvcCount is a number, the rest are arrays of PVC names.
|
|
638
|
+
const healthy = {
|
|
639
|
+
status: { pvcCount: 2, healthyPVC: ['pg-healthy-1', 'pg-healthy-2'] },
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
it('reads what the operator reports about volumes that exist', () => {
|
|
643
|
+
const h = getCNPGVolumeHealth(healthy)
|
|
644
|
+
expect(h).toMatchObject({ total: 2, healthy: ['pg-healthy-1', 'pg-healthy-2'], unusable: [] })
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('surfaces an unusable volume, which is why an instance cannot start', () => {
|
|
648
|
+
// Upstream: unusable means a paired volume is missing.
|
|
649
|
+
const h = getCNPGVolumeHealth({ status: { pvcCount: 3, healthyPVC: ['a'], unusablePVC: ['pg-main-3'] } })
|
|
650
|
+
expect(h?.unusable).toEqual(['pg-main-3'])
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('stays silent when the operator has reported nothing', () => {
|
|
654
|
+
// Absence must not render as "0 problems" — the section is hidden instead,
|
|
655
|
+
// so it never claims a check it did not perform.
|
|
656
|
+
expect(getCNPGVolumeHealth({ status: {} })).toBeNull()
|
|
657
|
+
expect(getCNPGVolumeHealth({})).toBeNull()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('ignores malformed entries rather than rendering them', () => {
|
|
661
|
+
const h = getCNPGVolumeHealth({ status: { pvcCount: 1, healthyPVC: ['ok', 42, null] } })
|
|
662
|
+
expect(h?.healthy).toEqual(['ok'])
|
|
663
|
+
})
|
|
664
|
+
})
|
|
@@ -666,6 +666,12 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
|
666
666
|
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
// Faults outrank intent: a Pooler that is both paused and unscheduled has a
|
|
670
|
+
// problem worth fixing before the pause is worth mentioning.
|
|
671
|
+
if (isCNPGPoolerPaused(resource)) {
|
|
672
|
+
return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
|
|
673
|
+
}
|
|
674
|
+
|
|
669
675
|
if (desired > 0 && scheduled >= desired) {
|
|
670
676
|
return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
|
|
671
677
|
}
|
|
@@ -673,6 +679,16 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
|
673
679
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
674
680
|
}
|
|
675
681
|
|
|
682
|
+
/**
|
|
683
|
+
* PgBouncer holds client connections instead of serving them while paused, and
|
|
684
|
+
* every pod stays scheduled and Ready throughout - the one Pooler state that
|
|
685
|
+
* pod counts cannot express. CRD-defaulted to false, so only an explicit true
|
|
686
|
+
* pauses.
|
|
687
|
+
*/
|
|
688
|
+
export function isCNPGPoolerPaused(resource: any): boolean {
|
|
689
|
+
return resource.spec?.pgbouncer?.paused === true
|
|
690
|
+
}
|
|
691
|
+
|
|
676
692
|
/** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
|
|
677
693
|
export function getCNPGPoolerDeploymentName(resource: any): string {
|
|
678
694
|
return resource.metadata?.name || ''
|
|
@@ -707,3 +723,47 @@ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } |
|
|
|
707
723
|
if (!secret?.name) return undefined
|
|
708
724
|
return { name: secret.name }
|
|
709
725
|
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Volume health for a CNPG cluster, read from the arrays the operator publishes
|
|
729
|
+
* on status. Radar previously showed only the REQUESTED size and storage class,
|
|
730
|
+
* which are spec-side intentions — a cluster whose volumes had failed rendered
|
|
731
|
+
* a cheerful "20Gi" with nothing to suggest otherwise.
|
|
732
|
+
*
|
|
733
|
+
* `unusablePVC` is the sharp one. Upstream defines it as a volume that cannot be
|
|
734
|
+
* used because a paired volume is missing, which is why a database instance can
|
|
735
|
+
* be permanently unable to start while every other field looks ordinary.
|
|
736
|
+
*
|
|
737
|
+
* Returns null when the operator has published nothing, so the section can stay
|
|
738
|
+
* silent rather than claim zero problems it never checked for.
|
|
739
|
+
*/
|
|
740
|
+
export interface CNPGVolumeHealth {
|
|
741
|
+
total?: number
|
|
742
|
+
healthy: string[]
|
|
743
|
+
unusable: string[]
|
|
744
|
+
dangling: string[]
|
|
745
|
+
resizing: string[]
|
|
746
|
+
initializing: string[]
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
|
|
750
|
+
const s = resource?.status
|
|
751
|
+
if (!s) return null
|
|
752
|
+
const list = (v: any): string[] => (Array.isArray(v) ? v.filter((x: any) => typeof x === 'string') : [])
|
|
753
|
+
const health: CNPGVolumeHealth = {
|
|
754
|
+
total: typeof s.pvcCount === 'number' ? s.pvcCount : undefined,
|
|
755
|
+
healthy: list(s.healthyPVC),
|
|
756
|
+
unusable: list(s.unusablePVC),
|
|
757
|
+
dangling: list(s.danglingPVC),
|
|
758
|
+
resizing: list(s.resizingPVC),
|
|
759
|
+
initializing: list(s.initializingPVC),
|
|
760
|
+
}
|
|
761
|
+
const reportedAnything =
|
|
762
|
+
health.total !== undefined ||
|
|
763
|
+
health.healthy.length > 0 ||
|
|
764
|
+
health.unusable.length > 0 ||
|
|
765
|
+
health.dangling.length > 0 ||
|
|
766
|
+
health.resizing.length > 0 ||
|
|
767
|
+
health.initializing.length > 0
|
|
768
|
+
return reportedAnything ? health : null
|
|
769
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getProviderConfigStatus,
|
|
10
10
|
getProviderConfigRef,
|
|
11
11
|
getCrossplaneResourceRefs,
|
|
12
|
+
getBoundXRRef,
|
|
12
13
|
getExternalName,
|
|
13
14
|
getComposingXRRef,
|
|
14
15
|
isCrossplanePaused,
|
|
@@ -512,6 +513,73 @@ describe('getCrossplaneResourceRefs', () => {
|
|
|
512
513
|
})
|
|
513
514
|
})
|
|
514
515
|
|
|
516
|
+
describe('getBoundXRRef', () => {
|
|
517
|
+
it('returns the singular spec.resourceRef of a v1 claim', () => {
|
|
518
|
+
const claim = {
|
|
519
|
+
spec: {
|
|
520
|
+
compositionRef: { name: 'database' },
|
|
521
|
+
resourceRef: {
|
|
522
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
523
|
+
kind: 'Database',
|
|
524
|
+
name: 'example-database-x7k2m',
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
} as const
|
|
528
|
+
const ref = getBoundXRRef(claim)
|
|
529
|
+
expect(ref?.kind).toBe('Database')
|
|
530
|
+
expect(ref?.name).toBe('example-database-x7k2m')
|
|
531
|
+
expect(ref?.apiVersion).toBe('platform.example.org/v1alpha1')
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('returns null when resourceRef is absent or malformed', () => {
|
|
535
|
+
expect(getBoundXRRef({ spec: {} } as const)).toBeNull()
|
|
536
|
+
expect(getBoundXRRef({ spec: { resourceRef: { name: 'no-kind' } } } as const)).toBeNull()
|
|
537
|
+
expect(getBoundXRRef({ spec: { resourceRef: { kind: 'X' } } } as const)).toBeNull()
|
|
538
|
+
expect(getBoundXRRef({} as const)).toBeNull()
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
describe('claim vs XR composed-refs split', () => {
|
|
543
|
+
// A v1 claim carries NO resourceRefs of its own — only a singular resourceRef
|
|
544
|
+
// to its bound XR. The composed refs live on that XR. The claim panel must
|
|
545
|
+
// follow the ref rather than read the claim, or it shows "No composed
|
|
546
|
+
// resources" for a claim that has composed plenty.
|
|
547
|
+
const claim = {
|
|
548
|
+
spec: {
|
|
549
|
+
compositionRef: { name: 'database' },
|
|
550
|
+
resourceRef: {
|
|
551
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
552
|
+
kind: 'Database',
|
|
553
|
+
name: 'example-database-x7k2m',
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
} as const
|
|
557
|
+
const boundXR = {
|
|
558
|
+
spec: {
|
|
559
|
+
resourceRefs: [
|
|
560
|
+
{ apiVersion: 'database.example.org/v1beta1', kind: 'Instance', name: 'example-database-x7k2m' },
|
|
561
|
+
{ apiVersion: 'identity.example.org/v1beta1', kind: 'Role', name: 'example-database-access' },
|
|
562
|
+
{ apiVersion: 'kubernetes.crossplane.io/v1alpha2', kind: 'Object', name: 'example-database-connection' },
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
} as const
|
|
566
|
+
|
|
567
|
+
it('reads nothing off the claim itself', () => {
|
|
568
|
+
expect(getCrossplaneResourceRefs(claim)).toEqual([])
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('is detected as a claim with a bound XR', () => {
|
|
572
|
+
expect(isClaim(claim)).toBe(true)
|
|
573
|
+
expect(getBoundXRRef(claim)?.name).toBe('example-database-x7k2m')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('reads the composed refs off the bound XR', () => {
|
|
577
|
+
const refs = getCrossplaneResourceRefs(boundXR)
|
|
578
|
+
expect(refs).toHaveLength(3)
|
|
579
|
+
expect(refs.map(r => r.kind)).toEqual(['Instance', 'Role', 'Object'])
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
515
583
|
describe('getExternalName', () => {
|
|
516
584
|
it('reads the crossplane.io/external-name annotation', () => {
|
|
517
585
|
const resource = {
|