@skyhook-io/k8s-ui 1.16.2 → 1.17.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 +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +12 -9
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +2 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.tsx +21 -7
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -7
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +17 -33
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -20
- package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineList.tsx +187 -38
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/topology/TopologyGraph.tsx +5 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/drawer-components.test.tsx +66 -1
- package/src/components/ui/drawer-components.tsx +43 -29
- package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.tsx +216 -48
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/types/capacity.ts +10 -0
- package/src/types/core.ts +32 -1
- package/src/utils/format.test.ts +40 -2
- package/src/utils/format.ts +5 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/log-level.ts +14 -0
- package/src/utils/navigation.test.ts +13 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/resource-hierarchy.test.ts +32 -0
- package/src/utils/resource-hierarchy.ts +28 -2
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
package/src/types/core.ts
CHANGED
|
@@ -167,6 +167,9 @@ export interface FeatureCapabilities {
|
|
|
167
167
|
podEnvironment?: boolean
|
|
168
168
|
policyResource?: boolean
|
|
169
169
|
workloadHistory?: boolean
|
|
170
|
+
serviceEndpointSlices?: boolean
|
|
171
|
+
trafficRecords?: boolean
|
|
172
|
+
gitopsWriteEvidence?: boolean
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
// DeploymentMode is the closed set of topologies Radar can run in.
|
|
@@ -653,6 +656,8 @@ export interface Relationships {
|
|
|
653
656
|
consumers?: ResourceRef[]
|
|
654
657
|
scalers?: ResourceRef[]
|
|
655
658
|
storageRefs?: ResourceRef[]
|
|
659
|
+
dependencies?: ResourceRef[]
|
|
660
|
+
dependents?: ResourceRef[]
|
|
656
661
|
scaleTarget?: ResourceRef
|
|
657
662
|
pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
|
|
658
663
|
networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
|
|
@@ -817,6 +822,7 @@ export interface HelmRevision {
|
|
|
817
822
|
appVersion: string
|
|
818
823
|
description: string
|
|
819
824
|
updated: string // ISO date string
|
|
825
|
+
deleted?: string
|
|
820
826
|
}
|
|
821
827
|
|
|
822
828
|
export type HelmOperationKind = 'release_failed' | 'upgrade_failed' | 'upgrade_rolled_back' | 'rollback' | 'pending'
|
|
@@ -1284,10 +1290,12 @@ export interface MetricsDataPoint {
|
|
|
1284
1290
|
export interface TrafficEndpoint {
|
|
1285
1291
|
name: string
|
|
1286
1292
|
namespace: string
|
|
1287
|
-
kind: string // Pod, Service, External, Host, Unknown
|
|
1293
|
+
kind: string // Pod, Workload, Service, External, Host, Unknown
|
|
1288
1294
|
ip?: string
|
|
1289
1295
|
labels?: Record<string, string>
|
|
1290
1296
|
workload?: string
|
|
1297
|
+
/** Deployment, StatefulSet, CronJob, … when resolved from the pod's owners. */
|
|
1298
|
+
workloadKind?: string
|
|
1291
1299
|
port?: number
|
|
1292
1300
|
}
|
|
1293
1301
|
|
|
@@ -1453,6 +1461,12 @@ export interface TrafficFlowsResponse {
|
|
|
1453
1461
|
* nodeFlowLimit, so older traffic in the window did not fit. */
|
|
1454
1462
|
coveredSince?: string
|
|
1455
1463
|
nodeFlowLimit?: number
|
|
1464
|
+
/** Set when the source kept only its newest flowLimit flows in total; the
|
|
1465
|
+
* window is then complete only from coveredSince. */
|
|
1466
|
+
flowLimit?: number
|
|
1467
|
+
/** How many records the aggregation covers. `flows` holds only the newest of
|
|
1468
|
+
* them; a Radar that predates the cap omits this and sends every record. */
|
|
1469
|
+
flowsTotal?: number
|
|
1456
1470
|
warning?: string // Non-fatal warning (e.g., query errors)
|
|
1457
1471
|
/** 'transient' (or absent) means the condition may clear on its own and a
|
|
1458
1472
|
* retry is worthwhile. 'partial' means the flows are correct but some of
|
|
@@ -1463,6 +1477,23 @@ export interface TrafficFlowsResponse {
|
|
|
1463
1477
|
warningKind?: 'transient' | 'partial' | 'incomplete'
|
|
1464
1478
|
}
|
|
1465
1479
|
|
|
1480
|
+
// Response from GET /api/traffic/flows/records — the newest records behind
|
|
1481
|
+
// one graph selection.
|
|
1482
|
+
export interface TrafficRecordsResponse {
|
|
1483
|
+
source: string
|
|
1484
|
+
timestamp: string
|
|
1485
|
+
/** Newest first, at most the server's records limit. */
|
|
1486
|
+
flows: TrafficFlow[]
|
|
1487
|
+
/** How many records matched; more than flows.length when capped. */
|
|
1488
|
+
matched: number
|
|
1489
|
+
l7ResponsesCallerOriented?: boolean
|
|
1490
|
+
coveredSince?: string
|
|
1491
|
+
nodeFlowLimit?: number
|
|
1492
|
+
flowLimit?: number
|
|
1493
|
+
warning?: string
|
|
1494
|
+
warningKind?: 'transient' | 'partial' | 'incomplete'
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1466
1497
|
// Wizard state for traffic setup
|
|
1467
1498
|
export type TrafficWizardState = 'detecting' | 'not_found' | 'wizard' | 'checking' | 'ready'
|
|
1468
1499
|
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { formatCPUString, formatMemoryString, parseMemoryToBytes, parseQuantityToNumber } from './format'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { formatCompactAge, formatCPUString, formatMemoryString, formatRelativeAgeTime, parseMemoryToBytes, parseQuantityToNumber } from './format'
|
|
3
3
|
|
|
4
4
|
describe('formatCPUString', () => {
|
|
5
5
|
it('renders exact zero as zero, not a fabricated near-zero', () => {
|
|
@@ -115,3 +115,41 @@ describe('parseQuantityToNumber', () => {
|
|
|
115
115
|
expect(parseQuantityToNumber(Number.POSITIVE_INFINITY)).toBe(0)
|
|
116
116
|
})
|
|
117
117
|
})
|
|
118
|
+
|
|
119
|
+
describe('relative ages', () => {
|
|
120
|
+
const now = Date.parse('2026-10-11T12:00:00Z')
|
|
121
|
+
const ago = (ms: number) => new Date(now - ms).toISOString()
|
|
122
|
+
const MIN = 60_000
|
|
123
|
+
const DAY = 24 * 60 * MIN
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.useFakeTimers()
|
|
127
|
+
vi.setSystemTime(now)
|
|
128
|
+
})
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
vi.useRealTimers()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('counts days past a month instead of rounding to months or years', () => {
|
|
134
|
+
expect(formatCompactAge(ago(212 * DAY))).toBe('212d')
|
|
135
|
+
expect(formatCompactAge(ago(364 * DAY))).toBe('364d')
|
|
136
|
+
expect(formatRelativeAgeTime(ago(364 * DAY))).toBe('364d ago')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('says just now for the current moment and for small clock skew', () => {
|
|
140
|
+
expect(formatRelativeAgeTime(ago(0))).toBe('just now')
|
|
141
|
+
expect(formatRelativeAgeTime(ago(-30_000))).toBe('just now')
|
|
142
|
+
expect(formatRelativeAgeTime(ago(5 * MIN))).toBe('5m ago')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('shows a real future time as a date, not a negative age', () => {
|
|
146
|
+
const future = ago(-2 * 60 * MIN)
|
|
147
|
+
expect(formatRelativeAgeTime(future)).toBe(new Date(future).toLocaleString())
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('keeps missing and unparseable input visible', () => {
|
|
151
|
+
expect(formatRelativeAgeTime(undefined)).toBe('-')
|
|
152
|
+
expect(formatRelativeAgeTime('', '')).toBe('')
|
|
153
|
+
expect(formatRelativeAgeTime('not a time')).toBe('not a time')
|
|
154
|
+
})
|
|
155
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -308,12 +308,16 @@ export function formatUpdatedAgo(elapsedMs: number): string {
|
|
|
308
308
|
return `${bucket} ago`
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// Timestamps written moments ago (a lease renewal, a sync) can land slightly in
|
|
312
|
+
// the browser's future when its clock trails the cluster's.
|
|
313
|
+
const CLOCK_SKEW_TOLERANCE_MS = 60_000
|
|
314
|
+
|
|
311
315
|
export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
|
|
312
316
|
if (!value) return fallback
|
|
313
317
|
const time = Date.parse(value)
|
|
314
318
|
if (!Number.isFinite(time)) return value
|
|
315
319
|
const diff = Date.now() - time
|
|
316
|
-
if (diff < 0) return new Date(time).toLocaleString()
|
|
320
|
+
if (diff < 0) return diff > -CLOCK_SKEW_TOLERANCE_MS ? 'just now' : new Date(time).toLocaleString()
|
|
317
321
|
const compact = formatCompactAge(value)
|
|
318
322
|
if (!compact) return fallback
|
|
319
323
|
return compact === '0s' ? 'just now' : `${compact} ago`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { gatewayBackendResourceRef, gatewayParentResourceRef } from './gateway-references'
|
|
3
|
+
|
|
4
|
+
describe('Gateway API references', () => {
|
|
5
|
+
it('applies the distinct backend and parent defaults', () => {
|
|
6
|
+
expect(gatewayBackendResourceRef({ name: 'api' }, 'app')).toEqual({ kind: 'Service', group: '', namespace: 'app', name: 'api' })
|
|
7
|
+
expect(gatewayParentResourceRef({ name: 'edge' }, 'app')).toEqual({ kind: 'Gateway', group: 'gateway.networking.k8s.io', namespace: 'app', name: 'edge' })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('preserves custom kinds, exact groups and explicit namespaces', () => {
|
|
11
|
+
expect(gatewayBackendResourceRef({ name: 'api', kind: 'Widget', group: 'custom.example.io', namespace: 'backends' }, 'app'))
|
|
12
|
+
.toEqual({ kind: 'Widget', group: 'custom.example.io', namespace: 'backends', name: 'api' })
|
|
13
|
+
expect(gatewayBackendResourceRef({ name: 'api', kind: 'Service', group: 'serving.knative.dev' }, 'app').group).toBe('serving.knative.dev')
|
|
14
|
+
expect(gatewayParentResourceRef({ name: 'mesh', kind: 'Service', group: '', namespace: 'front' }, 'app'))
|
|
15
|
+
.toEqual({ kind: 'Service', group: '', namespace: 'front', name: 'mesh' })
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ResourceRef } from '../types'
|
|
2
|
+
|
|
3
|
+
/** Gateway references name namespaced objects. Unlike ObjectReference, their
|
|
4
|
+
* namespace defaults to the referring Route and their group/kind have defaults.
|
|
5
|
+
*/
|
|
6
|
+
interface GatewayObjectReference {
|
|
7
|
+
name: string
|
|
8
|
+
group?: string
|
|
9
|
+
kind?: string
|
|
10
|
+
namespace?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function gatewayBackendResourceRef(ref: GatewayObjectReference, routeNamespace: string): ResourceRef {
|
|
14
|
+
return {
|
|
15
|
+
kind: ref.kind ?? 'Service',
|
|
16
|
+
group: ref.group ?? '',
|
|
17
|
+
namespace: ref.namespace ?? routeNamespace,
|
|
18
|
+
name: ref.name,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function gatewayParentResourceRef(ref: GatewayObjectReference, routeNamespace: string): ResourceRef {
|
|
23
|
+
return {
|
|
24
|
+
kind: ref.kind ?? 'Gateway',
|
|
25
|
+
group: ref.group ?? 'gateway.networking.k8s.io',
|
|
26
|
+
namespace: ref.namespace ?? routeNamespace,
|
|
27
|
+
name: ref.name,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { GitOpsOwnerRef } from './gitops-owner'
|
|
4
|
+
import {
|
|
5
|
+
canConfirmGitOpsWrite,
|
|
6
|
+
evaluateGitOpsWriteGuard,
|
|
7
|
+
gitOpsWriteEvidencePaths,
|
|
8
|
+
gitOpsWriteGuardKey,
|
|
9
|
+
type GitOpsPathEvidence,
|
|
10
|
+
type GitOpsWrite,
|
|
11
|
+
type GitOpsWriteEvidence,
|
|
12
|
+
type GitOpsWriteGuardInput,
|
|
13
|
+
type GitOpsWritePolicyEvidence,
|
|
14
|
+
} from './gitops-write-guard'
|
|
15
|
+
|
|
16
|
+
const target = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }
|
|
17
|
+
const argo: GitOpsOwnerRef = { tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'pg' }
|
|
18
|
+
const kustomization: GitOpsOwnerRef = { tool: 'fluxcd', kind: 'kustomizations', namespace: 'flux-system', name: 'apps' }
|
|
19
|
+
const helmRelease: GitOpsOwnerRef = { tool: 'fluxcd', kind: 'helmreleases', namespace: 'flux-system', name: 'pg' }
|
|
20
|
+
|
|
21
|
+
const HIBERNATE = 'metadata.annotations["cnpg.io/hibernation"]'
|
|
22
|
+
const hibernate: GitOpsWrite = { scope: 'metadata', paths: [HIBERNATE], description: 'Hibernation' }
|
|
23
|
+
|
|
24
|
+
const selfHeal: GitOpsWritePolicyEvidence = { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null }
|
|
25
|
+
const autoNoHeal: GitOpsWritePolicyEvidence = { tool: 'argocd', auto: true, selfHeal: false, prune: false, suspended: null }
|
|
26
|
+
const manual: GitOpsWritePolicyEvidence = { tool: 'argocd', auto: false, selfHeal: false, prune: false, suspended: null }
|
|
27
|
+
|
|
28
|
+
function pathEvidence(overrides: Partial<GitOpsPathEvidence> = {}): GitOpsPathEvidence {
|
|
29
|
+
return { path: HIBERNATE, lastApplied: 'absent', ownedBy: [], ownedByGitOps: false, ...overrides }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function evidence(policy: GitOpsWritePolicyEvidence | null, paths: GitOpsPathEvidence[] = [], extra: Partial<GitOpsWriteEvidence> = {}): GitOpsWriteEvidence {
|
|
33
|
+
return { uid: 'u', resourceVersion: '1', owner: null, policy, paths, ...extra }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function guard(input: Partial<GitOpsWriteGuardInput> & Pick<GitOpsWriteGuardInput, 'writes'>) {
|
|
37
|
+
return evaluateGitOpsWriteGuard({ target, owner: argo, ...input })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('evaluateGitOpsWriteGuard', () => {
|
|
41
|
+
it('reports nothing without an owner', () => {
|
|
42
|
+
const g = evaluateGitOpsWriteGuard({ target, owner: null, writes: [hibernate] })
|
|
43
|
+
expect(g.level).toBe('none')
|
|
44
|
+
expect(g.requiresAck).toBe(false)
|
|
45
|
+
expect(g.summary).toBe('')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('never warns for status writes', () => {
|
|
49
|
+
const g = guard({ writes: [{ scope: 'status' }], evidence: evidence(selfHeal) })
|
|
50
|
+
expect(g.level).toBe('none')
|
|
51
|
+
expect(g.perWrite[0].reason).toContain('ordinary GitOps sync does not manage these fields')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('treats a new child object as info', () => {
|
|
55
|
+
const g = guard({ writes: [{ scope: 'create-child' }], evidence: evidence(selfHeal) })
|
|
56
|
+
expect(g.level).toBe('info')
|
|
57
|
+
expect(g.requiresAck).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('declared fields', () => {
|
|
61
|
+
const inLastApplied = [pathEvidence({ lastApplied: 'present' })]
|
|
62
|
+
const ownedByArgo = [
|
|
63
|
+
pathEvidence({
|
|
64
|
+
lastApplied: 'no-annotation',
|
|
65
|
+
ownedByGitOps: true,
|
|
66
|
+
ownedBy: [{ manager: 'argocd-controller', operation: 'Apply', tool: 'argocd' }],
|
|
67
|
+
}),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
['last-applied', inLastApplied],
|
|
72
|
+
['managedFields', ownedByArgo],
|
|
73
|
+
])('%s + self-heal ⇒ will-revert', (_label, paths) => {
|
|
74
|
+
const g = guard({ writes: [hibernate], evidence: evidence(selfHeal, paths) })
|
|
75
|
+
expect(g.level).toBe('will-revert')
|
|
76
|
+
expect(g.requiresAck).toBe(true)
|
|
77
|
+
expect(g.summary).toContain('Argo CD Application argocd/pg will revert this change.')
|
|
78
|
+
expect(g.summary).toContain('overwritten at the next sync')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('names the owning field manager', () => {
|
|
82
|
+
const g = guard({ writes: [hibernate], evidence: evidence(selfHeal, ownedByArgo) })
|
|
83
|
+
expect(g.perWrite[0].reason).toContain('argocd-controller owns this field')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('auto-sync without self-heal ⇒ may-revert', () => {
|
|
87
|
+
const g = guard({ writes: [hibernate], evidence: evidence(autoNoHeal, inLastApplied) })
|
|
88
|
+
expect(g.level).toBe('may-revert')
|
|
89
|
+
expect(g.perWrite[0].reason).toContain('the next sync will overwrite it')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('manual sync ⇒ may-revert', () => {
|
|
93
|
+
const g = guard({ writes: [hibernate], evidence: evidence(manual, inLastApplied) })
|
|
94
|
+
expect(g.level).toBe('may-revert')
|
|
95
|
+
expect(g.perWrite[0].reason).toContain('Auto-sync is off; the next sync will overwrite it.')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('unreadable owner ⇒ may-revert naming why', () => {
|
|
99
|
+
const g = guard({
|
|
100
|
+
writes: [hibernate],
|
|
101
|
+
evidence: evidence(null, inLastApplied, { policyError: "you can't read the owner, so its sync policy is unknown" }),
|
|
102
|
+
})
|
|
103
|
+
expect(g.level).toBe('may-revert')
|
|
104
|
+
expect(g.syncPolicy).toBeNull()
|
|
105
|
+
expect(g.perWrite[0].reason).toContain("you can't read the owner")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('Argo Replace=true treats every field as declared', () => {
|
|
109
|
+
const g = guard({ writes: [hibernate], evidence: evidence({ ...selfHeal, replace: true }, [pathEvidence()]) })
|
|
110
|
+
expect(g.level).toBe('will-revert')
|
|
111
|
+
expect(g.perWrite[0].reason).toContain('Replace=true')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('undeclared or unknown fields are never safe', () => {
|
|
116
|
+
it('absent from last-applied and managedFields ⇒ may-revert, lower-confidence copy', () => {
|
|
117
|
+
const g = guard({ writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence()]) })
|
|
118
|
+
expect(g.level).toBe('may-revert')
|
|
119
|
+
expect(g.perWrite[0].reason).toContain("doesn't rule it out")
|
|
120
|
+
expect(g.perWrite[0].reason).toContain('may be overwritten on sync if the GitOps source declares this field')
|
|
121
|
+
expect(g.summary).not.toMatch(/won't be reverted|not declared in Git/i)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('no evidence at all ⇒ may-revert, and says why', () => {
|
|
125
|
+
const g = guard({ writes: [hibernate], evidenceError: 'GitOps revert warnings need a newer Radar' })
|
|
126
|
+
expect(g.level).toBe('may-revert')
|
|
127
|
+
expect(g.requiresAck).toBe(true)
|
|
128
|
+
expect(g.perWrite[0].reason).toContain("can't see which fields the GitOps source declares (GitOps revert warnings need a newer Radar)")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('unknown fields (no paths) ⇒ may-revert', () => {
|
|
132
|
+
const g = guard({ writes: [{ scope: 'spec' }], evidence: evidence(selfHeal) })
|
|
133
|
+
expect(g.level).toBe('may-revert')
|
|
134
|
+
expect(g.perWrite[0].reason).toContain("doesn't know which fields")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('ignore rules', () => {
|
|
139
|
+
it('effective ignore (RespectIgnoreDifferences) ⇒ info', () => {
|
|
140
|
+
const g = guard({
|
|
141
|
+
writes: [hibernate],
|
|
142
|
+
evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' })]),
|
|
143
|
+
})
|
|
144
|
+
expect(g.level).toBe('info')
|
|
145
|
+
expect(g.requiresAck).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('comparison-only ignore on a declared field ⇒ may-revert', () => {
|
|
149
|
+
const g = guard({
|
|
150
|
+
writes: [hibernate],
|
|
151
|
+
evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'comparison-only', ignoredBy: 'spec.ignoreDifferences' })]),
|
|
152
|
+
})
|
|
153
|
+
expect(g.level).toBe('may-revert')
|
|
154
|
+
expect(g.perWrite[0].reason).toContain('without RespectIgnoreDifferences')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('unevaluated jq ignore rule on a declared field ⇒ may-revert, not will-revert', () => {
|
|
158
|
+
const g = guard({
|
|
159
|
+
writes: [hibernate],
|
|
160
|
+
evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'unevaluated', ignoredBy: 'spec.ignoreDifferences jqPathExpressions' })]),
|
|
161
|
+
})
|
|
162
|
+
expect(g.level).toBe('may-revert')
|
|
163
|
+
expect(g.perWrite[0].reason).toContain('jq')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// The annotation on the live object doesn't prove the source declares it.
|
|
167
|
+
it('Flux ssa: IfNotPresent on the live object ⇒ may-revert', () => {
|
|
168
|
+
const g = guard({
|
|
169
|
+
owner: kustomization,
|
|
170
|
+
writes: [hibernate],
|
|
171
|
+
evidence: evidence({ tool: 'fluxcd', auto: true, selfHeal: true, prune: true, suspended: false, objectReconcile: 'if-not-present' }, [pathEvidence({ lastApplied: 'present' })]),
|
|
172
|
+
})
|
|
173
|
+
expect(g.level).toBe('may-revert')
|
|
174
|
+
expect(g.requiresAck).toBe(true)
|
|
175
|
+
expect(g.perWrite[0].reason).toContain("can't confirm the GitOps source declares it")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('an owner matched by name only never waives the acknowledgment', () => {
|
|
179
|
+
const ignored = evidence({ ...selfHeal, respectIgnoreDifferences: true }, [pathEvidence({ lastApplied: 'present', ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' })])
|
|
180
|
+
expect(guard({ writes: [hibernate], evidence: ignored }).level).toBe('info')
|
|
181
|
+
const byName = guard({ writes: [hibernate], evidence: ignored, ownerMatchedByName: true })
|
|
182
|
+
expect(byName.level).toBe('may-revert')
|
|
183
|
+
expect(byName.requiresAck).toBe(true)
|
|
184
|
+
expect(byName.perWrite[0].reason).toContain('by name only')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('Flux', () => {
|
|
189
|
+
const owned = [pathEvidence({ ownedByGitOps: true, ownedBy: [{ manager: 'kustomize-controller', operation: 'Apply', tool: 'fluxcd' }] })]
|
|
190
|
+
|
|
191
|
+
it('active Kustomization ⇒ will-revert with its interval', () => {
|
|
192
|
+
const g = guard({
|
|
193
|
+
owner: kustomization,
|
|
194
|
+
writes: [hibernate],
|
|
195
|
+
evidence: evidence({ tool: 'fluxcd', auto: true, selfHeal: true, prune: true, suspended: false, interval: '10m' }, owned),
|
|
196
|
+
})
|
|
197
|
+
expect(g.level).toBe('will-revert')
|
|
198
|
+
expect(g.perWrite[0].reason).toContain('next reconcile (every 10m)')
|
|
199
|
+
expect(g.syncPolicy).toEqual({ tool: 'fluxcd', auto: true, selfHeal: true, prune: true, suspended: false, interval: '10m' })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('suspended Kustomization ⇒ may-revert (resuming overwrites)', () => {
|
|
203
|
+
const g = guard({
|
|
204
|
+
owner: kustomization,
|
|
205
|
+
writes: [hibernate],
|
|
206
|
+
evidence: evidence({ tool: 'fluxcd', auto: false, selfHeal: false, prune: true, suspended: true, interval: '10m' }, owned),
|
|
207
|
+
})
|
|
208
|
+
expect(g.level).toBe('may-revert')
|
|
209
|
+
expect(g.perWrite[0].reason).toContain('is suspended; resuming it will overwrite')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it.each([
|
|
213
|
+
['disabled', false, 'may-revert'],
|
|
214
|
+
['warn', false, 'may-revert'],
|
|
215
|
+
['enabled', true, 'will-revert'],
|
|
216
|
+
] as const)('HelmRelease drift detection %s ⇒ %s', (mode, heal, level) => {
|
|
217
|
+
const g = guard({
|
|
218
|
+
owner: helmRelease,
|
|
219
|
+
writes: [hibernate],
|
|
220
|
+
evidence: evidence({ tool: 'fluxcd', auto: true, selfHeal: heal, prune: null, suspended: false, driftDetection: mode }, [
|
|
221
|
+
pathEvidence({ ownedByGitOps: true, ownedBy: [{ manager: 'helm-controller', operation: 'Update', tool: 'fluxcd' }] }),
|
|
222
|
+
]),
|
|
223
|
+
})
|
|
224
|
+
expect(g.level).toBe(level)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('HelmRelease drift exemptions', () => {
|
|
229
|
+
const helmOwned = pathEvidence({ ownedByGitOps: true, ownedBy: [{ manager: 'helm-controller', operation: 'Update', tool: 'fluxcd' }] })
|
|
230
|
+
const heal: GitOpsWritePolicyEvidence = { tool: 'fluxcd', auto: true, selfHeal: true, prune: null, suspended: false, driftDetection: 'enabled' }
|
|
231
|
+
|
|
232
|
+
it('an ignored field still reverts on the next upgrade', () => {
|
|
233
|
+
const g = guard({
|
|
234
|
+
owner: helmRelease,
|
|
235
|
+
writes: [hibernate],
|
|
236
|
+
evidence: evidence(heal, [{ ...helmOwned, ignored: 'effective', ignoredBy: 'spec.driftDetection.ignore' }]),
|
|
237
|
+
})
|
|
238
|
+
expect(g.level).toBe('may-revert')
|
|
239
|
+
expect(g.perWrite[0].reason).toContain('next Helm upgrade overwrites it')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('an object opted out of drift detection still reverts on the next upgrade', () => {
|
|
243
|
+
const g = guard({ owner: helmRelease, writes: [hibernate], evidence: evidence({ ...heal, objectReconcile: 'ignore' }, [helmOwned]) })
|
|
244
|
+
expect(g.level).toBe('may-revert')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('deleting an opted-out object is recreated by the next upgrade', () => {
|
|
248
|
+
const g = guard({ owner: helmRelease, writes: [{ scope: 'delete' }], evidence: evidence({ ...heal, objectReconcile: 'ignore' }) })
|
|
249
|
+
expect(g.level).toBe('may-revert')
|
|
250
|
+
expect(g.perWrite[0].reason).toContain('next Helm upgrade recreates it')
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('delete', () => {
|
|
255
|
+
it('self-heal ⇒ will-revert (recreated)', () => {
|
|
256
|
+
const g = guard({ writes: [{ scope: 'delete' }], evidence: evidence(selfHeal) })
|
|
257
|
+
expect(g.level).toBe('will-revert')
|
|
258
|
+
expect(g.perWrite[0].reason).toContain('recreate')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('manual sync ⇒ may-revert', () => {
|
|
262
|
+
expect(guard({ writes: [{ scope: 'delete' }], evidence: evidence(manual) }).level).toBe('may-revert')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('an operator-owned object is recreated by the operator, not GitOps', () => {
|
|
266
|
+
const g = guard({
|
|
267
|
+
target: { kind: 'Pod', group: '', namespace: 'db', name: 'pg-1' },
|
|
268
|
+
writes: [{ scope: 'delete' }],
|
|
269
|
+
evidence: evidence(selfHeal, [], { controllerOwner: { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', name: 'pg' } }),
|
|
270
|
+
})
|
|
271
|
+
expect(g.level).toBe('info')
|
|
272
|
+
expect(g.perWrite[0].reason).toBe('Cluster pg will recreate this Pod.')
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('native Helm ownership needs acknowledgement', () => {
|
|
277
|
+
const g = evaluateGitOpsWriteGuard({ target, owner: null, helmRelease: { namespace: 'db', name: 'pg' }, writes: [hibernate] })
|
|
278
|
+
expect(g.level).toBe('may-revert')
|
|
279
|
+
expect(g.summary).toContain('Helm release db/pg may revert this change.')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('unverifiable ownership needs acknowledgement', () => {
|
|
283
|
+
const why = "Radar couldn't read this resource, so it can't tell whether a GitOps tool or Helm manages it."
|
|
284
|
+
const g = evaluateGitOpsWriteGuard({ target, owner: null, ownershipError: why, writes: [hibernate] })
|
|
285
|
+
expect(g.requiresAck).toBe(true)
|
|
286
|
+
expect(g.summary).toBe(`A GitOps tool may revert this change. ${why}`)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('reports the worst write and keeps per-write reasons', () => {
|
|
290
|
+
const g = guard({
|
|
291
|
+
writes: [{ scope: 'status' }, { scope: 'create-child' }, hibernate],
|
|
292
|
+
evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]),
|
|
293
|
+
})
|
|
294
|
+
expect(g.perWrite.map((w) => w.level)).toEqual(['none', 'info', 'will-revert'])
|
|
295
|
+
expect(g.level).toBe('will-revert')
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('canConfirmGitOpsWrite', () => {
|
|
300
|
+
it('blocks while pending and until acknowledged', () => {
|
|
301
|
+
const managed = guard({ writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
302
|
+
expect(canConfirmGitOpsWrite(managed, false)).toBe(false)
|
|
303
|
+
expect(canConfirmGitOpsWrite(managed, true)).toBe(true)
|
|
304
|
+
expect(canConfirmGitOpsWrite({ ...managed, pending: true }, true)).toBe(false)
|
|
305
|
+
expect(canConfirmGitOpsWrite(undefined, true)).toBe(false)
|
|
306
|
+
expect(canConfirmGitOpsWrite(evaluateGitOpsWriteGuard({ target, owner: null, writes: [hibernate] }), false)).toBe(true)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('gitOpsWriteEvidencePaths', () => {
|
|
311
|
+
it('collects field paths of metadata/spec writes only', () => {
|
|
312
|
+
expect(
|
|
313
|
+
gitOpsWriteEvidencePaths([
|
|
314
|
+
hibernate,
|
|
315
|
+
{ scope: 'spec', paths: ['spec.suspend', HIBERNATE] },
|
|
316
|
+
{ scope: 'status', paths: ['status.phase'] },
|
|
317
|
+
]),
|
|
318
|
+
).toEqual([HIBERNATE, 'spec.suspend'])
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('gitOpsWriteGuardKey', () => {
|
|
323
|
+
it('changes with the verdict and stays put for the same one', () => {
|
|
324
|
+
const mayRevert = guard({ writes: [hibernate], evidence: evidence(manual, [pathEvidence({ lastApplied: 'present' })]) })
|
|
325
|
+
const willRevert = guard({ writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
326
|
+
expect(gitOpsWriteGuardKey(mayRevert)).not.toBe('')
|
|
327
|
+
expect(gitOpsWriteGuardKey(mayRevert)).toBe(gitOpsWriteGuardKey(guard({ writes: [hibernate], evidence: evidence(manual, [pathEvidence({ lastApplied: 'present' })]) })))
|
|
328
|
+
expect(gitOpsWriteGuardKey(mayRevert)).not.toBe(gitOpsWriteGuardKey(willRevert))
|
|
329
|
+
expect(gitOpsWriteGuardKey(guard({ writes: [hibernate], ownerPending: true }))).toBe('')
|
|
330
|
+
expect(gitOpsWriteGuardKey(undefined)).toBe('')
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('copy', () => {
|
|
335
|
+
it('never promises a change will not be reverted', () => {
|
|
336
|
+
const optedOut = evidence({ ...selfHeal, objectReconcile: 'ignore' }, [pathEvidence({ lastApplied: 'present' })])
|
|
337
|
+
const ignored = evidence({ ...selfHeal, respectIgnoreDifferences: true }, [pathEvidence({ lastApplied: 'present', ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' })])
|
|
338
|
+
for (const g of [
|
|
339
|
+
guard({ writes: [hibernate], evidence: optedOut }),
|
|
340
|
+
guard({ writes: [hibernate], evidence: ignored }),
|
|
341
|
+
guard({ writes: [{ scope: 'create-child', description: 'A Backup' }] }),
|
|
342
|
+
guard({ writes: [{ scope: 'delete' }], evidence: optedOut }),
|
|
343
|
+
]) {
|
|
344
|
+
expect(g.level).toBe('info')
|
|
345
|
+
for (const w of g.perWrite) expect(w.reason).not.toMatch(/won.t/)
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("doesn't let an object's opt-out waive a change whose fields are unknown", () => {
|
|
350
|
+
const optedOut = evidence({ ...selfHeal, objectReconcile: 'ignore' })
|
|
351
|
+
const g = guard({ writes: [{ scope: 'spec' }], evidence: optedOut })
|
|
352
|
+
expect(g.level).toBe('may-revert')
|
|
353
|
+
expect(g.requiresAck).toBe(true)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('describes a rule that covers only some of the entries as unconfirmed', () => {
|
|
357
|
+
const g = guard({
|
|
358
|
+
writes: [hibernate],
|
|
359
|
+
evidence: evidence({ ...selfHeal, respectIgnoreDifferences: true }, [
|
|
360
|
+
pathEvidence({ lastApplied: 'present', ignored: 'unevaluated', ignoredBy: 'spec.ignoreDifferences jsonPointers' }),
|
|
361
|
+
]),
|
|
362
|
+
})
|
|
363
|
+
expect(g.level).toBe('may-revert')
|
|
364
|
+
expect(g.requiresAck).toBe(true)
|
|
365
|
+
expect(g.perWrite[0].reason).toBe(
|
|
366
|
+
"Radar can't confirm the Application's ignore rule (spec.ignoreDifferences jsonPointers) covers this field, so the next sync may overwrite it.",
|
|
367
|
+
)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('a wildcard the source declares for only some entries may revert, not will', () => {
|
|
371
|
+
const partial = guard({ writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', partial: true })]) })
|
|
372
|
+
expect(partial.level).toBe('may-revert')
|
|
373
|
+
expect(partial.requiresAck).toBe(true)
|
|
374
|
+
expect(partial.perWrite[0].reason).toBe('The GitOps source sets this for some containers but not others; the ones it sets will be overwritten at the next sync.')
|
|
375
|
+
const whole = guard({ writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
376
|
+
expect(whole.level).toBe('will-revert')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('a path that matches nothing in the live object adds no verdict', () => {
|
|
380
|
+
const g = guard({
|
|
381
|
+
writes: [{ scope: 'spec', paths: ['spec.template.spec.containers[*].image', 'spec.template.spec.initContainers[*].image'] }],
|
|
382
|
+
evidence: evidence({ ...selfHeal, respectIgnoreDifferences: true }, [
|
|
383
|
+
pathEvidence({ path: 'spec.template.spec.containers[*].image', lastApplied: 'present', ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }),
|
|
384
|
+
pathEvidence({ path: 'spec.template.spec.initContainers[*].image', empty: true }),
|
|
385
|
+
]),
|
|
386
|
+
})
|
|
387
|
+
expect(g.level).toBe('info')
|
|
388
|
+
expect(g.requiresAck).toBe(false)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('names the owner once, in the headline', () => {
|
|
392
|
+
for (const ev of [
|
|
393
|
+
evidence({ ...selfHeal, respectIgnoreDifferences: true }, [pathEvidence({ lastApplied: 'present', ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' })]),
|
|
394
|
+
evidence({ ...selfHeal, suspended: true }, [pathEvidence({ lastApplied: 'present' })]),
|
|
395
|
+
evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'comparison-only', ignoredBy: 'spec.ignoreDifferences' })]),
|
|
396
|
+
]) {
|
|
397
|
+
const g = guard({ writes: [hibernate], evidence: ev })
|
|
398
|
+
expect(g.summary.split('argocd/pg').length - 1).toBe(1)
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
})
|