@skyhook-io/k8s-ui 1.18.0 → 1.18.2
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/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import type { StatusBadge } from './resource-utils'
|
|
9
9
|
import { healthColors } from './resource-utils'
|
|
10
|
+
import { getGenericResourceStatus, type GenericStatus } from './generic-status'
|
|
11
|
+
import type { ConditionTone } from '../ui/drawer-components'
|
|
10
12
|
|
|
11
13
|
interface CrossplaneCondition {
|
|
12
14
|
type: string
|
|
@@ -138,15 +140,37 @@ export function getDeletionPolicy(data: any): string | null {
|
|
|
138
140
|
return data?.spec?.crossplane?.deletionPolicy ?? data?.spec?.deletionPolicy ?? null
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
/**
|
|
142
|
-
* Crossplane convention: setting `crossplane.io/paused: "true"` on any MR/XR/Claim
|
|
143
|
-
* stops the provider from reconciling it. Universal across every provider, so
|
|
144
|
-
* surfacing it as a chip on every Crossplane resource is high-leverage.
|
|
145
|
-
*/
|
|
146
143
|
export function isCrossplanePaused(data: any): boolean {
|
|
147
144
|
return data?.metadata?.annotations?.['crossplane.io/paused'] === 'true'
|
|
148
145
|
}
|
|
149
146
|
|
|
147
|
+
export function isExpectedCrossplanePauseCondition(data: any, condition: CrossplaneCondition): boolean {
|
|
148
|
+
return isCrossplanePaused(data) && condition?.type === 'Synced'
|
|
149
|
+
&& condition.status === 'False' && condition.reason === 'ReconcilePaused'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getCrossplanePauseState(data: any): 'requested' | 'acknowledged' | null {
|
|
153
|
+
if (!isCrossplanePaused(data)) return null
|
|
154
|
+
const conditions = data?.status?.conditions
|
|
155
|
+
return Array.isArray(conditions) && conditions.some((condition: CrossplaneCondition) => isExpectedCrossplanePauseCondition(data, condition))
|
|
156
|
+
? 'acknowledged' : 'requested'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function getCrossplaneIndependentStatus(data: any): GenericStatus | null {
|
|
160
|
+
if (getCrossplanePauseState(data) !== 'acknowledged') return getGenericResourceStatus(data)
|
|
161
|
+
return getGenericResourceStatus({
|
|
162
|
+
...data,
|
|
163
|
+
status: {
|
|
164
|
+
...data.status,
|
|
165
|
+
conditions: data.status.conditions.filter((condition: CrossplaneCondition) => !isExpectedCrossplanePauseCondition(data, condition)),
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getCrossplaneConditionTone(data: any, condition: CrossplaneCondition): ConditionTone | undefined {
|
|
171
|
+
return isExpectedCrossplanePauseCondition(data, condition) ? 'neutral' : undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
/**
|
|
151
175
|
* Walk ownerReferences to find the parent Composite for a Managed Resource.
|
|
152
176
|
* Crossplane sets exactly one ownerRef pointing at the composing XR. Returns
|
|
@@ -186,16 +210,24 @@ export function getCrossplaneStatus(data: any): StatusBadge {
|
|
|
186
210
|
}
|
|
187
211
|
const ready = findCondition(data, 'Ready')
|
|
188
212
|
const synced = findCondition(data, 'Synced')
|
|
213
|
+
const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
|
|
214
|
+
? getCrossplaneIndependentStatus(data) : null
|
|
215
|
+
if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) {
|
|
216
|
+
return { text: independent.text, color: healthColors[independent.tone], level: independent.tone }
|
|
217
|
+
}
|
|
189
218
|
|
|
190
219
|
if (!ready && !synced) {
|
|
191
220
|
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
192
221
|
}
|
|
193
|
-
if (synced?.status === 'False') {
|
|
194
|
-
return { text: 'Out of sync', color: healthColors.alert, level: 'alert' }
|
|
222
|
+
if (synced?.status === 'False' && !isExpectedCrossplanePauseCondition(data, synced)) {
|
|
223
|
+
return { text: synced.reason === 'ReconcilePaused' ? 'ReconcilePaused' : 'Out of sync', color: healthColors.alert, level: 'alert' }
|
|
195
224
|
}
|
|
196
225
|
if (ready?.status === 'False') {
|
|
197
226
|
return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
198
227
|
}
|
|
228
|
+
if (getCrossplanePauseState(data) === 'acknowledged') {
|
|
229
|
+
return { text: ready?.status === 'True' ? 'Ready (reported)' : 'Readiness unknown', color: healthColors.neutral, level: 'neutral' }
|
|
230
|
+
}
|
|
199
231
|
if (ready?.status === 'True' && synced?.status === 'True') {
|
|
200
232
|
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
201
233
|
}
|
|
@@ -211,8 +243,12 @@ export function getCrossplaneStatus(data: any): StatusBadge {
|
|
|
211
243
|
* detail views.
|
|
212
244
|
*/
|
|
213
245
|
export function getCrossplaneStatusReason(data: any): string | null {
|
|
246
|
+
const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
|
|
247
|
+
? getCrossplaneIndependentStatus(data) : null
|
|
248
|
+
if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) return independent.reason ?? null
|
|
214
249
|
const ready = findCondition(data, 'Ready')
|
|
215
|
-
const
|
|
250
|
+
const reportedSynced = findCondition(data, 'Synced')
|
|
251
|
+
const synced = reportedSynced && !isExpectedCrossplanePauseCondition(data, reportedSynced) ? reportedSynced : undefined
|
|
216
252
|
if (synced && synced.status !== 'True' && synced.message) return synced.message
|
|
217
253
|
if (ready && ready.status !== 'True' && ready.message) return ready.message
|
|
218
254
|
if (synced && synced.status !== 'True' && synced.reason) return synced.reason
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { issueTitle } from '../issues/severity'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { renderToString } from 'react-dom/server'
|
|
5
|
+
import { getFlaggerCanaryStatus, getFlaggerStrategy, getFlaggerTargetRef, getFlaggerAutoscalerRef, getFlaggerMetricTemplateRef } from './resource-utils-flagger'
|
|
6
|
+
import { getGenericResourceStatus } from './generic-status'
|
|
7
|
+
import { getCellFilterValue } from './resource-utils'
|
|
8
|
+
import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
9
|
+
|
|
10
|
+
const vectors = JSON.parse(readFileSync(new URL('../../../../../pkg/health/testdata/flagger.json', import.meta.url), 'utf8'))
|
|
11
|
+
describe('Flagger recorded rollout state across surfaces', () => {
|
|
12
|
+
it('names the failed operation in Issues', () => {
|
|
13
|
+
expect(issueTitle({ category: 'operator_condition_fail', reason: 'FlaggerCanaryFailed' })).toBe('Canary rollout failed')
|
|
14
|
+
})
|
|
15
|
+
for (const vector of vectors) it(vector.name, () => {
|
|
16
|
+
expect(getFlaggerCanaryStatus(vector.object)).toMatchObject({ text: vector.text, level: vector.level })
|
|
17
|
+
expect(getGenericResourceStatus(vector.object)).toMatchObject({ text: vector.text, tone: vector.level })
|
|
18
|
+
expect(getResourceStatus('canaries', vector.object)).toMatchObject({ text: vector.text, level: vector.level })
|
|
19
|
+
expect(getCellFilterValue(vector.object, 'status', 'canaries')).toBe(vector.text)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('Flagger controller reference semantics', () => {
|
|
24
|
+
const resource = (spec: any) => ({ metadata: { namespace: 'application' }, spec })
|
|
25
|
+
it('resolves native targets with the controller factory, including the Knative Service collision', () => {
|
|
26
|
+
expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Deployment', apiVersion: 'foreign.example/v1', name: 'app' } }))).toMatchObject({ group: 'apps', name: 'app', namespace: 'application' })
|
|
27
|
+
expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'serving.knative.dev/v1', name: 'app' } }))).toMatchObject({ group: 'serving.knative.dev' })
|
|
28
|
+
expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'other.example/v1', name: 'app' } }))).toMatchObject({ group: '' })
|
|
29
|
+
expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'FutureKind', name: 'app' } }))).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
it('links exact supported scalers and always uses the typed MetricTemplate client', () => {
|
|
32
|
+
expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'HorizontalPodAutoscaler', name: 'app' } }))).toMatchObject({ group: 'autoscaling', namespace: 'application' })
|
|
33
|
+
expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'ScaledObject', name: 'app' } }))).toMatchObject({ group: 'keda.sh' })
|
|
34
|
+
expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { apiVersion: 'ignored.example/v1', kind: 'Ignored', name: 'latency', namespace: 'monitoring' } })).toMatchObject({ group: 'flagger.app', name: 'latency', namespace: 'monitoring' })
|
|
35
|
+
expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { name: 'latency' } })).toMatchObject({ namespace: 'application' })
|
|
36
|
+
expect(getFlaggerMetricTemplateRef(resource({}), { name: 'request-success-rate' })).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
it('distinguishes strategies, including the actually served deprecated analysis field', () => {
|
|
39
|
+
expect(getFlaggerStrategy(resource({ analysis: { iterations: 3 } }))).toBe('Blue/green')
|
|
40
|
+
expect(getFlaggerStrategy(resource({ analysis: { iterations: 3, match: [{}] } }))).toBe('A/B testing')
|
|
41
|
+
expect(getFlaggerStrategy(resource({ canaryAnalysis: { iterations: 3, mirror: true } }))).toBe('Blue/green with mirroring')
|
|
42
|
+
expect(getFlaggerStrategy(resource({ analysis: { stepWeight: 10 } }))).toBe('Canary')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Flagger drawer identity and useful states', () => {
|
|
47
|
+
function render(apiVersion: string, kind: string, spec = {}, status = {}) {
|
|
48
|
+
return renderToString(<ResourceRendererDispatch resource={{ kind: kind === 'Canary' ? 'canaries' : 'metrictemplates', namespace: 'test', name: 'test' }} data={{ apiVersion, kind, metadata: { name: 'test', namespace: 'test' }, spec, status }} onCopy={() => {}} copied={null} showCommonSections={false} />)
|
|
49
|
+
}
|
|
50
|
+
it('preserves zero reported weight, failure under suspension, and keeps built-in metrics distinct', () => {
|
|
51
|
+
const html = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis: { stepWeight: 10, metrics: [{ name: 'request-success-rate' }, { name: 'custom', query: 'sensitive-inline-query' }] } }, { phase: 'Failed', canaryWeight: 0 })
|
|
52
|
+
expect(html).toContain('0%')
|
|
53
|
+
expect(html).toContain('Failed')
|
|
54
|
+
expect(html).toContain('Built-in provider metric')
|
|
55
|
+
expect(html).toContain('Inline query')
|
|
56
|
+
expect(html).toContain('sensitive-inline-query')
|
|
57
|
+
})
|
|
58
|
+
it('does not show weight for blue/green or provider headers in native details', () => {
|
|
59
|
+
expect(render('flagger.app/v1beta1', 'Canary', { analysis: { iterations: 2 } }, { canaryWeight: 10 })).not.toContain('Reported canary weight')
|
|
60
|
+
expect(render('flagger.app/v1beta1', 'MetricTemplate', { provider: { type: 'prometheus', headers: { 'X-Key': 'private-value' } }, query: 'vector(1)' })).not.toContain('private-value')
|
|
61
|
+
})
|
|
62
|
+
it('shows the controller-default rollout hook when type is omitted', () => {
|
|
63
|
+
const html = render('flagger.app/v1beta1', 'Canary', { analysis: { webhooks: [{ name: 'load-test' }, { name: 'approve', type: 'confirm-rollout' }] } }, { phase: 'Waiting' })
|
|
64
|
+
expect(html).toContain('load-test')
|
|
65
|
+
expect(html).toContain('rollout')
|
|
66
|
+
expect(html).toContain('approve')
|
|
67
|
+
expect(html).toContain('confirm-rollout')
|
|
68
|
+
})
|
|
69
|
+
it('describes an active confirmation wait only while not suspended', () => {
|
|
70
|
+
const analysis = { webhooks: [{ name: 'approve', type: 'confirm-rollout' }] }
|
|
71
|
+
expect(render('flagger.app/v1beta1', 'Canary', { analysis }, { phase: 'Waiting' })).toContain('Flagger is waiting for a confirmation hook')
|
|
72
|
+
const suspended = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis }, { phase: 'Waiting' })
|
|
73
|
+
expect(suspended).not.toContain('Flagger is waiting for a confirmation hook')
|
|
74
|
+
expect(suspended).toContain('approve')
|
|
75
|
+
expect(suspended).toContain('Recorded phase')
|
|
76
|
+
})
|
|
77
|
+
for (const kind of ['Canary', 'MetricTemplate']) it(`keeps foreign ${kind} generic`, () => {
|
|
78
|
+
const html = render('other.example/v1', kind, { custom: 'generic-visible' })
|
|
79
|
+
expect(html).toContain('generic-visible')
|
|
80
|
+
expect(html).not.toContain(kind === 'Canary' ? 'Canary Rollout' : 'Metric Provider')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isApiGroup } from './resource-utils-cnpg'
|
|
2
|
+
import type { ResourceRef } from '../../types'
|
|
3
|
+
import { objectReferenceToResourceRef } from '../../utils/navigation'
|
|
4
|
+
import type { StatusBadge } from './resource-utils'
|
|
5
|
+
import { healthColors } from './resource-utils'
|
|
6
|
+
|
|
7
|
+
export function getFlaggerCanaryStatus(data: any): StatusBadge {
|
|
8
|
+
const phase = data.status?.phase
|
|
9
|
+
let text = phase || 'Not reported'
|
|
10
|
+
let level: StatusBadge['level'] = 'unknown'
|
|
11
|
+
if (phase === 'Failed') level = 'unhealthy'
|
|
12
|
+
else if (!phase && data.spec?.suspend === true) { text = 'Suspend requested'; level = 'neutral' }
|
|
13
|
+
else if (phase === 'Succeeded') level = data.spec?.suspend === true ? 'neutral' : 'healthy'
|
|
14
|
+
else if (['Initializing', 'Initialized', 'Waiting', 'Progressing', 'WaitingPromotion', 'Promoting', 'Finalising', 'Terminating', 'Terminated'].includes(phase)) level = 'neutral'
|
|
15
|
+
return { text, level, color: healthColors[level] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Both fields exist in the served v1beta1 schema. The controller's GetAnalysis
|
|
19
|
+
// explicitly prefers analysis and still accepts deprecated canaryAnalysis.
|
|
20
|
+
export function getFlaggerAnalysis(data: any): any {
|
|
21
|
+
return data.spec?.analysis ?? data.spec?.canaryAnalysis ?? {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getFlaggerStrategy(data: any): string {
|
|
25
|
+
const analysis = getFlaggerAnalysis(data)
|
|
26
|
+
if (analysis.iterations > 0) {
|
|
27
|
+
if (analysis.match?.length > 0) return 'A/B testing'
|
|
28
|
+
return analysis.mirror ? 'Blue/green with mirroring' : 'Blue/green'
|
|
29
|
+
}
|
|
30
|
+
return analysis.mirror ? 'Canary with mirroring' : 'Canary'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Match the pinned controller factory: it chooses its native clients by kind,
|
|
34
|
+
// not arbitrary targetRef.apiVersion (except the exact Knative Service case).
|
|
35
|
+
export function getFlaggerTargetRef(data: any): ResourceRef | null {
|
|
36
|
+
const target = data.spec?.targetRef
|
|
37
|
+
if (!target?.name) return null
|
|
38
|
+
let apiVersion: string
|
|
39
|
+
if (target.kind === 'Deployment' || target.kind === 'DaemonSet') apiVersion = 'apps/v1'
|
|
40
|
+
else if (target.kind === 'Service') apiVersion = target.apiVersion === 'serving.knative.dev/v1' ? target.apiVersion : 'v1'
|
|
41
|
+
else return null
|
|
42
|
+
return objectReferenceToResourceRef({ ...target, apiVersion, namespace: data.metadata?.namespace })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getFlaggerAutoscalerRef(data: any): ResourceRef | null {
|
|
46
|
+
const scaler = data.spec?.autoscalerRef
|
|
47
|
+
const apiVersion = scaler?.kind === 'HorizontalPodAutoscaler' ? 'autoscaling/v2' : scaler?.kind === 'ScaledObject' ? 'keda.sh/v1alpha1' : null
|
|
48
|
+
return apiVersion ? objectReferenceToResourceRef({ ...scaler, apiVersion, namespace: data.metadata?.namespace }) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// scheduler_metrics.go reads only templateRef.name/namespace through its typed
|
|
52
|
+
// MetricTemplate client. apiVersion/kind are ignored, so do not link elsewhere.
|
|
53
|
+
export function getFlaggerMetricTemplateRef(data: any, metric: any): ResourceRef | null {
|
|
54
|
+
return metric.templateRef?.name ? objectReferenceToResourceRef({ apiVersion: 'flagger.app/v1beta1', kind: 'MetricTemplate', name: metric.templateRef.name, namespace: metric.templateRef.namespace || data.metadata?.namespace }) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getFlaggerColumnValue(data: any, column: string): string | undefined {
|
|
58
|
+
if (!isApiGroup(data?.apiVersion, 'flagger.app')) return undefined
|
|
59
|
+
if (data.kind === 'Canary') {
|
|
60
|
+
switch (column) {
|
|
61
|
+
case 'status': return getFlaggerCanaryStatus(data).text
|
|
62
|
+
case 'strategy': return getFlaggerStrategy(data)
|
|
63
|
+
case 'target': return data.spec?.targetRef?.name ? `${data.spec.targetRef.kind}/${data.spec.targetRef.name}` : ''
|
|
64
|
+
case 'canaryWeight': return getFlaggerAnalysis(data).iterations > 0 ? 'Not weighted' : data.status?.canaryWeight != null ? `${data.status.canaryWeight}%` : 'Not reported'
|
|
65
|
+
case 'suspended': return data.spec?.suspend === true ? 'Yes' : 'No'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (data.kind === 'MetricTemplate') {
|
|
69
|
+
switch (column) {
|
|
70
|
+
case 'status': return 'Configured'
|
|
71
|
+
case 'provider': return data.spec?.provider?.type ?? ''
|
|
72
|
+
case 'address': return data.spec?.provider?.address ?? ''
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
4
|
+
import { getCollectorPipelines, getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
|
|
5
|
+
import { getGenericResourceStatus } from './generic-status'
|
|
6
|
+
import { getCellFilterValue } from './resource-utils'
|
|
7
|
+
import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
8
|
+
|
|
9
|
+
const vectors = JSON.parse(readFileSync(new URL('../../../../../pkg/health/testdata/opentelemetry.json', import.meta.url), 'utf8'))
|
|
10
|
+
|
|
11
|
+
describe('OpenTelemetry status across surfaces', () => {
|
|
12
|
+
for (const vector of vectors) it(vector.name, () => {
|
|
13
|
+
const resource = vector.object
|
|
14
|
+
const kind = resource.kind === 'Instrumentation' ? 'instrumentations' : 'opentelemetrycollectors'
|
|
15
|
+
const state = resource.kind === 'Instrumentation' ? getOpenTelemetryInstrumentationStatus(resource) : getOpenTelemetryCollectorStatus(resource)
|
|
16
|
+
expect(state).toMatchObject({ text: vector.text, level: vector.level })
|
|
17
|
+
expect(getGenericResourceStatus(resource)).toMatchObject({ text: vector.text, tone: vector.level })
|
|
18
|
+
expect(getCellFilterValue(resource, 'status', kind)).toBe(vector.text)
|
|
19
|
+
expect(getResourceStatus(kind, resource)).toMatchObject({ text: vector.text, level: vector.level })
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('Collector pipeline declarations', () => {
|
|
24
|
+
const config = { connectors: { spanmetrics: {} }, service: { pipelines: { traces: { receivers: ['otlp'], processors: ['batch'], exporters: ['spanmetrics'] }, metrics: { receivers: ['spanmetrics'], exporters: ['debug'] } } } }
|
|
25
|
+
it('reads beta structured and genuinely served alpha YAML configuration', () => {
|
|
26
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1beta1', spec: { config } })).toMatchObject({ connectors: ['spanmetrics'], pipelines: [{ name: 'traces', receivers: ['otlp'], processors: ['batch'], exporters: ['spanmetrics'] }, { name: 'metrics' }] })
|
|
27
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: 'service:\n pipelines:\n traces:\n receivers: [otlp]\n exporters: [debug]\n' } }).pipelines).toHaveLength(1)
|
|
28
|
+
})
|
|
29
|
+
it('reports unavailable and malformed previews instead of a false empty configuration', () => {
|
|
30
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: '[broken' } }).error).toContain('Inspect YAML')
|
|
31
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1beta1', spec: { config: 'service: {}' } }).error).toBeTruthy()
|
|
32
|
+
expect(getCollectorPipelines({ spec: { config: { service: { pipelines: [] } } } }).error).toBeTruthy()
|
|
33
|
+
for (const service of ['invalid', 1, false, null, []]) {
|
|
34
|
+
expect(getCollectorPipelines({ spec: { config: { service } } }).error).toContain('Inspect YAML')
|
|
35
|
+
}
|
|
36
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: 'service: invalid' } }).error).toContain('Inspect YAML')
|
|
37
|
+
for (const pipeline of ['invalid', 1, false, null, []]) {
|
|
38
|
+
expect(getCollectorPipelines({ spec: { config: { service: { pipelines: { traces: pipeline } } } } }).error).toContain('Inspect YAML')
|
|
39
|
+
}
|
|
40
|
+
expect(getCollectorPipelines({ spec: { config: {} } }).error).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
it('bounds alpha YAML size and alias expansion before previewing pipelines', () => {
|
|
43
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: ' '.repeat(1_000_001) } }).error).toContain('too large')
|
|
44
|
+
const aliases = 'base: &a [otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp]\nlevel1: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]\nlevel2: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]\nservice:\n pipelines:\n traces:\n receivers: *c\n'
|
|
45
|
+
expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: aliases } }).error).toContain('Inspect YAML')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('OpenTelemetry drawer identity and meaning', () => {
|
|
51
|
+
function render(apiVersion: string, kind: string, spec = {}, status = {}) {
|
|
52
|
+
return renderToString(<ResourceRendererDispatch resource={{ kind: kind === 'Instrumentation' ? 'instrumentations' : 'opentelemetrycollectors', namespace: 'test', name: 'test' }} data={{ apiVersion, kind, metadata: { name: 'test', namespace: 'test' }, spec, status }} onCopy={() => {}} copied={null} showCommonSections={false} />)
|
|
53
|
+
}
|
|
54
|
+
it('renders native reconciliation/pipeline context only for the exact group', () => {
|
|
55
|
+
expect(render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector')).toContain('Reconciliation')
|
|
56
|
+
expect(render('other.example/v1', 'OpenTelemetryCollector')).not.toContain('Configured Pipelines')
|
|
57
|
+
expect(render('other.example/v1', 'OpenTelemetryCollector', { custom: 'generic-visible' })).toContain('generic-visible')
|
|
58
|
+
})
|
|
59
|
+
it('retains an unmanaged condition without presenting it as a current failure', () => {
|
|
60
|
+
const status = { conditions: [{ type: 'Ready', status: 'False', reason: 'ReconcileError' }] }
|
|
61
|
+
const managed = render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector', { managementState: 'managed' }, status)
|
|
62
|
+
expect(managed).toContain('1 failing')
|
|
63
|
+
const unmanaged = render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector', { managementState: 'unmanaged' }, status)
|
|
64
|
+
expect(unmanaged).toContain('ReconcileError')
|
|
65
|
+
expect(unmanaged).not.toContain('1 failing')
|
|
66
|
+
})
|
|
67
|
+
it('does not claim instrumentation readiness or dump arbitrary env values', () => {
|
|
68
|
+
const html = render('opentelemetry.io/v1alpha1', 'Instrumentation', { env: [{ name: 'TOKEN', value: 'do-not-surface' }] })
|
|
69
|
+
expect(html).toContain('Configured')
|
|
70
|
+
expect(html).toContain('does not report which Pods')
|
|
71
|
+
expect(html).not.toContain('do-not-surface')
|
|
72
|
+
expect(render('other.example/v1', 'Instrumentation', { custom: 'generic-visible' })).toContain('generic-visible')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { isApiGroup } from './resource-utils-cnpg'
|
|
2
|
+
import { parseDocument } from 'yaml'
|
|
3
|
+
import type { StatusBadge } from './resource-utils'
|
|
4
|
+
import { healthColors } from './resource-utils'
|
|
5
|
+
|
|
6
|
+
// Ready describes reconciliation only. In the operator, Ready=True can coexist
|
|
7
|
+
// with 0/1 ready Pods. Never derive runtime health from this condition or scale.
|
|
8
|
+
export function getOpenTelemetryCollectorStatus(data: any): StatusBadge {
|
|
9
|
+
const badge = (text: string, level: StatusBadge['level']): StatusBadge => ({ text, level, color: healthColors[level] })
|
|
10
|
+
if (data.spec?.managementState === 'unmanaged') return badge('Unmanaged', 'neutral')
|
|
11
|
+
const ready = data.status?.conditions?.find((c: any) => c.type === 'Ready')
|
|
12
|
+
if (!ready) return badge('Not reported', 'unknown')
|
|
13
|
+
const observed = data.status?.observedGeneration
|
|
14
|
+
if (observed > 0 && observed < data.metadata?.generation) return badge('Status stale', 'unknown')
|
|
15
|
+
if (ready.status === 'False') return badge(ready.reason === 'StatusUpdateFailed' ? 'Status unavailable' : 'Reconcile failed', ready.reason === 'StatusUpdateFailed' ? 'unknown' : 'unhealthy')
|
|
16
|
+
if (ready.status === 'True') return badge('Reconciled', 'neutral')
|
|
17
|
+
return badge('Reconciliation unknown', 'unknown')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getOpenTelemetryInstrumentationStatus(data: any): StatusBadge {
|
|
21
|
+
const blocked = Object.keys(data.status?.upgradeBlockedVersions ?? {}).length > 0
|
|
22
|
+
const level = 'neutral'
|
|
23
|
+
return { text: blocked ? 'Upgrade blocked' : 'Configured', level, color: healthColors[level] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CollectorPipeline {
|
|
27
|
+
name: string
|
|
28
|
+
receivers: string[]
|
|
29
|
+
processors: string[]
|
|
30
|
+
exporters: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CollectorPipelines {
|
|
34
|
+
pipelines: CollectorPipeline[]
|
|
35
|
+
connectors: string[]
|
|
36
|
+
error?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getCollectorPipelines(data: any): CollectorPipelines {
|
|
40
|
+
let config = data.spec?.config
|
|
41
|
+
// These are two genuinely served API versions, not a response-shape fallback.
|
|
42
|
+
if (data.apiVersion === 'opentelemetry.io/v1alpha1' && typeof config === 'string') {
|
|
43
|
+
if (config.length > 1_000_000) return { pipelines: [], connectors: [], error: 'Configuration is too large for a pipeline preview. Inspect YAML.' }
|
|
44
|
+
try {
|
|
45
|
+
const document = parseDocument(config)
|
|
46
|
+
if (document.errors.length) throw new Error('Invalid YAML')
|
|
47
|
+
config = document.toJS({ maxAliasCount: 100 })
|
|
48
|
+
} catch {
|
|
49
|
+
return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
53
|
+
return { pipelines: [], connectors: [], error: 'No structured pipeline configuration is available. Inspect YAML.' }
|
|
54
|
+
}
|
|
55
|
+
const service = config.service
|
|
56
|
+
if (service !== undefined && (!service || typeof service !== 'object' || Array.isArray(service))) {
|
|
57
|
+
return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
|
|
58
|
+
}
|
|
59
|
+
const declared = service?.pipelines
|
|
60
|
+
if (declared != null && (typeof declared !== 'object' || Array.isArray(declared))) {
|
|
61
|
+
return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
|
|
62
|
+
}
|
|
63
|
+
if (Object.values(declared ?? {}).some(pipeline => !pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline))) {
|
|
64
|
+
return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
|
|
65
|
+
}
|
|
66
|
+
const names = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
|
|
67
|
+
return {
|
|
68
|
+
pipelines: Object.entries(declared ?? {}).map(([name, pipeline]: [string, any]) => ({
|
|
69
|
+
name, receivers: names(pipeline?.receivers), processors: names(pipeline?.processors), exporters: names(pipeline?.exporters),
|
|
70
|
+
})),
|
|
71
|
+
connectors: Object.keys(config.connectors ?? {}),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A single reader backs native cells, their filters and sorting.
|
|
76
|
+
export function getOpenTelemetryColumnValue(data: any, column: string): string | undefined {
|
|
77
|
+
if (!isApiGroup(data?.apiVersion, 'opentelemetry.io')) return undefined
|
|
78
|
+
if (data.kind === 'OpenTelemetryCollector') {
|
|
79
|
+
switch (column) {
|
|
80
|
+
case 'status': return getOpenTelemetryCollectorStatus(data).text
|
|
81
|
+
case 'mode': return data.spec?.mode ?? ''
|
|
82
|
+
case 'reportedPods': return data.status?.scale?.statusReplicas ?? ''
|
|
83
|
+
case 'version': return data.status?.version ?? ''
|
|
84
|
+
case 'image': return data.status?.image ?? ''
|
|
85
|
+
case 'management': return data.spec?.managementState ?? ''
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (data.kind === 'Instrumentation') {
|
|
89
|
+
switch (column) {
|
|
90
|
+
case 'status': return getOpenTelemetryInstrumentationStatus(data).text
|
|
91
|
+
case 'endpoint': return data.spec?.exporter?.endpoint ?? ''
|
|
92
|
+
case 'sampler': return data.spec?.sampler?.type ?? ''
|
|
93
|
+
case 'samplerArgument': return data.spec?.sampler?.argument ?? ''
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { searchTrivyFindings, trivyReportImageRef } from './resource-utils-trivy'
|
|
3
|
+
|
|
4
|
+
const vuln = {
|
|
5
|
+
metadata: { name: 'replicaset-billing-5cfccf7776-php-nginx', namespace: 'legacy' },
|
|
6
|
+
report: {
|
|
7
|
+
registry: { server: 'index.docker.io' },
|
|
8
|
+
artifact: { repository: 'trafex/php-nginx', tag: '3.6.0', digest: 'sha256:abc' },
|
|
9
|
+
vulnerabilities: [
|
|
10
|
+
{ vulnerabilityID: 'CVE-2025-0001', resource: 'libssl3', installedVersion: '3.3.2-r0', fixedVersion: '3.3.3-r0' },
|
|
11
|
+
{ vulnerabilityID: 'CVE-2026-109618', resource: 'php83', installedVersion: '8.3.20-r0', fixedVersion: '8.3.36-r0' },
|
|
12
|
+
{ vulnerabilityID: 'CVE-2026-0002', resource: 'busybox', installedVersion: '1.36.1-r29' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const secrets = {
|
|
17
|
+
metadata: { name: 'replicaset-billing-secrets', namespace: 'legacy' },
|
|
18
|
+
report: { secrets: [{ ruleID: 'aws-access-key-id', target: '/var/www/html/.env', match: 'AWS_ACCESS_KEY_ID=AKIAREVIEWFIXTURE01' }] },
|
|
19
|
+
}
|
|
20
|
+
const audit = {
|
|
21
|
+
metadata: { name: 'replicaset-api-8467889d75', namespace: 'team-b' },
|
|
22
|
+
report: { checks: [{ checkID: 'KSV012', success: false }, { checkID: 'KSV001', success: true }] },
|
|
23
|
+
}
|
|
24
|
+
const sbom = {
|
|
25
|
+
metadata: { name: 'replicaset-web-sbom', namespace: 'shop' },
|
|
26
|
+
report: { components: { components: [{ name: 'guzzlehttp/guzzle', version: '7.8.1' }] } },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('searchTrivyFindings', () => {
|
|
30
|
+
it('matches a CVE and names the finding with its versions', () => {
|
|
31
|
+
const { matches, truncated } = searchTrivyFindings([vuln, secrets], 'cve-2026-109618')
|
|
32
|
+
expect(truncated).toBe(false)
|
|
33
|
+
expect([...matches.keys()]).toEqual([vuln])
|
|
34
|
+
expect(matches.get(vuln)).toEqual({ site: 'finding', text: 'CVE-2026-109618 php83 8.3.20-r0 → 8.3.36-r0' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('matches packages, unfixed findings, SBOM components and failed checks', () => {
|
|
38
|
+
const text = (r: any, term: string) => searchTrivyFindings([r], term).matches.get(r)?.text
|
|
39
|
+
expect(text(vuln, 'php83')).toContain('php83')
|
|
40
|
+
expect(text(vuln, 'busybox')).toBe('CVE-2026-0002 busybox 1.36.1-r29 → unfixed')
|
|
41
|
+
expect(text(sbom, 'guzzle')).toBe('guzzlehttp/guzzle 7.8.1')
|
|
42
|
+
expect(text(audit, 'ksv012')).toBe('KSV012')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tells an image match apart from a finding', () => {
|
|
46
|
+
expect(searchTrivyFindings([vuln], 'sha256:abc').matches.get(vuln)).toEqual({ site: 'image', text: 'index.docker.io/trafex/php-nginx:3.6.0@sha256:abc' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('treats the term as text, not a pattern', () => {
|
|
50
|
+
expect(searchTrivyFindings([vuln], '8.3.20-r0 →').matches.has(vuln)).toBe(true)
|
|
51
|
+
expect(searchTrivyFindings([vuln], 'php8.').matches.size).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('caps a finding at 256 UTF-8 bytes for phrases, like server search', () => {
|
|
55
|
+
const wide = { metadata: { name: 'r', namespace: 'n' }, report: { components: { components: [{ name: 'é'.repeat(128), version: 'needle' }] } } }
|
|
56
|
+
expect(searchTrivyFindings([wide], 'é needle').matches.size).toBe(0)
|
|
57
|
+
expect(searchTrivyFindings([wide], 'needle').matches.size).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('matches a phrase across one finding, never across findings', () => {
|
|
61
|
+
expect(searchTrivyFindings([vuln], 'php83 8.3.20').matches.has(vuln)).toBe(true)
|
|
62
|
+
expect(searchTrivyFindings([vuln], 'libssl3 8.3.20').matches.has(vuln)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never searches a passing check or a secret finding\'s matched text', () => {
|
|
66
|
+
expect(searchTrivyFindings([audit], 'ksv001').matches.size).toBe(0)
|
|
67
|
+
expect(searchTrivyFindings([secrets], 'akiareview').matches.size).toBe(0)
|
|
68
|
+
expect(searchTrivyFindings([secrets], 'aws-access-key-id').matches.get(secrets)?.text).toBe('aws-access-key-id /var/www/html/.env')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('skips rows whose name or namespace already match', () => {
|
|
72
|
+
expect(searchTrivyFindings([vuln], 'legacy').matches.size).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('stops at the findings limit and says the search was cut short', () => {
|
|
76
|
+
const { matches, truncated } = searchTrivyFindings([vuln, vuln], 'cve-2026-0002', 4)
|
|
77
|
+
expect(truncated).toBe(true)
|
|
78
|
+
expect(matches.size).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('trivyReportImageRef', () => {
|
|
83
|
+
it('renders registry/repository:tag@digest like server search', () => {
|
|
84
|
+
expect(trivyReportImageRef(vuln)).toBe('index.docker.io/trafex/php-nginx:3.6.0@sha256:abc')
|
|
85
|
+
expect(trivyReportImageRef({ report: {} })).toBe('')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -55,6 +55,128 @@ export function getVulnerabilityReportContainer(report: any): string {
|
|
|
55
55
|
return report.metadata?.labels?.['trivy-operator.container.name'] || '-'
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// TRIVY OPERATOR — TABLE SEARCH OVER FINDINGS
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export const TRIVY_GROUP = 'aquasecurity.github.io'
|
|
63
|
+
|
|
64
|
+
// Bounds one table search over findings. The filter runs on the main thread,
|
|
65
|
+
// and a cluster can hold thousands of reports with hundreds of findings each.
|
|
66
|
+
const MAX_TABLE_FINDINGS = 200_000
|
|
67
|
+
|
|
68
|
+
// Server search caps one indexed finding at 256 bytes (internal/search/trivy.go).
|
|
69
|
+
const MAX_FINDING_LABEL = 256
|
|
70
|
+
|
|
71
|
+
export interface TrivyFindingMatch {
|
|
72
|
+
site: 'finding' | 'image'
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TrivyFindingsSearch {
|
|
77
|
+
// Rows that matched only through a finding or the scanned image.
|
|
78
|
+
matches: Map<object, TrivyFindingMatch>
|
|
79
|
+
truncated: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function str(v: unknown): string {
|
|
83
|
+
return typeof v === 'string' ? v : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asArray(v: unknown): any[] {
|
|
87
|
+
return Array.isArray(v) ? v : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// findingLabel joins a finding's parts and caps it at MAX_FINDING_LABEL
|
|
91
|
+
// UTF-8 bytes on a character boundary, as server search does, so a phrase
|
|
92
|
+
// that falls past the cap matches in neither place.
|
|
93
|
+
function findingLabel(...parts: string[]): string {
|
|
94
|
+
const label = parts.filter(Boolean).join(' ')
|
|
95
|
+
// A UTF-16 unit is at most 3 UTF-8 bytes, so short labels need no count.
|
|
96
|
+
if (label.length * 3 <= MAX_FINDING_LABEL) return label
|
|
97
|
+
let bytes = 0
|
|
98
|
+
let end = 0
|
|
99
|
+
for (const ch of label) {
|
|
100
|
+
const cp = ch.codePointAt(0) ?? 0
|
|
101
|
+
const size = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
|
|
102
|
+
if (bytes + size > MAX_FINDING_LABEL) break
|
|
103
|
+
bytes += size
|
|
104
|
+
end += ch.length
|
|
105
|
+
}
|
|
106
|
+
return label.slice(0, end)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// trivyReportImageRef renders a report's scanned image the way search
|
|
110
|
+
// indexes it: registry/repository:tag@digest.
|
|
111
|
+
export function trivyReportImageRef(resource: any): string {
|
|
112
|
+
const report = resource?.report
|
|
113
|
+
const repo = str(report?.artifact?.repository)
|
|
114
|
+
if (!repo) return ''
|
|
115
|
+
const registry = str(report?.registry?.server)
|
|
116
|
+
let ref = registry ? `${registry}/${repo}` : repo
|
|
117
|
+
const tag = str(report?.artifact?.tag)
|
|
118
|
+
if (tag) ref += `:${tag}`
|
|
119
|
+
const digest = str(report?.artifact?.digest)
|
|
120
|
+
if (digest) ref += `@${digest}`
|
|
121
|
+
return ref
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// searchTrivyFindings finds reports whose findings contain term, over the
|
|
125
|
+
// entries server search indexes (internal/search/trivy.go): CVE IDs with
|
|
126
|
+
// package and versions, SBOM components, failed check IDs, secret rule IDs
|
|
127
|
+
// and targets, plus the scanned image. A secret finding's `match` is the
|
|
128
|
+
// matched line itself and is never searched. A term with a space matches
|
|
129
|
+
// within one finding, never across two. Rows whose name or namespace already
|
|
130
|
+
// contain term are skipped, since they match without a finding.
|
|
131
|
+
export function searchTrivyFindings(resources: readonly any[], term: string, limit = MAX_TABLE_FINDINGS): TrivyFindingsSearch {
|
|
132
|
+
const matches = new Map<object, TrivyFindingMatch>()
|
|
133
|
+
const re = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
134
|
+
const spaced = term.includes(' ')
|
|
135
|
+
const hit = (...parts: string[]): boolean =>
|
|
136
|
+
spaced ? re.test(findingLabel(...parts)) : parts.some((p) => p !== '' && re.test(p))
|
|
137
|
+
let left = limit
|
|
138
|
+
for (const r of resources) {
|
|
139
|
+
const meta = r?.metadata
|
|
140
|
+
if (re.test(str(meta?.name)) || re.test(str(meta?.namespace))) continue
|
|
141
|
+
const report = r?.report
|
|
142
|
+
if (!report || typeof report !== 'object') continue
|
|
143
|
+
const image = trivyReportImageRef(r)
|
|
144
|
+
if (image && re.test(image)) {
|
|
145
|
+
matches.set(r, { site: 'image', text: image })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
let found = ''
|
|
149
|
+
let truncated = false
|
|
150
|
+
scan: {
|
|
151
|
+
for (const v of asArray(report.vulnerabilities)) {
|
|
152
|
+
if (--left < 0) { truncated = true; break scan }
|
|
153
|
+
const id = str(v?.vulnerabilityID)
|
|
154
|
+
if (!id) continue
|
|
155
|
+
const parts = [id, str(v.resource), str(v.installedVersion), '→', str(v.fixedVersion) || 'unfixed']
|
|
156
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
157
|
+
}
|
|
158
|
+
for (const c of asArray(report.components?.components)) {
|
|
159
|
+
if (--left < 0) { truncated = true; break scan }
|
|
160
|
+
const parts = [str(c?.name), str(c?.version)]
|
|
161
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
162
|
+
}
|
|
163
|
+
for (const c of asArray(report.checks)) {
|
|
164
|
+
if (--left < 0) { truncated = true; break scan }
|
|
165
|
+
if (c?.success !== false) continue
|
|
166
|
+
if (hit(str(c.checkID))) { found = findingLabel(str(c.checkID)); break scan }
|
|
167
|
+
}
|
|
168
|
+
for (const sec of asArray(report.secrets)) {
|
|
169
|
+
if (--left < 0) { truncated = true; break scan }
|
|
170
|
+
const parts = [str(sec?.ruleID), str(sec?.target)]
|
|
171
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (found) matches.set(r, { site: 'finding', text: found })
|
|
175
|
+
if (truncated) return { matches, truncated: true }
|
|
176
|
+
}
|
|
177
|
+
return { matches, truncated: false }
|
|
178
|
+
}
|
|
179
|
+
|
|
58
180
|
// ============================================================================
|
|
59
181
|
// TRIVY OPERATOR — CONFIG AUDIT REPORT UTILITIES
|
|
60
182
|
// ============================================================================
|