@skyhook-io/k8s-ui 1.16.3 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- 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/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- 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/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- 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 +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- 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 +57 -17
- 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.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- 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 +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- 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/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- 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.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- 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 +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- 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-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -24
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- 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 +99 -39
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +8 -74
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +21 -2
- package/src/types/core.ts +60 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
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', () => {
|
|
@@ -41,6 +41,15 @@ describe('parseMemoryToBytes', () => {
|
|
|
41
41
|
expect(parseMemoryToBytes('1P')).toBe(1000 ** 5)
|
|
42
42
|
expect(parseMemoryToBytes('1E')).toBe(1000 ** 6)
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
// The API server keeps a quoted exponent quantity as written, and defaults a
|
|
46
|
+
// request from it the same way ("1e9" limit → "500e6" request).
|
|
47
|
+
it('parses decimal-exponent quantities', () => {
|
|
48
|
+
expect(parseMemoryToBytes('1e9')).toBe(1e9)
|
|
49
|
+
expect(parseMemoryToBytes('500e6')).toBe(5e8)
|
|
50
|
+
expect(parseMemoryToBytes('1E3')).toBe(1000)
|
|
51
|
+
expect(formatMemoryString('1e9')).toBe('954 MiB')
|
|
52
|
+
})
|
|
44
53
|
})
|
|
45
54
|
|
|
46
55
|
describe('parseQuantityToNumber', () => {
|
|
@@ -115,3 +124,41 @@ describe('parseQuantityToNumber', () => {
|
|
|
115
124
|
expect(parseQuantityToNumber(Number.POSITIVE_INFINITY)).toBe(0)
|
|
116
125
|
})
|
|
117
126
|
})
|
|
127
|
+
|
|
128
|
+
describe('relative ages', () => {
|
|
129
|
+
const now = Date.parse('2026-10-11T12:00:00Z')
|
|
130
|
+
const ago = (ms: number) => new Date(now - ms).toISOString()
|
|
131
|
+
const MIN = 60_000
|
|
132
|
+
const DAY = 24 * 60 * MIN
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
vi.useFakeTimers()
|
|
136
|
+
vi.setSystemTime(now)
|
|
137
|
+
})
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
vi.useRealTimers()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('counts days past a month instead of rounding to months or years', () => {
|
|
143
|
+
expect(formatCompactAge(ago(212 * DAY))).toBe('212d')
|
|
144
|
+
expect(formatCompactAge(ago(364 * DAY))).toBe('364d')
|
|
145
|
+
expect(formatRelativeAgeTime(ago(364 * DAY))).toBe('364d ago')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('says just now for the current moment and for small clock skew', () => {
|
|
149
|
+
expect(formatRelativeAgeTime(ago(0))).toBe('just now')
|
|
150
|
+
expect(formatRelativeAgeTime(ago(-30_000))).toBe('just now')
|
|
151
|
+
expect(formatRelativeAgeTime(ago(5 * MIN))).toBe('5m ago')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('shows a real future time as a date, not a negative age', () => {
|
|
155
|
+
const future = ago(-2 * 60 * MIN)
|
|
156
|
+
expect(formatRelativeAgeTime(future)).toBe(new Date(future).toLocaleString())
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('keeps missing and unparseable input visible', () => {
|
|
160
|
+
expect(formatRelativeAgeTime(undefined)).toBe('-')
|
|
161
|
+
expect(formatRelativeAgeTime('', '')).toBe('')
|
|
162
|
+
expect(formatRelativeAgeTime('not a time')).toBe('not a time')
|
|
163
|
+
})
|
|
164
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -120,6 +120,10 @@ export function parseMemoryToBytes(memString: string): number {
|
|
|
120
120
|
if (!memString) return 0
|
|
121
121
|
|
|
122
122
|
const str = memString.trim()
|
|
123
|
+
// Decimal-exponent form ("1e9") is decided before the suffix table, as in
|
|
124
|
+
// parseQuantityToNumber, so "1E3" reads as 1000 and not as exa.
|
|
125
|
+
if (/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/.test(str)) return Number(str)
|
|
126
|
+
|
|
123
127
|
const match = str.match(/^(-?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
|
|
124
128
|
if (!match) return 0
|
|
125
129
|
|
|
@@ -308,12 +312,16 @@ export function formatUpdatedAgo(elapsedMs: number): string {
|
|
|
308
312
|
return `${bucket} ago`
|
|
309
313
|
}
|
|
310
314
|
|
|
315
|
+
// Timestamps written moments ago (a lease renewal, a sync) can land slightly in
|
|
316
|
+
// the browser's future when its clock trails the cluster's.
|
|
317
|
+
const CLOCK_SKEW_TOLERANCE_MS = 60_000
|
|
318
|
+
|
|
311
319
|
export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
|
|
312
320
|
if (!value) return fallback
|
|
313
321
|
const time = Date.parse(value)
|
|
314
322
|
if (!Number.isFinite(time)) return value
|
|
315
323
|
const diff = Date.now() - time
|
|
316
|
-
if (diff < 0) return new Date(time).toLocaleString()
|
|
324
|
+
if (diff < 0) return diff > -CLOCK_SKEW_TOLERANCE_MS ? 'just now' : new Date(time).toLocaleString()
|
|
317
325
|
const compact = formatCompactAge(value)
|
|
318
326
|
if (!compact) return fallback
|
|
319
327
|
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
|
+
}
|
|
@@ -38,10 +38,11 @@ export function gitOpsOwnerFromRelationships(
|
|
|
38
38
|
): GitOpsOwnerRef | null {
|
|
39
39
|
const refs = rel?.managedBy
|
|
40
40
|
if (!refs || refs.length === 0) return null
|
|
41
|
-
return
|
|
41
|
+
return gitOpsOwnerFromRef(refs[0])
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** A manager ref (relationships.managedBy, a workspace's managedBy) as a GitOps owner, or null when the manager is not a GitOps controller. */
|
|
45
|
+
export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
|
|
45
46
|
switch (true) {
|
|
46
47
|
case ref.kind === 'Application' && ref.group === ARGO_APPLICATION_GROUP:
|
|
47
48
|
return { tool: 'argocd', kind: 'applications', namespace: ref.namespace, name: ref.name }
|
|
@@ -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
|
+
})
|