@skyhook-io/k8s-ui 1.16.3 → 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 +1 -1
- 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/primitives.tsx +3 -3
- 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/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesView.tsx +7 -8
- 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/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.ts +0 -4
- 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/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 +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- 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 +120 -43
- 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/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/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
|
+
})
|