@skyhook-io/k8s-ui 1.12.0 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +419 -9
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +13 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +5 -2
- package/src/types/core.ts +36 -4
- package/src/types/policy.ts +153 -2
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -64,16 +64,28 @@ describe('PolicySection — what an empty result means', () => {
|
|
|
64
64
|
describe('PolicySection — partial coverage', () => {
|
|
65
65
|
it('admits when some report families were unreadable, even while passing', () => {
|
|
66
66
|
// "All checks passing" plus an unreadable family is a claim of coverage we
|
|
67
|
-
// do not have.
|
|
67
|
+
// do not have. The note alone does not undo it — "All" is the word that gets
|
|
68
|
+
// read, and the note is the small print under a green shield.
|
|
68
69
|
const html = renderToString(<PolicySection data={resp({
|
|
69
70
|
counts: { pass: 3, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
70
71
|
deniedGroups: ['openreports.io'],
|
|
71
72
|
})} />)
|
|
72
|
-
expect(html).toContain('
|
|
73
|
+
expect(html).toContain('3 checks passing')
|
|
74
|
+
expect(html).not.toContain('All 3 checks passing')
|
|
73
75
|
expect(html).toContain('may be incomplete')
|
|
74
76
|
expect(html).toContain('openreports.io')
|
|
75
77
|
})
|
|
76
78
|
|
|
79
|
+
// Staleness dates the answer, it does not shrink it: "as of the last sync, all
|
|
80
|
+
// passed" is still a true sentence, so this one keeps the word.
|
|
81
|
+
it('still says all passed when the only caveat is a frozen index', () => {
|
|
82
|
+
const html = renderToString(<PolicySection data={resp({
|
|
83
|
+
counts: { pass: 3, fail: 0, warn: 0, error: 0, skip: 0 }, liveUpdates: false,
|
|
84
|
+
})} />)
|
|
85
|
+
expect(html).toContain('All 3 checks passing')
|
|
86
|
+
expect(html).toContain('not updating live')
|
|
87
|
+
})
|
|
88
|
+
|
|
77
89
|
it('admits when the results are frozen', () => {
|
|
78
90
|
const html = renderToString(<PolicySection data={resp({
|
|
79
91
|
counts: { pass: 1, fail: 0, warn: 0, error: 0, skip: 0 }, liveUpdates: false,
|
|
@@ -100,3 +112,48 @@ describe('PolicySection — request states', () => {
|
|
|
100
112
|
expect(html).toContain('text-red')
|
|
101
113
|
})
|
|
102
114
|
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Findings the caller may not read are dropped from `findings` AND from
|
|
118
|
+
* `counts`, so the screen has nothing left to reveal that anything is missing.
|
|
119
|
+
* A resource whose only failure came from a report family this identity cannot
|
|
120
|
+
* read then renders as passing everything — an all-clear on a security surface,
|
|
121
|
+
* derived from an answer that was never fully asked.
|
|
122
|
+
*/
|
|
123
|
+
describe('when results were withheld from this caller', () => {
|
|
124
|
+
const withheld: PolicyResourceResponse = {
|
|
125
|
+
...base,
|
|
126
|
+
counts: { pass: 3, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
127
|
+
findings: [],
|
|
128
|
+
withheldByFamily: 1,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('does not claim everything passed', () => {
|
|
132
|
+
const html = renderToString(<PolicySection data={withheld} />)
|
|
133
|
+
expect(html).not.toContain('All 3 checks passing')
|
|
134
|
+
expect(html).toContain('3 checks passing')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('says what is missing and why', () => {
|
|
138
|
+
const html = renderToString(<PolicySection data={withheld} />)
|
|
139
|
+
expect(html).toContain('not shown here')
|
|
140
|
+
expect(html).toContain('permission')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('agrees in number', () => {
|
|
144
|
+
const html = renderToString(
|
|
145
|
+
<PolicySection data={{ ...withheld, withheldByFamily: 2 }} />,
|
|
146
|
+
)
|
|
147
|
+
expect(html).toContain('2 results are not shown')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// The ordinary case must keep its plain claim — the note only appears when
|
|
151
|
+
// something was actually held back.
|
|
152
|
+
it('still claims all-clear when nothing was withheld', () => {
|
|
153
|
+
const html = renderToString(
|
|
154
|
+
<PolicySection data={{ ...base, counts: { ...base.counts, pass: 3 } }} />,
|
|
155
|
+
)
|
|
156
|
+
expect(html).toContain('All 3 checks passing')
|
|
157
|
+
expect(html).not.toContain('not shown here')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -112,7 +112,12 @@ export function PolicySection({ data, loading, error }: PolicySectionProps) {
|
|
|
112
112
|
>
|
|
113
113
|
{failing === 0 ? (
|
|
114
114
|
<div className="text-sm text-theme-text-secondary">
|
|
115
|
-
{
|
|
115
|
+
{/* "All" is a claim about the resource, and it only holds when the
|
|
116
|
+
caller was shown everything. With results withheld it becomes an
|
|
117
|
+
all-clear on a question that was never fully asked. */}
|
|
118
|
+
{coverageIsPartial(data)
|
|
119
|
+
? `${counts.pass} ${counts.pass === 1 ? 'check' : 'checks'} passing.`
|
|
120
|
+
: `All ${counts.pass} ${counts.pass === 1 ? 'check' : 'checks'} passing.`}
|
|
116
121
|
</div>
|
|
117
122
|
) : (
|
|
118
123
|
<div className="space-y-2">
|
|
@@ -152,15 +157,37 @@ function PolicyFindingRow({ finding }: { finding: PolicyResourceFinding }) {
|
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
160
|
+
* Whether findings are missing from this answer. Two different gaps reach here:
|
|
161
|
+
* `deniedGroups` is a family radar's own probe could not read, `withheldByFamily`
|
|
162
|
+
* one THIS caller may not — and the counts exclude both, so a resource whose only
|
|
163
|
+
* failure came from either reads as passing everything.
|
|
164
|
+
*
|
|
165
|
+
* Staleness is deliberately not one of them. A frozen index dates the answer
|
|
166
|
+
* rather than shrinking it, and "as of the last sync, all passed" is still true.
|
|
167
|
+
*/
|
|
168
|
+
function coverageIsPartial(data: PolicyResourceResponse): boolean {
|
|
169
|
+
return (data.withheldByFamily ?? 0) > 0 || (data.deniedGroups ?? []).length > 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A result can be real and incomplete at the same time. Saying "all checks
|
|
174
|
+
* passing" without mentioning that claims coverage we do not have — so this note
|
|
175
|
+
* and the word "All" above it read the same predicate rather than each deciding
|
|
176
|
+
* for itself.
|
|
158
177
|
*/
|
|
159
178
|
function PartialCoverageNote({ data }: { data: PolicyResourceResponse }) {
|
|
160
179
|
const denied = data.deniedGroups ?? []
|
|
161
|
-
|
|
180
|
+
const withheld = data.withheldByFamily ?? 0
|
|
181
|
+
if (!coverageIsPartial(data) && data.liveUpdates) return null
|
|
162
182
|
return (
|
|
163
183
|
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-tertiary">
|
|
184
|
+
{withheld > 0 && (
|
|
185
|
+
<div>
|
|
186
|
+
{withheld === 1
|
|
187
|
+
? '1 result is not shown here because you don’t have permission to read the report it came from.'
|
|
188
|
+
: `${withheld} results are not shown here because you don’t have permission to read the reports they came from.`}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
164
191
|
{denied.length > 0 && (
|
|
165
192
|
<div>{`Some policy results could not be read (${denied.join(', ')}), so this list may be incomplete.`}</div>
|
|
166
193
|
)}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import {
|
|
4
|
+
RolloutRenderer,
|
|
5
|
+
rolloutActions,
|
|
6
|
+
canaryStepLabel,
|
|
7
|
+
rolloutAnalysisRuns,
|
|
8
|
+
rolloutConditionTone,
|
|
9
|
+
rolloutProblems,
|
|
10
|
+
type RolloutAction,
|
|
11
|
+
type RolloutCapabilities,
|
|
12
|
+
} from './RolloutRenderer'
|
|
13
|
+
|
|
14
|
+
const allCapabilities: RolloutCapabilities = {
|
|
15
|
+
abort: true,
|
|
16
|
+
retry: true,
|
|
17
|
+
promote: true,
|
|
18
|
+
promoteFull: true,
|
|
19
|
+
skipStep: true,
|
|
20
|
+
rollback: true,
|
|
21
|
+
restart: true,
|
|
22
|
+
strategy: 'canary',
|
|
23
|
+
terminating: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canaryRollout(status: Record<string, unknown>, steps = 4) {
|
|
27
|
+
return {
|
|
28
|
+
metadata: { name: 'web', namespace: 'prod' },
|
|
29
|
+
spec: {
|
|
30
|
+
replicas: 4,
|
|
31
|
+
strategy: { canary: { steps: Array.from({ length: steps }, () => ({ setWeight: 25 })) } },
|
|
32
|
+
template: { spec: { containers: [{ name: 'web', image: 'web:v2' }] } },
|
|
33
|
+
},
|
|
34
|
+
status,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function offered(data: unknown, capabilities?: RolloutCapabilities): RolloutAction[] {
|
|
39
|
+
return rolloutActions(data, capabilities).map((a) => a.action)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function blockedReason(data: unknown, action: RolloutAction, capabilities = allCapabilities) {
|
|
43
|
+
return rolloutActions(data, capabilities).find((a) => a.action === action)?.blocked
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('rolloutActions capability gating', () => {
|
|
47
|
+
it('offers nothing until the capability probe resolves', () => {
|
|
48
|
+
expect(rolloutActions(canaryRollout({ phase: 'Paused' }), undefined)).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('omits a denied verb rather than disabling it', () => {
|
|
52
|
+
const caps = { ...allCapabilities, promoteFull: false, abort: false }
|
|
53
|
+
expect(offered(canaryRollout({ phase: 'Paused' }), caps)).toEqual(['promote', 'skip-step'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('omits every verb when the probe denies all of them', () => {
|
|
57
|
+
const caps: RolloutCapabilities = {
|
|
58
|
+
...allCapabilities,
|
|
59
|
+
abort: false,
|
|
60
|
+
retry: false,
|
|
61
|
+
promote: false,
|
|
62
|
+
promoteFull: false,
|
|
63
|
+
skipStep: false,
|
|
64
|
+
}
|
|
65
|
+
expect(offered(canaryRollout({ phase: 'Paused' }), caps)).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('offers only the blueGreen-relevant verbs when the probe reports no skipStep', () => {
|
|
69
|
+
const blueGreen = {
|
|
70
|
+
spec: { strategy: { blueGreen: { activeService: 'active' } } },
|
|
71
|
+
status: { phase: 'Paused', pauseConditions: [{ reason: 'BlueGreenPause' }] },
|
|
72
|
+
}
|
|
73
|
+
const caps = { ...allCapabilities, skipStep: false, strategy: 'blueGreen' }
|
|
74
|
+
expect(offered(blueGreen, caps)).toEqual(['promote', 'promote-full', 'abort'])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('rolloutActions state gating', () => {
|
|
79
|
+
it('swaps Abort for Retry once the rollout is aborted', () => {
|
|
80
|
+
expect(offered(canaryRollout({ phase: 'Paused' }), allCapabilities)).toContain('abort')
|
|
81
|
+
expect(offered(canaryRollout({ phase: 'Paused' }), allCapabilities)).not.toContain('retry')
|
|
82
|
+
|
|
83
|
+
const aborted = offered(canaryRollout({ phase: 'Degraded', abort: true }), allCapabilities)
|
|
84
|
+
expect(aborted).toContain('retry')
|
|
85
|
+
expect(aborted).not.toContain('abort')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('blocks Promote unless the rollout is paused', () => {
|
|
89
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused' }), 'promote')).toBeUndefined()
|
|
90
|
+
expect(blockedReason(canaryRollout({ phase: 'Progressing', currentStepIndex: 1 }), 'promote')).toBe(
|
|
91
|
+
'Rollout is not paused and no analysis is running',
|
|
92
|
+
)
|
|
93
|
+
expect(blockedReason(canaryRollout({ phase: 'Healthy' }), 'promote')).toBe(
|
|
94
|
+
'Rollout is not paused and no analysis is running',
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('blocks the promotion verbs on a fully promoted rollout', () => {
|
|
99
|
+
const settled = canaryRollout({ phase: 'Healthy', currentStepIndex: 4 }, 4)
|
|
100
|
+
expect(blockedReason(settled, 'promote-full')).toBe('Nothing left to promote')
|
|
101
|
+
expect(blockedReason(settled, 'skip-step')).toBe('No canary step left to skip')
|
|
102
|
+
expect(blockedReason(settled, 'abort')).toBe('Rollout is fully promoted — roll back instead')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('routes an aborted rollout through Retry before it will promote again', () => {
|
|
106
|
+
const aborted = canaryRollout({ phase: 'Degraded', abort: true, currentStepIndex: 1 })
|
|
107
|
+
expect(blockedReason(aborted, 'promote-full')).toBe('Retry the rollout first')
|
|
108
|
+
expect(blockedReason(aborted, 'skip-step')).toBe('Retry the rollout first')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// An aborted rollout is Degraded, not Healthy, so isSettled must not swallow it.
|
|
112
|
+
it('does not treat an aborted rollout as settled', () => {
|
|
113
|
+
const abortedHealthy = canaryRollout({ phase: 'Healthy', abort: true })
|
|
114
|
+
expect(blockedReason(abortedHealthy, 'promote-full')).toBe('Retry the rollout first')
|
|
115
|
+
expect(offered(abortedHealthy, allCapabilities)).toContain('retry')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('blocks Skip step once the last step is reached', () => {
|
|
119
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused', currentStepIndex: 3 }, 4), 'skip-step')).toBeUndefined()
|
|
120
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused', currentStepIndex: 4 }, 4), 'skip-step')).toBe(
|
|
121
|
+
'No canary step left to skip',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('treats a missing currentStepIndex as step 0 with steps remaining', () => {
|
|
126
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused' }, 4), 'skip-step')).toBeUndefined()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('blocks Skip step on a stepless canary', () => {
|
|
130
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused' }, 0), 'skip-step')).toBe('No canary step left to skip')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('survives a Rollout with no status at all', () => {
|
|
134
|
+
expect(() => rolloutActions({ spec: {} }, allCapabilities)).not.toThrow()
|
|
135
|
+
expect(blockedReason({ spec: {} }, 'promote')).toBe(
|
|
136
|
+
'Rollout is not paused and no analysis is running',
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('rolloutProblems', () => {
|
|
142
|
+
it('reports an aborted rollout once, not once per matching branch', () => {
|
|
143
|
+
const problems = rolloutProblems(
|
|
144
|
+
canaryRollout({
|
|
145
|
+
phase: 'Degraded',
|
|
146
|
+
abort: true,
|
|
147
|
+
message: 'RolloutAborted: Rollout aborted update to revision 6',
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
expect(problems).toEqual([
|
|
151
|
+
{ color: 'red', message: 'RolloutAborted: Rollout aborted update to revision 6' },
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('collapses a Progressing condition that echoes status.message', () => {
|
|
156
|
+
const problems = rolloutProblems(
|
|
157
|
+
canaryRollout({
|
|
158
|
+
phase: 'Degraded',
|
|
159
|
+
message: 'ProgressDeadlineExceeded: ReplicaSet has timed out',
|
|
160
|
+
conditions: [
|
|
161
|
+
{
|
|
162
|
+
type: 'Progressing',
|
|
163
|
+
status: 'False',
|
|
164
|
+
reason: 'ProgressDeadlineExceeded',
|
|
165
|
+
message: 'ProgressDeadlineExceeded: ReplicaSet has timed out',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
expect(problems).toHaveLength(1)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('keeps genuinely different messages apart', () => {
|
|
174
|
+
const problems = rolloutProblems(
|
|
175
|
+
canaryRollout({
|
|
176
|
+
phase: 'Degraded',
|
|
177
|
+
abort: true,
|
|
178
|
+
message: 'RolloutAborted: analysis failed',
|
|
179
|
+
canary: { currentStepAnalysisRunStatus: { name: 'ar-1', status: 'Failed', message: 'error rate 12%' } },
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
expect(problems).toHaveLength(2)
|
|
183
|
+
expect(problems[1].message).toBe('Step analysis failed (ar-1): error rate 12%')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('names every pause reason with how long it has been waiting', () => {
|
|
187
|
+
const problems = rolloutProblems(
|
|
188
|
+
canaryRollout({
|
|
189
|
+
phase: 'Paused',
|
|
190
|
+
pauseConditions: [{ reason: 'CanaryPauseStep', startTime: '2020-01-01T00:00:00Z' }],
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
expect(problems).toHaveLength(1)
|
|
194
|
+
expect(problems[0].color).toBe('yellow')
|
|
195
|
+
expect(problems[0].message).toMatch(/^Rollout is paused: CanaryPauseStep \(since .+\)$/)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not report a paused rollout that is actually aborted', () => {
|
|
199
|
+
const problems = rolloutProblems(
|
|
200
|
+
canaryRollout({ phase: 'Paused', abort: true, message: 'aborted mid-step' }),
|
|
201
|
+
)
|
|
202
|
+
expect(problems.map((p) => p.message)).toEqual(['aborted mid-step'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('reports nothing for a healthy rollout', () => {
|
|
206
|
+
expect(rolloutProblems(canaryRollout({ phase: 'Healthy' }))).toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('surfaces an invalid spec', () => {
|
|
210
|
+
const problems = rolloutProblems(
|
|
211
|
+
canaryRollout({
|
|
212
|
+
phase: 'Progressing',
|
|
213
|
+
conditions: [{ type: 'InvalidSpec', status: 'True', message: 'spec.strategy is required' }],
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
expect(problems).toEqual([{ color: 'red', message: 'spec.strategy is required' }])
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('rolloutAnalysisRuns', () => {
|
|
221
|
+
it('labels every populated slot with its trigger', () => {
|
|
222
|
+
const runs = rolloutAnalysisRuns({
|
|
223
|
+
canary: {
|
|
224
|
+
currentStepAnalysisRunStatus: { name: 'ar-step', status: 'Inconclusive', message: 'no verdict' },
|
|
225
|
+
currentBackgroundAnalysisRunStatus: { name: 'ar-bg', status: 'Successful' },
|
|
226
|
+
},
|
|
227
|
+
blueGreen: {
|
|
228
|
+
prePromotionAnalysisRunStatus: { name: 'ar-pre', status: 'Running' },
|
|
229
|
+
postPromotionAnalysisRunStatus: { name: 'ar-post', status: 'Failed' },
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
expect(runs.map((r) => [r.label, r.name, r.status])).toEqual([
|
|
233
|
+
['Step analysis', 'ar-step', 'Inconclusive'],
|
|
234
|
+
['Background analysis', 'ar-bg', 'Successful'],
|
|
235
|
+
['Pre-promotion analysis', 'ar-pre', 'Running'],
|
|
236
|
+
['Post-promotion analysis', 'ar-post', 'Failed'],
|
|
237
|
+
])
|
|
238
|
+
expect(runs[0].message).toBe('no verdict')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// A slot with no name is a slot the controller has not populated.
|
|
242
|
+
it('drops nameless and absent slots', () => {
|
|
243
|
+
expect(rolloutAnalysisRuns({})).toEqual([])
|
|
244
|
+
expect(rolloutAnalysisRuns(undefined)).toEqual([])
|
|
245
|
+
expect(rolloutAnalysisRuns({ canary: { currentStepAnalysisRunStatus: { status: 'Running' } } })).toEqual([])
|
|
246
|
+
expect(rolloutAnalysisRuns({ canary: { currentStepAnalysisRunStatus: { name: '', status: 'Running' } } })).toEqual(
|
|
247
|
+
[],
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('rolloutActions confirmation gating', () => {
|
|
253
|
+
it('confirms exactly the verbs that shift production traffic in one click', () => {
|
|
254
|
+
const specs = rolloutActions(canaryRollout({ phase: 'Paused' }), allCapabilities)
|
|
255
|
+
const confirmed = specs.filter((a) => a.confirm).map((a) => a.action)
|
|
256
|
+
expect(confirmed.sort()).toEqual(['abort', 'promote-full'])
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('rolloutActions promote reachability', () => {
|
|
261
|
+
// The controller reports Progressing, not Paused, while a step analysis runs —
|
|
262
|
+
// and promote is what advances past it, so it must not be blocked there.
|
|
263
|
+
it('offers promote while a step analysis is in flight', () => {
|
|
264
|
+
for (const status of ['Running', 'Pending', 'Inconclusive']) {
|
|
265
|
+
const data = canaryRollout({
|
|
266
|
+
phase: 'Progressing',
|
|
267
|
+
currentStepIndex: 2,
|
|
268
|
+
canary: { currentStepAnalysisRunStatus: { name: 'ar-1', status } },
|
|
269
|
+
})
|
|
270
|
+
expect(blockedReason(data, 'promote')).toBeUndefined()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('still blocks promote on a plain progressing rollout with no analysis', () => {
|
|
275
|
+
const data = canaryRollout({ phase: 'Progressing', currentStepIndex: 1 })
|
|
276
|
+
expect(blockedReason(data, 'promote')).toBe('Rollout is not paused and no analysis is running')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('leaves promote unblocked when paused', () => {
|
|
280
|
+
expect(blockedReason(canaryRollout({ phase: 'Paused' }), 'promote')).toBeUndefined()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Live state after aborting a Rollout paused on an inconclusive analysis: the
|
|
284
|
+
// analysis status persists, so promote must not ride analysisInFlight past the abort.
|
|
285
|
+
it('blocks promote on an aborted rollout whose analysis stayed inconclusive', () => {
|
|
286
|
+
const data = canaryRollout({
|
|
287
|
+
phase: 'Degraded',
|
|
288
|
+
abort: true,
|
|
289
|
+
currentStepIndex: 0,
|
|
290
|
+
canary: { currentStepAnalysisRunStatus: { name: 'ar-1', status: 'Inconclusive' } },
|
|
291
|
+
})
|
|
292
|
+
expect(blockedReason(data, 'promote')).toBe('Retry the rollout first')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('blocks promote, promote-full and skip-step alike on an aborted rollout', () => {
|
|
296
|
+
const data = canaryRollout({ phase: 'Degraded', abort: true, currentStepIndex: 1 })
|
|
297
|
+
for (const action of ['promote', 'promote-full', 'skip-step']) {
|
|
298
|
+
expect(blockedReason(data, action)).toBe('Retry the rollout first')
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('canaryStepLabel', () => {
|
|
304
|
+
it('labels every step type Argo defines without leaking JSON', () => {
|
|
305
|
+
const cases: Array<[any, string]> = [
|
|
306
|
+
[{ setWeight: 25 }, 'Set weight: 25%'],
|
|
307
|
+
[{ pause: {} }, 'Pause: until promoted'],
|
|
308
|
+
[{ pause: { duration: '30s' } }, 'Pause: 30s'],
|
|
309
|
+
[{ analysis: { templates: [{ templateName: 'success-rate' }] } }, 'Analysis: success-rate'],
|
|
310
|
+
[
|
|
311
|
+
{ analysis: { templates: [{ clusterTemplateName: 'shared-check' }] } },
|
|
312
|
+
'Analysis: shared-check',
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
{ experiment: { templates: [{ name: 'baseline' }], duration: '5m' } },
|
|
316
|
+
'Experiment: baseline for 5m',
|
|
317
|
+
],
|
|
318
|
+
[{ setCanaryScale: { weight: 40 } }, 'Set canary scale: 40%'],
|
|
319
|
+
[{ setCanaryScale: { replicas: 3 } }, 'Set canary scale: 3 replicas'],
|
|
320
|
+
[{ setCanaryScale: { matchTrafficWeight: true } }, 'Set canary scale: match traffic weight'],
|
|
321
|
+
[
|
|
322
|
+
{ setHeaderRoute: { name: 'canary-hdr', match: [{ headerName: 'x-canary' }] } },
|
|
323
|
+
'Header route canary-hdr: x-canary',
|
|
324
|
+
],
|
|
325
|
+
[{ setHeaderRoute: { name: 'canary-hdr', match: [] } }, 'Remove header route: canary-hdr'],
|
|
326
|
+
[
|
|
327
|
+
{ setMirrorRoute: { name: 'mirror', match: [{}], percentage: 20 } },
|
|
328
|
+
'Mirror route mirror (20%)',
|
|
329
|
+
],
|
|
330
|
+
[{ setMirrorRoute: { name: 'mirror' } }, 'Remove mirror route: mirror'],
|
|
331
|
+
[{ plugin: { name: 'acme/step' } }, 'Plugin: acme/step'],
|
|
332
|
+
]
|
|
333
|
+
for (const [step, want] of cases) {
|
|
334
|
+
expect(canaryStepLabel(step)).toBe(want)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('names an unrecognized step instead of dumping its body', () => {
|
|
339
|
+
const label = canaryStepLabel({ someFutureStep: { nested: { a: 1 } } })
|
|
340
|
+
expect(label).toBe('Unrecognized step: someFutureStep')
|
|
341
|
+
expect(label).not.toContain('{')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('survives malformed steps', () => {
|
|
345
|
+
expect(canaryStepLabel({})).toBe('Unknown step')
|
|
346
|
+
expect(canaryStepLabel(null)).toBe('Unknown step')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('renders an analysis step as prose in the step list', () => {
|
|
350
|
+
const html = renderToString(
|
|
351
|
+
<RolloutRenderer
|
|
352
|
+
data={{
|
|
353
|
+
metadata: { name: 'web', namespace: 'prod' },
|
|
354
|
+
spec: {
|
|
355
|
+
strategy: {
|
|
356
|
+
canary: {
|
|
357
|
+
steps: [{ analysis: { templates: [{ templateName: 'success-rate' }] } }],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
template: { spec: { containers: [{ name: 'web', image: 'web:v2' }] } },
|
|
361
|
+
},
|
|
362
|
+
status: { phase: 'Progressing' },
|
|
363
|
+
}}
|
|
364
|
+
/>,
|
|
365
|
+
)
|
|
366
|
+
expect(html).toContain('Analysis: success-rate')
|
|
367
|
+
expect(html).not.toContain('templateName')
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('rolloutConditionTone', () => {
|
|
372
|
+
it('reads Paused=False as the healthy state', () => {
|
|
373
|
+
expect(rolloutConditionTone({ type: 'Paused', status: 'False' })).toBe('ok')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('warns rather than fails when actually paused', () => {
|
|
377
|
+
expect(rolloutConditionTone({ type: 'Paused', status: 'True' })).toBe('warning')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('reads InvalidSpec=False as the healthy state', () => {
|
|
381
|
+
expect(rolloutConditionTone({ type: 'InvalidSpec', status: 'False' })).toBe('ok')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('fails on InvalidSpec=True', () => {
|
|
385
|
+
expect(rolloutConditionTone({ type: 'InvalidSpec', status: 'True' })).toBe('fail')
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('defers to the generic rule for ordinary condition types', () => {
|
|
389
|
+
expect(rolloutConditionTone({ type: 'Available', status: 'True' })).toBeUndefined()
|
|
390
|
+
expect(rolloutConditionTone({ type: 'Progressing', status: 'False' })).toBeUndefined()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('defers when the status is neither True nor False', () => {
|
|
394
|
+
expect(rolloutConditionTone({ type: 'Paused', status: 'Unknown' })).toBeUndefined()
|
|
395
|
+
expect(rolloutConditionTone({ type: 'InvalidSpec' })).toBeUndefined()
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe('RolloutRenderer', () => {
|
|
400
|
+
it('renders no action row without an onAction handler', () => {
|
|
401
|
+
const html = renderToString(
|
|
402
|
+
<RolloutRenderer data={canaryRollout({ phase: 'Paused' })} capabilities={allCapabilities} />,
|
|
403
|
+
)
|
|
404
|
+
expect(html).not.toContain('Skip step')
|
|
405
|
+
expect(html).not.toContain('Promote')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('counts no failing conditions on a settled healthy Rollout', () => {
|
|
409
|
+
const html = renderToString(
|
|
410
|
+
<RolloutRenderer
|
|
411
|
+
data={canaryRollout({
|
|
412
|
+
phase: 'Healthy',
|
|
413
|
+
conditions: [
|
|
414
|
+
{ type: 'Available', status: 'True' },
|
|
415
|
+
{ type: 'Paused', status: 'False' },
|
|
416
|
+
{ type: 'Healthy', status: 'True' },
|
|
417
|
+
{ type: 'Progressing', status: 'True' },
|
|
418
|
+
{ type: 'Completed', status: 'True' },
|
|
419
|
+
],
|
|
420
|
+
})}
|
|
421
|
+
/>,
|
|
422
|
+
)
|
|
423
|
+
expect(html).not.toContain('failing')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('renders the offered verbs when wired', () => {
|
|
427
|
+
const html = renderToString(
|
|
428
|
+
<RolloutRenderer
|
|
429
|
+
data={canaryRollout({ phase: 'Paused' })}
|
|
430
|
+
capabilities={allCapabilities}
|
|
431
|
+
onAction={() => {}}
|
|
432
|
+
/>,
|
|
433
|
+
)
|
|
434
|
+
expect(html).toContain('Promote full')
|
|
435
|
+
expect(html).toContain('Skip step')
|
|
436
|
+
expect(html).toContain('Abort')
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('marks the in-flight action', () => {
|
|
440
|
+
const html = renderToString(
|
|
441
|
+
<RolloutRenderer
|
|
442
|
+
data={canaryRollout({ phase: 'Paused' })}
|
|
443
|
+
capabilities={allCapabilities}
|
|
444
|
+
onAction={() => {}}
|
|
445
|
+
pendingAction="promote"
|
|
446
|
+
/>,
|
|
447
|
+
)
|
|
448
|
+
expect(html).toContain('Promoting…')
|
|
449
|
+
expect(html).toContain('animate-pulse')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('names the inconclusive AnalysisRun and its message alongside the pause reason', () => {
|
|
453
|
+
const html = renderToString(
|
|
454
|
+
<RolloutRenderer
|
|
455
|
+
data={canaryRollout({
|
|
456
|
+
phase: 'Paused',
|
|
457
|
+
currentStepIndex: 1,
|
|
458
|
+
pauseConditions: [{ reason: 'InconclusiveAnalysisRun' }],
|
|
459
|
+
canary: {
|
|
460
|
+
currentStepAnalysisRunStatus: {
|
|
461
|
+
name: 'web-6c4f-2',
|
|
462
|
+
status: 'Inconclusive',
|
|
463
|
+
message: 'success-rate returned no verdict',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
})}
|
|
467
|
+
/>,
|
|
468
|
+
)
|
|
469
|
+
expect(html).toContain('InconclusiveAnalysisRun')
|
|
470
|
+
expect(html).toContain('web-6c4f-2')
|
|
471
|
+
expect(html).toContain('success-rate returned no verdict')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('escalates a failed analysis to an error alert, not a warning', () => {
|
|
475
|
+
const html = renderToString(
|
|
476
|
+
<RolloutRenderer
|
|
477
|
+
data={canaryRollout({
|
|
478
|
+
phase: 'Degraded',
|
|
479
|
+
canary: { currentStepAnalysisRunStatus: { name: 'web-6c4f-2', status: 'Failed' } },
|
|
480
|
+
})}
|
|
481
|
+
/>,
|
|
482
|
+
)
|
|
483
|
+
expect(html).toContain('Step analysis failed')
|
|
484
|
+
expect(html).toContain('Issue Detected')
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('renders no Analysis section when no slot is populated', () => {
|
|
488
|
+
const html = renderToString(<RolloutRenderer data={canaryRollout({ phase: 'Healthy' })} />)
|
|
489
|
+
expect(html).not.toContain('Step analysis')
|
|
490
|
+
})
|
|
491
|
+
})
|