@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.
Files changed (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. 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
 
@@ -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
+ })
@@ -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
+ })