@skyhook-io/radar-app 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 +1 -1
- package/package.json +3 -3
- package/src/App.tsx +138 -93
- package/src/RadarApp.tsx +27 -17
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +381 -47
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +23 -150
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +230 -124
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import { SetImageDialog, canConfirmGitOpsWrite, setImageSelectionKey, type WorkloadImageInventory } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
7
|
+
import { useGitOpsWriteGuard, type GitOpsWriteGuardState } from './useGitOpsWriteGuard'
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => ({
|
|
10
|
+
applications: { data: undefined as unknown[] | undefined, isPending: true, isError: false },
|
|
11
|
+
inherited: { data: undefined as unknown, isPending: false, isError: false },
|
|
12
|
+
target: { data: undefined as unknown, relationships: undefined as unknown, isLoading: false, isError: false },
|
|
13
|
+
evidence: vi.fn(),
|
|
14
|
+
evidenceUnsupported: false,
|
|
15
|
+
}))
|
|
16
|
+
vi.mock('../api/client', () => ({
|
|
17
|
+
useResource: (kind: string) => (kind === 'deployments' ? mocks.target : { data: undefined, isLoading: false, isError: false }),
|
|
18
|
+
useResourceWithRelationships: () => mocks.inherited,
|
|
19
|
+
useResources: () => mocks.applications,
|
|
20
|
+
useRadarFeature: () => ({
|
|
21
|
+
guard: async (fn: () => unknown) => {
|
|
22
|
+
if (!mocks.evidenceUnsupported) return fn()
|
|
23
|
+
const { RadarFeatureUnsupportedError } = await import('../api/radarFeatures')
|
|
24
|
+
throw new RadarFeatureUnsupportedError('gitopsWriteEvidence', { currentVersion: 'v1.16.0' })
|
|
25
|
+
},
|
|
26
|
+
gatedKey: mocks.evidenceUnsupported ? ['radar-feature-unsupported'] : [],
|
|
27
|
+
}),
|
|
28
|
+
fetchGitOpsWriteEvidence: (...args: unknown[]) => mocks.evidence(...args),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
mocks.evidenceUnsupported = false
|
|
33
|
+
mocks.applications.data = undefined
|
|
34
|
+
mocks.applications.isPending = true
|
|
35
|
+
mocks.applications.isError = false
|
|
36
|
+
mocks.inherited = { data: undefined, isPending: false, isError: false }
|
|
37
|
+
mocks.target = { data: undefined, relationships: undefined, isLoading: false, isError: false }
|
|
38
|
+
mocks.evidence.mockReset()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it.each(['resolved', 'failed', 'empty', 'ambiguous'])('waits for Argo Application namespace lookup before handling %s ownership', async (outcome) => {
|
|
42
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
43
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
44
|
+
const root = createRoot(document.createElement('div'))
|
|
45
|
+
let state: GitOpsWriteGuardState
|
|
46
|
+
const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
|
|
47
|
+
target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
|
|
48
|
+
containers: [{ type: 'container', name: 'app', image: 'app:old' }],
|
|
49
|
+
behavior: { type: 'rolling' },
|
|
50
|
+
}))
|
|
51
|
+
mocks.evidence.mockResolvedValue({ uid: 'deployment', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] })
|
|
52
|
+
function Harness() {
|
|
53
|
+
state = useGitOpsWriteGuard({
|
|
54
|
+
target: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' },
|
|
55
|
+
resource: {},
|
|
56
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
|
|
57
|
+
writes: [{ scope: 'spec', paths: ['spec.replicas'] }],
|
|
58
|
+
})
|
|
59
|
+
return outcome !== 'resolved' ? <SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={state.guard ? { guard: state.guard } : undefined} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} /> : null
|
|
60
|
+
}
|
|
61
|
+
const render = () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>)
|
|
62
|
+
try {
|
|
63
|
+
await act(async () => render())
|
|
64
|
+
expect(state!.guard?.pending).toBe(true)
|
|
65
|
+
expect(mocks.evidence).not.toHaveBeenCalled()
|
|
66
|
+
mocks.applications.isPending = false
|
|
67
|
+
if (outcome === 'resolved') mocks.applications.data = [{ metadata: { name: 'web', namespace: 'argocd' } }]
|
|
68
|
+
else if (outcome === 'failed') mocks.applications.isError = true
|
|
69
|
+
else mocks.applications.data = outcome === 'empty' ? [] : ['argocd-a', 'argocd-b'].map((namespace) => ({ metadata: { name: 'web', namespace } }))
|
|
70
|
+
await act(async () => render())
|
|
71
|
+
if (outcome === 'resolved') {
|
|
72
|
+
await act(async () => vi.waitFor(() => expect(mocks.evidence).toHaveBeenCalledTimes(1)))
|
|
73
|
+
expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('argocd')
|
|
74
|
+
} else {
|
|
75
|
+
// The server resolves the namespace; the browser sends the name alone.
|
|
76
|
+
await act(async () => vi.waitFor(() => expect(mocks.evidence).toHaveBeenCalledTimes(1)))
|
|
77
|
+
expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('')
|
|
78
|
+
await act(async () => vi.waitFor(() => expect(state!.guard?.pending).toBe(false)))
|
|
79
|
+
expect(state!.ownership.lookupError).toBe(false)
|
|
80
|
+
expect(state!.guard?.pending).toBe(false)
|
|
81
|
+
expect(state!.guard?.requiresAck).toBe(true)
|
|
82
|
+
expect(state!.guard?.level).toBe('may-revert')
|
|
83
|
+
expect(canConfirmGitOpsWrite(state!.guard, true)).toBe(true)
|
|
84
|
+
expect(document.body.textContent).not.toContain('Management ownership unavailable')
|
|
85
|
+
expect(document.body.textContent).toContain('Argo CD')
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await act(async () => root.unmount())
|
|
89
|
+
queryClient.clear()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
async function renderGuard(options: Parameters<typeof useGitOpsWriteGuard>[0]) {
|
|
94
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
95
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
96
|
+
const root = createRoot(document.createElement('div'))
|
|
97
|
+
const ref: { state?: GitOpsWriteGuardState } = {}
|
|
98
|
+
function Harness() {
|
|
99
|
+
ref.state = useGitOpsWriteGuard(options)
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
await act(async () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>))
|
|
103
|
+
return {
|
|
104
|
+
ref,
|
|
105
|
+
queryClient,
|
|
106
|
+
rerender: () => act(async () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>)),
|
|
107
|
+
cleanup: async () => {
|
|
108
|
+
await act(async () => root.unmount())
|
|
109
|
+
queryClient.clear()
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const deployment = { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' }
|
|
115
|
+
const replicas = [{ scope: 'spec' as const, paths: ['spec.replicas'] }]
|
|
116
|
+
|
|
117
|
+
it('reads missing relationships as unknown ownership, not as unmanaged', async () => {
|
|
118
|
+
const h = await renderGuard({ target: deployment, resource: {}, relationshipsUnavailable: true, writes: replicas })
|
|
119
|
+
try {
|
|
120
|
+
expect(mocks.evidence).not.toHaveBeenCalled()
|
|
121
|
+
expect(h.ref.state!.guard?.pending).toBe(false)
|
|
122
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
123
|
+
expect(h.ref.state!.guard?.requiresAck).toBe(true)
|
|
124
|
+
expect(h.ref.state!.guard?.ownershipError).toContain("hasn't mapped this resource's owners")
|
|
125
|
+
expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
|
|
126
|
+
} finally {
|
|
127
|
+
await h.cleanup()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('asks for an acknowledgment when the server saw a manager the browser could not resolve', async () => {
|
|
132
|
+
const h = await renderGuard({ target: deployment, resource: {}, relationships: {}, declaredManager: 'Argo CD', writes: replicas })
|
|
133
|
+
try {
|
|
134
|
+
expect(h.ref.state!.guard?.requiresAck).toBe(true)
|
|
135
|
+
expect(h.ref.state!.guard?.ownershipError).toContain('Argo CD')
|
|
136
|
+
} finally {
|
|
137
|
+
await h.cleanup()
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('ignores the declared manager once an owner resolves', async () => {
|
|
142
|
+
mocks.evidence.mockResolvedValue({ uid: 'u', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] })
|
|
143
|
+
const h = await renderGuard({
|
|
144
|
+
target: deployment,
|
|
145
|
+
resource: {},
|
|
146
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
|
|
147
|
+
declaredManager: 'Argo CD',
|
|
148
|
+
writes: replicas,
|
|
149
|
+
})
|
|
150
|
+
try {
|
|
151
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
152
|
+
expect(h.ref.state!.guard?.ownershipError).toBeNull()
|
|
153
|
+
expect(h.ref.state!.guard?.owner?.name).toBe('web')
|
|
154
|
+
} finally {
|
|
155
|
+
await h.cleanup()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('treats a refetch of the evidence as pending', async () => {
|
|
160
|
+
let resolveSecond: (v: unknown) => void = () => {}
|
|
161
|
+
const evidence = { uid: 'u', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] }
|
|
162
|
+
mocks.evidence.mockResolvedValueOnce(evidence).mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
|
|
163
|
+
const h = await renderGuard({
|
|
164
|
+
target: deployment,
|
|
165
|
+
resource: {},
|
|
166
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
|
|
167
|
+
writes: replicas,
|
|
168
|
+
})
|
|
169
|
+
try {
|
|
170
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
171
|
+
await act(async () => { void h.queryClient.invalidateQueries({ queryKey: ['gitops-write-evidence'] }) })
|
|
172
|
+
await h.rerender()
|
|
173
|
+
expect(h.ref.state!.guard?.pending).toBe(true)
|
|
174
|
+
expect(canConfirmGitOpsWrite(h.ref.state!.guard, true)).toBe(false)
|
|
175
|
+
await act(async () => resolveSecond(evidence))
|
|
176
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
177
|
+
} finally {
|
|
178
|
+
await h.cleanup()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not keep an exemption after a refetch fails', async () => {
|
|
183
|
+
const exempt = {
|
|
184
|
+
uid: 'u',
|
|
185
|
+
resourceVersion: '1',
|
|
186
|
+
owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
|
|
187
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
188
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
189
|
+
}
|
|
190
|
+
mocks.evidence.mockResolvedValueOnce(exempt).mockRejectedValueOnce(new Error('503 cache not ready'))
|
|
191
|
+
const h = await renderGuard({
|
|
192
|
+
target: deployment,
|
|
193
|
+
resource: {},
|
|
194
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
|
|
195
|
+
writes: replicas,
|
|
196
|
+
})
|
|
197
|
+
try {
|
|
198
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
199
|
+
expect(h.ref.state!.guard?.level).toBe('info')
|
|
200
|
+
await act(async () => { await h.queryClient.invalidateQueries({ queryKey: ['gitops-write-evidence'] }) })
|
|
201
|
+
await act(async () => vi.waitFor(() => {
|
|
202
|
+
expect(h.ref.state!.guard?.pending).toBe(false)
|
|
203
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
204
|
+
}))
|
|
205
|
+
expect(h.ref.state!.guard?.requiresAck).toBe(true)
|
|
206
|
+
} finally {
|
|
207
|
+
await h.cleanup()
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('asks for an acknowledgment, not a block, on a Radar without write evidence', async () => {
|
|
212
|
+
mocks.evidenceUnsupported = true
|
|
213
|
+
const h = await renderGuard({
|
|
214
|
+
target: deployment,
|
|
215
|
+
resource: {},
|
|
216
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
|
|
217
|
+
writes: replicas,
|
|
218
|
+
})
|
|
219
|
+
try {
|
|
220
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
221
|
+
expect(mocks.evidence).not.toHaveBeenCalled()
|
|
222
|
+
const guard = h.ref.state!.guard
|
|
223
|
+
expect(guard?.level).toBe('may-revert')
|
|
224
|
+
expect(guard?.perWrite[0].reason).toContain('need a newer Radar')
|
|
225
|
+
expect(canConfirmGitOpsWrite(guard, false)).toBe(false)
|
|
226
|
+
expect(canConfirmGitOpsWrite(guard, true)).toBe(true)
|
|
227
|
+
} finally {
|
|
228
|
+
await h.cleanup()
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const pod ={ kind: 'Pod', group: '', namespace: 'prod', name: 'web-1-abc' }
|
|
233
|
+
const podRelationships = {
|
|
234
|
+
owner: { kind: 'ReplicaSet', group: 'apps', namespace: 'prod', name: 'web-1' },
|
|
235
|
+
deployment: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' },
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
it('reads a parent without relationships as unknown ownership, not as unmanaged', async () => {
|
|
239
|
+
mocks.inherited = { data: { resource: { metadata: { labels: { 'kustomize.toolkit.fluxcd.io/name': 'apps' } } } }, isPending: false, isError: false }
|
|
240
|
+
const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
|
|
241
|
+
try {
|
|
242
|
+
expect(mocks.evidence).not.toHaveBeenCalled()
|
|
243
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
244
|
+
expect(h.ref.state!.guard?.requiresAck).toBe(true)
|
|
245
|
+
expect(h.ref.state!.guard?.ownershipError).toContain("hasn't mapped this resource's owners")
|
|
246
|
+
} finally {
|
|
247
|
+
await h.cleanup()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does not keep an exemption after the parent lookup fails', async () => {
|
|
252
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }
|
|
253
|
+
mocks.inherited = { data: { resource: {}, relationships: { managedBy: [app] } }, isPending: false, isError: false }
|
|
254
|
+
mocks.evidence.mockResolvedValue({
|
|
255
|
+
uid: 'u',
|
|
256
|
+
resourceVersion: '1',
|
|
257
|
+
owner: app,
|
|
258
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
259
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
260
|
+
})
|
|
261
|
+
const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
|
|
262
|
+
try {
|
|
263
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
264
|
+
expect(h.ref.state!.guard?.level).toBe('info')
|
|
265
|
+
// React Query keeps the parent's data after a failed refetch.
|
|
266
|
+
mocks.inherited = { ...mocks.inherited, isError: true }
|
|
267
|
+
await h.rerender()
|
|
268
|
+
expect(h.ref.state!.guard?.pending).toBe(false)
|
|
269
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
270
|
+
expect(h.ref.state!.guard?.requiresAck).toBe(true)
|
|
271
|
+
} finally {
|
|
272
|
+
await h.cleanup()
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('does not keep an exemption after the target lookup fails', async () => {
|
|
277
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }
|
|
278
|
+
mocks.target = { data: {}, relationships: { managedBy: [app] }, isLoading: false, isError: false }
|
|
279
|
+
mocks.evidence.mockResolvedValue({
|
|
280
|
+
uid: 'u',
|
|
281
|
+
resourceVersion: '1',
|
|
282
|
+
owner: app,
|
|
283
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
284
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
285
|
+
})
|
|
286
|
+
const h = await renderGuard({ target: deployment, writes: replicas })
|
|
287
|
+
try {
|
|
288
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
289
|
+
expect(h.ref.state!.guard?.level).toBe('info')
|
|
290
|
+
// React Query keeps the target's data after a failed refetch.
|
|
291
|
+
mocks.target = { ...mocks.target, isError: true }
|
|
292
|
+
await h.rerender()
|
|
293
|
+
expect(h.ref.state!.guard?.pending).toBe(false)
|
|
294
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
295
|
+
expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
|
|
296
|
+
} finally {
|
|
297
|
+
await h.cleanup()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('asks for an acknowledgment, not a block, when the parent cannot be read', async () => {
|
|
302
|
+
mocks.inherited = { data: undefined, isPending: false, isError: true }
|
|
303
|
+
const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
|
|
304
|
+
try {
|
|
305
|
+
expect(mocks.evidence).not.toHaveBeenCalled()
|
|
306
|
+
expect(h.ref.state!.guard?.level).toBe('may-revert')
|
|
307
|
+
expect(h.ref.state!.guard?.ownershipError).toContain("couldn't read the resource that owns this one")
|
|
308
|
+
expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
|
|
309
|
+
expect(canConfirmGitOpsWrite(h.ref.state!.guard, true)).toBe(true)
|
|
310
|
+
} finally {
|
|
311
|
+
await h.cleanup()
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('lets an ignore rule waive the acknowledgment once the server confirms a name-only Argo match', async () => {
|
|
316
|
+
mocks.applications.isPending = false
|
|
317
|
+
mocks.applications.data = [{ metadata: { name: 'web', namespace: 'argocd' } }]
|
|
318
|
+
const exempt = (ownerTracksTarget: boolean) => ({
|
|
319
|
+
uid: 'u',
|
|
320
|
+
resourceVersion: '1',
|
|
321
|
+
owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
|
|
322
|
+
ownerTracksTarget,
|
|
323
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
324
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
325
|
+
})
|
|
326
|
+
for (const [tracks, level] of [[true, 'info'], [false, 'may-revert']] as const) {
|
|
327
|
+
mocks.evidence.mockResolvedValueOnce(exempt(tracks))
|
|
328
|
+
const h = await renderGuard({
|
|
329
|
+
target: deployment,
|
|
330
|
+
resource: {},
|
|
331
|
+
// Argo CD 3's tracking id omits the namespace of Applications in its own namespace.
|
|
332
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
|
|
333
|
+
writes: replicas,
|
|
334
|
+
})
|
|
335
|
+
try {
|
|
336
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
337
|
+
expect(h.ref.state!.guard?.level).toBe(level)
|
|
338
|
+
} finally {
|
|
339
|
+
await h.cleanup()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it("takes the Application the server confirmed when the browser can't list Applications", async () => {
|
|
345
|
+
mocks.applications.isPending = false
|
|
346
|
+
mocks.applications.data = []
|
|
347
|
+
mocks.evidence.mockResolvedValueOnce({
|
|
348
|
+
uid: 'u',
|
|
349
|
+
resourceVersion: '1',
|
|
350
|
+
owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
|
|
351
|
+
ownerTracksTarget: true,
|
|
352
|
+
policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
|
|
353
|
+
paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
|
|
354
|
+
})
|
|
355
|
+
const h = await renderGuard({
|
|
356
|
+
target: deployment,
|
|
357
|
+
resource: {},
|
|
358
|
+
relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
|
|
359
|
+
writes: replicas,
|
|
360
|
+
})
|
|
361
|
+
try {
|
|
362
|
+
await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
|
|
363
|
+
expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('')
|
|
364
|
+
expect(h.ref.state!.guard?.owner?.namespace).toBe('argocd')
|
|
365
|
+
expect(h.ref.state!.guard?.level).toBe('info')
|
|
366
|
+
expect(h.ref.state!.ownership.ownerVerified).toBe(true)
|
|
367
|
+
} finally {
|
|
368
|
+
await h.cleanup()
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('reports the listed and changed containers so the host can judge exactly those', async () => {
|
|
373
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
374
|
+
const root = createRoot(document.createElement('div'))
|
|
375
|
+
const onSelectionChange = vi.fn()
|
|
376
|
+
const guard = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
|
|
377
|
+
const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
|
|
378
|
+
target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
|
|
379
|
+
containers: [{ type: 'container', name: 'app', image: 'app:old' }, { type: 'initContainer', name: 'migrate', image: 'migrate:old' }],
|
|
380
|
+
behavior: { type: 'rolling' },
|
|
381
|
+
}))
|
|
382
|
+
try {
|
|
383
|
+
await act(async () => root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard, onSelectionChange }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />))
|
|
384
|
+
await act(async () => vi.waitFor(() => expect(onSelectionChange).toHaveBeenLastCalledWith({
|
|
385
|
+
containers: [{ type: 'container', name: 'app' }, { type: 'initContainer', name: 'migrate' }],
|
|
386
|
+
changed: [],
|
|
387
|
+
})))
|
|
388
|
+
} finally {
|
|
389
|
+
await act(async () => root.unmount())
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('keeps Update disabled until the host has judged the containers being changed', async () => {
|
|
394
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
395
|
+
const host = document.createElement('div')
|
|
396
|
+
document.body.appendChild(host)
|
|
397
|
+
const root = createRoot(host)
|
|
398
|
+
const unmanaged = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
|
|
399
|
+
const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
|
|
400
|
+
target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
|
|
401
|
+
containers: [{ type: 'container', name: 'app', image: 'app:old' }],
|
|
402
|
+
behavior: { type: 'rolling' },
|
|
403
|
+
}))
|
|
404
|
+
const changed = setImageSelectionKey({ containers: [{ type: 'container', name: 'app' }], changed: [{ type: 'container', name: 'app' }] })
|
|
405
|
+
const render = (selectionKey: string) =>
|
|
406
|
+
root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard: unmanaged, onSelectionChange: () => {}, selectionKey }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />)
|
|
407
|
+
const update = () => [...document.querySelectorAll('button')].find((b) => /^Update \d+ image/.test(b.textContent ?? '')) as HTMLButtonElement | undefined
|
|
408
|
+
try {
|
|
409
|
+
await act(async () => render(''))
|
|
410
|
+
const input = await vi.waitFor(() => {
|
|
411
|
+
const el = document.querySelector('input[aria-label^="New image"]') as HTMLInputElement | null
|
|
412
|
+
expect(el).not.toBeNull()
|
|
413
|
+
return el!
|
|
414
|
+
})
|
|
415
|
+
await act(async () => {
|
|
416
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'app:new')
|
|
417
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
418
|
+
})
|
|
419
|
+
// The guard on screen was judged before the edit.
|
|
420
|
+
expect(update()?.disabled).toBe(true)
|
|
421
|
+
await act(async () => render(changed))
|
|
422
|
+
expect(update()?.disabled).toBe(false)
|
|
423
|
+
} finally {
|
|
424
|
+
await act(async () => root.unmount())
|
|
425
|
+
host.remove()
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it("doesn't hold Update for a host that reports the selection without judging it", async () => {
|
|
430
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
431
|
+
const host = document.createElement('div')
|
|
432
|
+
document.body.appendChild(host)
|
|
433
|
+
const root = createRoot(host)
|
|
434
|
+
const unmanaged = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
|
|
435
|
+
const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
|
|
436
|
+
target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
|
|
437
|
+
containers: [{ type: 'container', name: 'app', image: 'app:old' }],
|
|
438
|
+
behavior: { type: 'rolling' },
|
|
439
|
+
}))
|
|
440
|
+
try {
|
|
441
|
+
await act(async () => root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard: unmanaged, onSelectionChange: () => {} }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />))
|
|
442
|
+
const input = await vi.waitFor(() => {
|
|
443
|
+
const el = document.querySelector('input[aria-label^="New image"]') as HTMLInputElement | null
|
|
444
|
+
expect(el).not.toBeNull()
|
|
445
|
+
return el!
|
|
446
|
+
})
|
|
447
|
+
await act(async () => {
|
|
448
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'app:new')
|
|
449
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
450
|
+
})
|
|
451
|
+
const update = [...document.querySelectorAll('button')].find((b) => /^Update \d+ image/.test(b.textContent ?? '')) as HTMLButtonElement | undefined
|
|
452
|
+
expect(update?.disabled).toBe(false)
|
|
453
|
+
} finally {
|
|
454
|
+
await act(async () => root.unmount())
|
|
455
|
+
host.remove()
|
|
456
|
+
}
|
|
457
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import {
|
|
4
|
+
evaluateGitOpsWriteGuard,
|
|
5
|
+
gitOpsWriteEvidencePaths,
|
|
6
|
+
type GitOpsWrite,
|
|
7
|
+
type GitOpsWriteEvidence,
|
|
8
|
+
type GitOpsWriteGuard,
|
|
9
|
+
type GitOpsWriteTarget,
|
|
10
|
+
} from '@skyhook-io/k8s-ui'
|
|
11
|
+
import type { Relationships } from '../types'
|
|
12
|
+
import { fetchGitOpsWriteEvidence, useResource, useRadarFeature } from '../api/client'
|
|
13
|
+
import { getApiBase } from '../api/config'
|
|
14
|
+
import { kindToPluralWithGroup } from '../utils/navigation'
|
|
15
|
+
import { gitOpsOwnerKindRef, useResolvedGitOpsOwner, type ResolvedGitOpsOwner } from './useResolvedGitOpsOwner'
|
|
16
|
+
|
|
17
|
+
export interface GitOpsWriteGuardState {
|
|
18
|
+
/** Undefined only while disabled. */
|
|
19
|
+
guard: GitOpsWriteGuard | undefined
|
|
20
|
+
ownership: ResolvedGitOpsOwner
|
|
21
|
+
evidence: GitOpsWriteEvidence | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Evaluates the GitOps write guard for a write to `target`: resolves the
|
|
25
|
+
// owner (or takes one the host already resolved), asks the server for the
|
|
26
|
+
// field-level evidence the browser can't see, and classifies the writes.
|
|
27
|
+
//
|
|
28
|
+
// `relationships`/`resource` may be omitted; the target is then fetched.
|
|
29
|
+
export function useGitOpsWriteGuard({
|
|
30
|
+
target,
|
|
31
|
+
writes,
|
|
32
|
+
relationships,
|
|
33
|
+
resource,
|
|
34
|
+
ownership: resolved,
|
|
35
|
+
relationshipsUnavailable = false,
|
|
36
|
+
declaredManager,
|
|
37
|
+
evidencePaths: requestedPaths,
|
|
38
|
+
enabled = true,
|
|
39
|
+
}: {
|
|
40
|
+
target: GitOpsWriteTarget
|
|
41
|
+
writes: GitOpsWrite[]
|
|
42
|
+
/** Paths to ask the server about, when wider than the writes being judged
|
|
43
|
+
* (every container a dialog lists, while judging the ones being changed). */
|
|
44
|
+
evidencePaths?: string[]
|
|
45
|
+
relationships?: Relationships
|
|
46
|
+
resource?: unknown
|
|
47
|
+
/** Pass when the host already resolved ownership for this target. */
|
|
48
|
+
ownership?: ResolvedGitOpsOwner
|
|
49
|
+
/** The host read the target but the server sent no relationships (its
|
|
50
|
+
* topology isn't built yet), so ownership is unknown rather than absent. */
|
|
51
|
+
relationshipsUnavailable?: boolean
|
|
52
|
+
/** A manager the server detected on its own (e.g. "Argo CD"). When the
|
|
53
|
+
* browser can't resolve an owner, ownership is unknown rather than absent. */
|
|
54
|
+
declaredManager?: string | null
|
|
55
|
+
enabled?: boolean
|
|
56
|
+
}): GitOpsWriteGuardState {
|
|
57
|
+
const needsTarget = enabled && !resolved && relationships === undefined
|
|
58
|
+
const targetQuery = useResource<any>(
|
|
59
|
+
needsTarget ? kindToPluralWithGroup(target.kind, target.group) : '',
|
|
60
|
+
target.namespace,
|
|
61
|
+
target.name,
|
|
62
|
+
target.group || undefined,
|
|
63
|
+
// Keyed by API base: a host can point one QueryClient at several clusters
|
|
64
|
+
// (Radar Hub's fleet Diagnose panel), and the shared resource keys aren't.
|
|
65
|
+
{ enabled: needsTarget, cacheScope: getApiBase() },
|
|
66
|
+
)
|
|
67
|
+
const ownRelationships = relationships ?? targetQuery.relationships
|
|
68
|
+
const ownResolution = useResolvedGitOpsOwner({
|
|
69
|
+
kind: kindToPluralWithGroup(target.kind, target.group),
|
|
70
|
+
group: target.group || undefined,
|
|
71
|
+
namespace: target.namespace,
|
|
72
|
+
name: target.name,
|
|
73
|
+
relationships: ownRelationships,
|
|
74
|
+
resource: resource ?? targetQuery.data,
|
|
75
|
+
enabled: enabled && !resolved,
|
|
76
|
+
cacheScope: getApiBase(),
|
|
77
|
+
})
|
|
78
|
+
const ownership = resolved ?? ownResolution
|
|
79
|
+
|
|
80
|
+
const targetLookupPending = needsTarget && targetQuery.isLoading
|
|
81
|
+
const targetLookupFailed = needsTarget && targetQuery.isError
|
|
82
|
+
const lookupPending = targetLookupPending || ownership.lookupPending
|
|
83
|
+
const managed = Boolean(ownership.owner || ownership.helmOwner)
|
|
84
|
+
|
|
85
|
+
const writesKey = JSON.stringify(writes)
|
|
86
|
+
const requestedPathsKey = requestedPaths ? JSON.stringify(requestedPaths) : ''
|
|
87
|
+
const paths = useMemo(
|
|
88
|
+
() => (requestedPathsKey ? (JSON.parse(requestedPathsKey) as string[]) : gitOpsWriteEvidencePaths(JSON.parse(writesKey))),
|
|
89
|
+
[requestedPathsKey, writesKey],
|
|
90
|
+
)
|
|
91
|
+
const ownerRef = useMemo(() => {
|
|
92
|
+
if (ownership.owner) {
|
|
93
|
+
return { ...gitOpsOwnerKindRef(ownership.owner), namespace: ownership.owner.namespace, name: ownership.owner.name }
|
|
94
|
+
}
|
|
95
|
+
if (ownership.helmOwner) {
|
|
96
|
+
return { kind: 'HelmRelease', group: '', namespace: ownership.helmOwner.namespace, name: ownership.helmOwner.name }
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}, [ownership.owner, ownership.helmOwner])
|
|
100
|
+
// An Argo owner without a namespace is still asked about: the server finds
|
|
101
|
+
// the Application whose status lists the object.
|
|
102
|
+
const canFetchEvidence = managed && !lookupPending && !targetLookupFailed && !ownership.lookupError
|
|
103
|
+
|
|
104
|
+
const evidenceFeature = useRadarFeature('gitopsWriteEvidence')
|
|
105
|
+
const evidenceQuery = useQuery({
|
|
106
|
+
// The API base identifies the cluster when a host switches it (Radar Hub).
|
|
107
|
+
queryKey: ['gitops-write-evidence', getApiBase(), target.kind, target.group, target.namespace, target.name, paths, ownerRef, ...evidenceFeature.gatedKey],
|
|
108
|
+
queryFn: () =>
|
|
109
|
+
evidenceFeature.guard(() =>
|
|
110
|
+
fetchGitOpsWriteEvidence({
|
|
111
|
+
kind: target.kind,
|
|
112
|
+
group: target.group,
|
|
113
|
+
namespace: target.namespace,
|
|
114
|
+
name: target.name,
|
|
115
|
+
paths,
|
|
116
|
+
owner: ownerRef ?? undefined,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
enabled: enabled && canFetchEvidence,
|
|
120
|
+
staleTime: 0,
|
|
121
|
+
retry: false,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const targetRelationshipsMissing = needsTarget && targetQuery.data != null && targetQuery.relationships == null
|
|
125
|
+
const unresolvedDeclaredManager = Boolean(declaredManager) && !managed && !lookupPending
|
|
126
|
+
const unknownOwner = "so it can't tell whether a GitOps tool or Helm manages it."
|
|
127
|
+
const ownershipError = targetLookupFailed
|
|
128
|
+
? `Radar couldn't read this resource, ${unknownOwner}`
|
|
129
|
+
: ownership.lookupError
|
|
130
|
+
? `Radar couldn't read the resource that owns this one, ${unknownOwner}`
|
|
131
|
+
: relationshipsUnavailable || targetRelationshipsMissing || ownership.relationshipsUnavailable
|
|
132
|
+
? `Radar hasn't mapped this resource's owners yet, ${unknownOwner}`
|
|
133
|
+
: unresolvedDeclaredManager
|
|
134
|
+
? `Its labels mark it as managed by ${declaredManager}, but Radar couldn't find the owner to check whether it would revert this change.`
|
|
135
|
+
: null
|
|
136
|
+
|
|
137
|
+
// React Query keeps earlier data after a failed refetch, and while the query
|
|
138
|
+
// is disabled (an unreadable target or owner); an exemption it carried may no
|
|
139
|
+
// longer hold.
|
|
140
|
+
const evidence = evidenceQuery.isError || !canFetchEvidence ? undefined : evidenceQuery.data
|
|
141
|
+
|
|
142
|
+
// Argo CD 3's tracking id omits the namespace of Applications in its own
|
|
143
|
+
// namespace, and the browser's namespace view may hide them; take the
|
|
144
|
+
// Application the server confirmed.
|
|
145
|
+
const confirmedOwnership = useMemo<ResolvedGitOpsOwner>(() => {
|
|
146
|
+
const owner = ownership.owner
|
|
147
|
+
const namespace = evidence?.ownerTracksTarget ? evidence.owner?.namespace : undefined
|
|
148
|
+
if (!owner || owner.namespace || !namespace) return ownership
|
|
149
|
+
return { ...ownership, owner: { ...owner, namespace }, ownerVerified: true }
|
|
150
|
+
}, [ownership, evidence])
|
|
151
|
+
|
|
152
|
+
const guard = useMemo(
|
|
153
|
+
() =>
|
|
154
|
+
enabled
|
|
155
|
+
? evaluateGitOpsWriteGuard({
|
|
156
|
+
target,
|
|
157
|
+
owner: confirmedOwnership.owner,
|
|
158
|
+
helmRelease: ownership.helmOwner,
|
|
159
|
+
// A refetch counts as pending: an earlier verdict may no longer hold.
|
|
160
|
+
ownerPending: lookupPending || (canFetchEvidence && (evidenceQuery.isPending || evidenceQuery.isFetching)),
|
|
161
|
+
ownershipError,
|
|
162
|
+
// The Application's status listing the object confirms a name match.
|
|
163
|
+
ownerMatchedByName: ownership.ownerMatchedByName && !evidence?.ownerTracksTarget,
|
|
164
|
+
evidence,
|
|
165
|
+
evidenceError: evidenceQuery.error instanceof Error ? evidenceQuery.error.message : null,
|
|
166
|
+
writes: JSON.parse(writesKey),
|
|
167
|
+
})
|
|
168
|
+
: undefined,
|
|
169
|
+
// target fields are listed individually so an inline literal doesn't
|
|
170
|
+
// re-evaluate every render.
|
|
171
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
172
|
+
[
|
|
173
|
+
enabled,
|
|
174
|
+
target.kind,
|
|
175
|
+
target.group,
|
|
176
|
+
target.namespace,
|
|
177
|
+
target.name,
|
|
178
|
+
confirmedOwnership.owner,
|
|
179
|
+
ownership.helmOwner,
|
|
180
|
+
lookupPending,
|
|
181
|
+
canFetchEvidence,
|
|
182
|
+
evidenceQuery.isPending,
|
|
183
|
+
evidenceQuery.isFetching,
|
|
184
|
+
evidence,
|
|
185
|
+
evidenceQuery.error,
|
|
186
|
+
ownershipError,
|
|
187
|
+
ownership.ownerMatchedByName,
|
|
188
|
+
writesKey,
|
|
189
|
+
],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return { guard, ownership: confirmedOwnership, evidence }
|
|
193
|
+
}
|