@skyhook-io/k8s-ui 1.16.3 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/primitives.tsx +3 -3
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
- package/src/components/issues/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesView.tsx +7 -8
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +2 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.tsx +21 -7
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-cnpg.ts +0 -4
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +17 -33
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -20
- package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- package/src/components/topology/TopologyGraph.tsx +5 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/drawer-components.test.tsx +66 -1
- package/src/components/ui/drawer-components.tsx +43 -29
- package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.tsx +120 -43
- package/src/types/capacity.ts +10 -0
- package/src/types/core.ts +32 -1
- package/src/utils/format.test.ts +40 -2
- package/src/utils/format.ts +5 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/navigation.test.ts +13 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/resource-hierarchy.test.ts +32 -0
- package/src/utils/resource-hierarchy.ts +28 -2
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// Classifies a direct write to a live object against the GitOps (or Helm)
|
|
2
|
+
// source that manages it: will the owner put the old value back, might it, or
|
|
3
|
+
// does a policy exclude the field?
|
|
4
|
+
//
|
|
5
|
+
// Evidence comes from the server (POST /api/gitops/write-evidence), because
|
|
6
|
+
// the facts that matter — the last client-side apply payload and
|
|
7
|
+
// managedFields — are stripped from every cached object Radar serves. Neither
|
|
8
|
+
// is proof of what Git declares today: last-applied is a PREVIOUS apply, and a
|
|
9
|
+
// field missing from both can still be in the source. So absence of evidence
|
|
10
|
+
// is "unknown" (may be overwritten), never "safe".
|
|
11
|
+
|
|
12
|
+
import type { GitOpsOwnerRef, GitOpsOwnerTool } from './gitops-owner'
|
|
13
|
+
|
|
14
|
+
export type GitOpsWriteScope = 'status' | 'metadata' | 'spec' | 'create-child' | 'delete'
|
|
15
|
+
|
|
16
|
+
export interface GitOpsWrite {
|
|
17
|
+
scope: GitOpsWriteScope
|
|
18
|
+
/** Field paths, e.g. `spec.suspend`, `metadata.annotations["cnpg.io/hibernation"]`,
|
|
19
|
+
* `spec.template.spec.containers[name=app].image`, `spec.template.spec.containers[*].image`.
|
|
20
|
+
* Omitted = the fields aren't known. */
|
|
21
|
+
paths?: string[]
|
|
22
|
+
description?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GitOpsWriteTarget {
|
|
26
|
+
/** Singular Kind, e.g. `Deployment`. */
|
|
27
|
+
kind: string
|
|
28
|
+
/** API group; `''` for core. */
|
|
29
|
+
group: string
|
|
30
|
+
namespace: string
|
|
31
|
+
name: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type GitOpsWriteLevel = 'none' | 'info' | 'may-revert' | 'will-revert'
|
|
35
|
+
|
|
36
|
+
export interface GitOpsSyncPolicy {
|
|
37
|
+
tool: GitOpsOwnerTool
|
|
38
|
+
auto: boolean | null
|
|
39
|
+
selfHeal: boolean | null
|
|
40
|
+
prune: boolean | null
|
|
41
|
+
suspended: boolean | null
|
|
42
|
+
interval?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GitOpsWriteHelmRelease {
|
|
46
|
+
namespace: string
|
|
47
|
+
name: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GitOpsWriteOperatorOwner {
|
|
51
|
+
kind: string
|
|
52
|
+
name: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Wire types of POST /api/gitops/write-evidence -------------------------
|
|
56
|
+
|
|
57
|
+
export interface GitOpsFieldOwnerEvidence {
|
|
58
|
+
manager: string
|
|
59
|
+
operation: string
|
|
60
|
+
subresource?: string
|
|
61
|
+
tool?: 'argocd' | 'fluxcd' | 'helm'
|
|
62
|
+
approximate?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GitOpsPathEvidence {
|
|
66
|
+
path: string
|
|
67
|
+
error?: string
|
|
68
|
+
lastApplied: 'present' | 'absent' | 'no-annotation'
|
|
69
|
+
ownedBy: GitOpsFieldOwnerEvidence[]
|
|
70
|
+
/** A manager of the owner's controller owns the field. */
|
|
71
|
+
ownedByGitOps: boolean
|
|
72
|
+
approximate?: boolean
|
|
73
|
+
/** The path has a wildcard, and the source declares the field on some of
|
|
74
|
+
* the entries it matches (one container's image but not another's). */
|
|
75
|
+
partial?: boolean
|
|
76
|
+
/** The wildcard matches nothing in the live object (no init containers),
|
|
77
|
+
* so the write can't touch this path. */
|
|
78
|
+
empty?: boolean
|
|
79
|
+
/** `effective`: the owner is not expected to overwrite it. `comparison-only`: Argo
|
|
80
|
+
* ignoreDifferences without RespectIgnoreDifferences. `unevaluated`: a
|
|
81
|
+
* matching rule uses jqPathExpressions, which Radar doesn't evaluate, or
|
|
82
|
+
* covers only some of the entries a wildcard path matches. */
|
|
83
|
+
ignored?: 'effective' | 'comparison-only' | 'unevaluated'
|
|
84
|
+
ignoredBy?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface GitOpsWritePolicyEvidence {
|
|
88
|
+
tool: GitOpsOwnerTool
|
|
89
|
+
auto: boolean | null
|
|
90
|
+
selfHeal: boolean | null
|
|
91
|
+
prune: boolean | null
|
|
92
|
+
suspended: boolean | null
|
|
93
|
+
interval?: string
|
|
94
|
+
respectIgnoreDifferences?: boolean
|
|
95
|
+
serverSideApply?: boolean
|
|
96
|
+
replace?: boolean
|
|
97
|
+
/** HelmRelease `spec.driftDetection.mode`. */
|
|
98
|
+
driftDetection?: string
|
|
99
|
+
objectReconcile?: 'ignore' | 'if-not-present'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface GitOpsWriteEvidence {
|
|
103
|
+
uid: string
|
|
104
|
+
resourceVersion: string
|
|
105
|
+
owner: { kind: string; group?: string; namespace: string; name: string } | null
|
|
106
|
+
policy: GitOpsWritePolicyEvidence | null
|
|
107
|
+
policyError?: string
|
|
108
|
+
controllerOwner?: { apiVersion: string; kind: string; name: string }
|
|
109
|
+
/** The Argo CD Application's status lists the object among its resources. */
|
|
110
|
+
ownerTracksTarget?: boolean
|
|
111
|
+
paths: GitOpsPathEvidence[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface GitOpsWriteGuardInput {
|
|
117
|
+
target: GitOpsWriteTarget
|
|
118
|
+
owner: GitOpsOwnerRef | null
|
|
119
|
+
/** Native Helm release that manages the object when no GitOps tool does. */
|
|
120
|
+
helmRelease?: GitOpsWriteHelmRelease | null
|
|
121
|
+
/** A non-GitOps controller (e.g. a CNPG Cluster owning a Pod). Defaults to
|
|
122
|
+
* the evidence's controllerOwner. Only affects `delete`. */
|
|
123
|
+
operatorOwner?: GitOpsWriteOperatorOwner | null
|
|
124
|
+
/** Ownership or evidence is still loading. */
|
|
125
|
+
ownerPending?: boolean
|
|
126
|
+
/** The host could not determine ownership: one sentence saying why, shown as the reason. */
|
|
127
|
+
ownershipError?: string | null
|
|
128
|
+
/** The owner was matched by name only (an Argo CD tracking label without a
|
|
129
|
+
* namespace), so its policy can't waive an acknowledgment. */
|
|
130
|
+
ownerMatchedByName?: boolean
|
|
131
|
+
evidence?: GitOpsWriteEvidence | null
|
|
132
|
+
/** The evidence request failed. */
|
|
133
|
+
evidenceError?: string | null
|
|
134
|
+
writes: GitOpsWrite[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface GitOpsWriteAssessment {
|
|
138
|
+
write: GitOpsWrite
|
|
139
|
+
level: GitOpsWriteLevel
|
|
140
|
+
reason: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface GitOpsWriteGuard {
|
|
144
|
+
owner: GitOpsOwnerRef | null
|
|
145
|
+
helmRelease: GitOpsWriteHelmRelease | null
|
|
146
|
+
pending: boolean
|
|
147
|
+
level: GitOpsWriteLevel
|
|
148
|
+
perWrite: GitOpsWriteAssessment[]
|
|
149
|
+
summary: string
|
|
150
|
+
syncPolicy: GitOpsSyncPolicy | null
|
|
151
|
+
requiresAck: boolean
|
|
152
|
+
ownershipError: string | null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const LEVEL_RANK: Record<GitOpsWriteLevel, number> = {
|
|
156
|
+
none: 0,
|
|
157
|
+
info: 1,
|
|
158
|
+
'may-revert': 2,
|
|
159
|
+
'will-revert': 3,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function maxLevel(a: GitOpsWriteLevel, b: GitOpsWriteLevel): GitOpsWriteLevel {
|
|
163
|
+
return LEVEL_RANK[b] > LEVEL_RANK[a] ? b : a
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function gitOpsToolLabel(tool: GitOpsOwnerTool | 'helm'): string {
|
|
167
|
+
if (tool === 'argocd') return 'Argo CD'
|
|
168
|
+
if (tool === 'fluxcd') return 'Flux'
|
|
169
|
+
return 'Helm'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const OWNER_KIND_LABEL: Record<GitOpsOwnerRef['kind'], string> = {
|
|
173
|
+
applications: 'Application',
|
|
174
|
+
kustomizations: 'Kustomization',
|
|
175
|
+
helmreleases: 'HelmRelease',
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `Argo CD Application argocd/api` */
|
|
179
|
+
export function describeGitOpsOwner(owner: GitOpsOwnerRef): string {
|
|
180
|
+
const ref = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
181
|
+
return `${gitOpsToolLabel(owner.tool)} ${OWNER_KIND_LABEL[owner.kind]} ${ref}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `the Application`: the owner as reasons name it, after a headline that already named it in full. */
|
|
185
|
+
function ownerKindRef(owner: GitOpsOwnerRef): string {
|
|
186
|
+
return `the ${OWNER_KIND_LABEL[owner.kind]}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function capitalize(text: string): string {
|
|
190
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The tool named in acknowledgments: `Argo CD`, `Flux`, `Helm`. */
|
|
194
|
+
export function guardToolLabel(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>): string {
|
|
195
|
+
if (guard.owner) return gitOpsToolLabel(guard.owner.tool)
|
|
196
|
+
if (guard.helmRelease) return 'Helm'
|
|
197
|
+
return 'a GitOps tool'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Identifies what an acknowledgment was given for; it changes when the
|
|
201
|
+
* verdict, owner, reasons or the fields being written do. */
|
|
202
|
+
export function gitOpsWriteGuardKey(guard: GitOpsWriteGuard | undefined): string {
|
|
203
|
+
if (!guard || guard.pending) return ''
|
|
204
|
+
const owner = guard.owner ? `${guard.owner.kind}/${guard.owner.namespace ?? ''}/${guard.owner.name}` : guard.helmRelease ? `helm/${guard.helmRelease.namespace}/${guard.helmRelease.name}` : ''
|
|
205
|
+
return [guard.level, owner, guard.ownershipError ?? '', ...guard.perWrite.map((w) => `${w.write.scope}:${(w.write.paths ?? []).join(',')}:${w.level}:${w.reason}`)].join('|')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function canConfirmGitOpsWrite(guard: GitOpsWriteGuard | undefined, acked: boolean): boolean {
|
|
209
|
+
if (!guard || guard.pending) return false
|
|
210
|
+
return !guard.requiresAck || acked
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function gitOpsSyncPolicy(policy: GitOpsWritePolicyEvidence | null | undefined): GitOpsSyncPolicy | null {
|
|
214
|
+
if (!policy) return null
|
|
215
|
+
return {
|
|
216
|
+
tool: policy.tool,
|
|
217
|
+
auto: policy.auto,
|
|
218
|
+
selfHeal: policy.selfHeal,
|
|
219
|
+
prune: policy.prune,
|
|
220
|
+
suspended: policy.suspended,
|
|
221
|
+
...(policy.interval ? { interval: policy.interval } : {}),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The field paths to request evidence for. */
|
|
226
|
+
export function gitOpsWriteEvidencePaths(writes: GitOpsWrite[]): string[] {
|
|
227
|
+
const paths = new Set<string>()
|
|
228
|
+
for (const write of writes) {
|
|
229
|
+
if (write.scope === 'metadata' || write.scope === 'spec') {
|
|
230
|
+
for (const path of write.paths ?? []) paths.add(path)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...paths]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Classification
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
type Verdict = { level: GitOpsWriteLevel; reason: string }
|
|
241
|
+
|
|
242
|
+
interface Context {
|
|
243
|
+
input: GitOpsWriteGuardInput
|
|
244
|
+
owner: GitOpsOwnerRef
|
|
245
|
+
policy: GitOpsWritePolicyEvidence | null
|
|
246
|
+
policyUnknown: string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function nextSync(ctx: Context): string {
|
|
250
|
+
if (ctx.owner.tool === 'argocd') return 'the next sync'
|
|
251
|
+
const every = ctx.policy?.interval ? ` (every ${ctx.policy.interval})` : ''
|
|
252
|
+
return `the next reconcile${every}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// What happens to a field the source sets, under the owner's policy.
|
|
256
|
+
function declaredOutcome(ctx: Context): Verdict {
|
|
257
|
+
const { owner, policy } = ctx
|
|
258
|
+
if (!policy) {
|
|
259
|
+
return {
|
|
260
|
+
level: 'may-revert',
|
|
261
|
+
reason: `Radar couldn't read ${ownerKindRef(owner)}'s sync policy (${ctx.policyUnknown}); the next sync will overwrite it.`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (policy.suspended) {
|
|
265
|
+
return { level: 'may-revert', reason: `${capitalize(ownerKindRef(owner))} is suspended; resuming it will overwrite this change.` }
|
|
266
|
+
}
|
|
267
|
+
if (policy.selfHeal) {
|
|
268
|
+
return { level: 'will-revert', reason: `It will be overwritten at ${nextSync(ctx)}.` }
|
|
269
|
+
}
|
|
270
|
+
if (owner.kind === 'helmreleases') {
|
|
271
|
+
return {
|
|
272
|
+
level: 'may-revert',
|
|
273
|
+
reason: `Drift detection is ${policy.driftDetection || 'disabled'}, so it stays until the next Helm upgrade by Flux overwrites it.`,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (owner.tool === 'argocd' && policy.auto) {
|
|
277
|
+
return { level: 'may-revert', reason: 'Self-heal is off; the next sync will overwrite it.' }
|
|
278
|
+
}
|
|
279
|
+
return { level: 'may-revert', reason: 'Auto-sync is off; the next sync will overwrite it.' }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function evidenceNote(ev: GitOpsPathEvidence, owner: GitOpsOwnerRef): string {
|
|
283
|
+
const manager = ev.ownedBy.find((o) => o.tool === owner.tool && !o.subresource)?.manager
|
|
284
|
+
if (ev.ownedByGitOps && manager) return `${manager} ${ev.approximate ? 'manages' : 'owns'} this field`
|
|
285
|
+
return 'the last applied configuration sets this field'
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function classifyPath(ctx: Context, path: string | null): Verdict {
|
|
289
|
+
const { owner, policy } = ctx
|
|
290
|
+
const tool = gitOpsToolLabel(owner.tool)
|
|
291
|
+
const ev = path ? ctx.input.evidence?.paths.find((p) => p.path === path) : undefined
|
|
292
|
+
if (ev?.empty) return { level: 'none', reason: '' }
|
|
293
|
+
|
|
294
|
+
// Helm's drift exemptions only stop drift correction: the next upgrade's
|
|
295
|
+
// three-way merge still resets a field the chart renders.
|
|
296
|
+
const helm = owner.kind === 'helmreleases'
|
|
297
|
+
const helmExemption = helm
|
|
298
|
+
? policy?.objectReconcile === 'ignore'
|
|
299
|
+
? 'the object opts out of drift detection'
|
|
300
|
+
: ev?.ignored === 'effective'
|
|
301
|
+
? `drift detection ignores this field (${ev.ignoredBy ?? 'ignore rule'})`
|
|
302
|
+
: null
|
|
303
|
+
: null
|
|
304
|
+
// A change whose fields are unknown may be the one that removes the opt-out.
|
|
305
|
+
if (!helm && path && policy?.objectReconcile === 'ignore') {
|
|
306
|
+
return { level: 'info', reason: `This object opts out of ${tool} reconciliation, so a sync is not expected to overwrite it.` }
|
|
307
|
+
}
|
|
308
|
+
// Flux reads IfNotPresent from the desired manifest; the live annotation
|
|
309
|
+
// may have been added by hand or outlived a change in the source.
|
|
310
|
+
if (policy?.objectReconcile === 'if-not-present') {
|
|
311
|
+
return {
|
|
312
|
+
level: 'may-revert',
|
|
313
|
+
reason: `This object carries ${tool}'s IfNotPresent annotation, but Radar can't confirm the GitOps source declares it; if it doesn't, the next reconcile overwrites this change.`,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!helm && ev?.ignored === 'effective') {
|
|
317
|
+
return {
|
|
318
|
+
level: 'info',
|
|
319
|
+
reason: `${capitalize(ownerKindRef(owner))} ignores this field (${ev.ignoredBy ?? 'ignore rule'}), so a sync is not expected to overwrite it.`,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const declared = Boolean(ev && !ev.error && (ev.lastApplied === 'present' || ev.ownedByGitOps))
|
|
324
|
+
if (declared || policy?.replace) {
|
|
325
|
+
if (ev?.ignored === 'unevaluated') {
|
|
326
|
+
return {
|
|
327
|
+
level: 'may-revert',
|
|
328
|
+
reason: `Radar can't confirm ${ownerKindRef(owner)}'s ignore rule (${ev.ignoredBy ?? 'spec.ignoreDifferences'}) covers this field, so the next sync may overwrite it.`,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (ev?.ignored === 'comparison-only') {
|
|
332
|
+
return {
|
|
333
|
+
level: 'may-revert',
|
|
334
|
+
reason: `${capitalize(ownerKindRef(owner))} excludes this field from comparison (${ev.ignoredBy ?? 'ignoreDifferences'}) without RespectIgnoreDifferences, so self-heal won't react, but the next sync will overwrite it.`,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const outcome = helmExemption
|
|
338
|
+
? { level: 'may-revert' as const, reason: `Flux won't correct it as drift because ${helmExemption}, but the next Helm upgrade overwrites it.` }
|
|
339
|
+
: declaredOutcome(ctx)
|
|
340
|
+
// A wildcard the source only partly declares: which entries revert
|
|
341
|
+
// depends on which one the user changes.
|
|
342
|
+
if (ev?.partial && !policy?.replace && outcome.level === 'will-revert') {
|
|
343
|
+
return {
|
|
344
|
+
level: 'may-revert',
|
|
345
|
+
reason: `The GitOps source sets this for some containers but not others; the ones it sets will be overwritten at ${nextSync(ctx)}.`,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const why =
|
|
349
|
+
declared && ev
|
|
350
|
+
? `The GitOps source sets this field (${evidenceNote(ev, owner)}).`
|
|
351
|
+
: `${capitalize(ownerKindRef(owner))} syncs with Replace=true, which replaces the whole object.`
|
|
352
|
+
return { level: outcome.level, reason: `${why} ${outcome.reason}` }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const unknown = 'It may be overwritten on sync if the GitOps source declares this field.'
|
|
356
|
+
if (!path) {
|
|
357
|
+
return { level: 'may-revert', reason: `Radar doesn't know which fields this change touches. ${unknown}` }
|
|
358
|
+
}
|
|
359
|
+
if (!ev || ev.error) {
|
|
360
|
+
const cause = ev?.error || ctx.input.evidenceError
|
|
361
|
+
return {
|
|
362
|
+
level: 'may-revert',
|
|
363
|
+
reason: `Radar can't see which fields the GitOps source declares${cause ? ` (${cause})` : ''}. ${unknown}`,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
level: 'may-revert',
|
|
368
|
+
reason: `Radar found no record of the GitOps source setting this field, but that doesn't rule it out. ${unknown}`,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function classifyFieldWrite(ctx: Context, write: GitOpsWrite): Verdict {
|
|
373
|
+
const paths: Array<string | null> = write.paths?.length ? write.paths : [null]
|
|
374
|
+
let worst = classifyPath(ctx, paths[0])
|
|
375
|
+
for (const path of paths.slice(1)) {
|
|
376
|
+
const verdict = classifyPath(ctx, path)
|
|
377
|
+
if (LEVEL_RANK[verdict.level] > LEVEL_RANK[worst.level]) worst = verdict
|
|
378
|
+
}
|
|
379
|
+
return worst
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function classifyDelete(ctx: Context): Verdict {
|
|
383
|
+
const { owner, policy } = ctx
|
|
384
|
+
const tool = gitOpsToolLabel(owner.tool)
|
|
385
|
+
if (!policy) {
|
|
386
|
+
return {
|
|
387
|
+
level: 'may-revert',
|
|
388
|
+
reason: `Radar couldn't read ${ownerKindRef(owner)}'s sync policy (${ctx.policyUnknown}); the next sync will recreate it.`,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (policy.objectReconcile === 'ignore') {
|
|
392
|
+
if (owner.kind === 'helmreleases') {
|
|
393
|
+
return { level: 'may-revert', reason: "Flux drift detection skips this object, but the next Helm upgrade recreates it." }
|
|
394
|
+
}
|
|
395
|
+
return { level: 'info', reason: `This object opts out of ${tool} reconciliation, so it is not expected to be recreated.` }
|
|
396
|
+
}
|
|
397
|
+
if (policy.suspended) {
|
|
398
|
+
return { level: 'may-revert', reason: `${capitalize(ownerKindRef(owner))} is suspended; resuming it will recreate it.` }
|
|
399
|
+
}
|
|
400
|
+
if (policy.selfHeal) {
|
|
401
|
+
return { level: 'will-revert', reason: `${tool} will recreate it at ${nextSync(ctx)}.` }
|
|
402
|
+
}
|
|
403
|
+
return { level: 'may-revert', reason: 'The next sync will recreate it.' }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function operatorOwnerOf(input: GitOpsWriteGuardInput): GitOpsWriteOperatorOwner | null {
|
|
407
|
+
if (input.operatorOwner !== undefined) return input.operatorOwner
|
|
408
|
+
const controller = input.evidence?.controllerOwner
|
|
409
|
+
return controller ? { kind: controller.kind, name: controller.name } : null
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function classifyWrite(input: GitOpsWriteGuardInput, owner: GitOpsOwnerRef | null, write: GitOpsWrite): Verdict {
|
|
413
|
+
if (write.scope === 'status') {
|
|
414
|
+
return { level: 'none', reason: 'This writes controller status; ordinary GitOps sync does not manage these fields.' }
|
|
415
|
+
}
|
|
416
|
+
if (write.scope === 'delete') {
|
|
417
|
+
const operator = operatorOwnerOf(input)
|
|
418
|
+
if (operator) {
|
|
419
|
+
return { level: 'info', reason: `${operator.kind} ${operator.name} will recreate this ${input.target.kind}.` }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (owner) {
|
|
423
|
+
const tool = gitOpsToolLabel(owner.tool)
|
|
424
|
+
if (write.scope === 'create-child') {
|
|
425
|
+
return { level: 'info', reason: `A new object isn't in the GitOps source, so ${tool} is not expected to revert it.` }
|
|
426
|
+
}
|
|
427
|
+
const ctx: Context = {
|
|
428
|
+
input,
|
|
429
|
+
owner,
|
|
430
|
+
policy: input.evidence?.policy ?? null,
|
|
431
|
+
policyUnknown: input.evidence?.policyError || input.evidenceError || 'unavailable',
|
|
432
|
+
}
|
|
433
|
+
return write.scope === 'delete' ? classifyDelete(ctx) : classifyFieldWrite(ctx, write)
|
|
434
|
+
}
|
|
435
|
+
if (input.helmRelease) {
|
|
436
|
+
if (write.scope === 'create-child') {
|
|
437
|
+
return { level: 'info', reason: "A new object isn't part of the release, so a Helm upgrade is not expected to revert it." }
|
|
438
|
+
}
|
|
439
|
+
if (write.scope === 'delete') {
|
|
440
|
+
return { level: 'may-revert', reason: 'The next Helm upgrade or rollback will recreate it.' }
|
|
441
|
+
}
|
|
442
|
+
const helmOwned = (write.paths ?? []).some((path) =>
|
|
443
|
+
input.evidence?.paths.find((p) => p.path === path)?.ownedBy.some((o) => o.tool === 'helm'),
|
|
444
|
+
)
|
|
445
|
+
return {
|
|
446
|
+
level: 'may-revert',
|
|
447
|
+
reason: helmOwned
|
|
448
|
+
? 'Helm set this field; the next Helm upgrade or rollback will overwrite it.'
|
|
449
|
+
: 'If the chart sets this field, the next Helm upgrade or rollback will overwrite it.',
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (input.ownershipError && write.scope !== 'create-child') {
|
|
453
|
+
return {
|
|
454
|
+
level: 'may-revert',
|
|
455
|
+
reason: input.ownershipError,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { level: 'none', reason: '' }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>, level: GitOpsWriteLevel): string {
|
|
462
|
+
const who = guard.owner
|
|
463
|
+
? describeGitOpsOwner(guard.owner)
|
|
464
|
+
: guard.helmRelease
|
|
465
|
+
? `Helm release ${guard.helmRelease.namespace}/${guard.helmRelease.name}`
|
|
466
|
+
: 'A GitOps tool'
|
|
467
|
+
if (level === 'will-revert') return `${who} will revert this change.`
|
|
468
|
+
if (level === 'may-revert') return `${who} may revert this change.`
|
|
469
|
+
if (level === 'info') return guard.owner || guard.helmRelease ? `Managed by ${who}.` : ''
|
|
470
|
+
return ''
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function gitOpsWriteHeadline(guard: GitOpsWriteGuard): string {
|
|
474
|
+
return headline(guard, guard.level)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function evaluateGitOpsWriteGuard(input: GitOpsWriteGuardInput): GitOpsWriteGuard {
|
|
478
|
+
const owner = input.owner ?? null
|
|
479
|
+
const helmRelease = owner ? null : (input.helmRelease ?? null)
|
|
480
|
+
const ownershipError = owner || helmRelease ? null : (input.ownershipError ?? null)
|
|
481
|
+
const normalized: GitOpsWriteGuardInput = { ...input, owner, helmRelease, ownershipError }
|
|
482
|
+
|
|
483
|
+
const perWrite = input.writes.map((write) => {
|
|
484
|
+
const verdict = classifyWrite(normalized, owner, write)
|
|
485
|
+
if (owner && input.ownerMatchedByName && verdict.level === 'info' && write.scope !== 'create-child') {
|
|
486
|
+
return {
|
|
487
|
+
write,
|
|
488
|
+
level: 'may-revert' as const,
|
|
489
|
+
reason: `${verdict.reason} Radar matched ${ownerKindRef(owner)} by name only, so it can't confirm this.`,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return { write, ...verdict }
|
|
493
|
+
})
|
|
494
|
+
const level = perWrite.reduce<GitOpsWriteLevel>((max, entry) => maxLevel(max, entry.level), 'none')
|
|
495
|
+
const top = perWrite.find((entry) => entry.level === level)
|
|
496
|
+
const summary = level === 'none' ? '' : [headline({ owner, helmRelease }, level), top?.reason ?? ''].filter(Boolean).join(' ')
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
owner,
|
|
500
|
+
helmRelease,
|
|
501
|
+
pending: Boolean(input.ownerPending),
|
|
502
|
+
level,
|
|
503
|
+
perWrite,
|
|
504
|
+
summary,
|
|
505
|
+
syncPolicy: owner ? gitOpsSyncPolicy(input.evidence?.policy) : null,
|
|
506
|
+
requiresAck: level === 'will-revert' || level === 'may-revert',
|
|
507
|
+
ownershipError,
|
|
508
|
+
}
|
|
509
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './api-resources'
|
|
|
17
17
|
export * from './skeleton-yaml'
|
|
18
18
|
export * from './k8s-errors'
|
|
19
19
|
export * from './parse-go-time'
|
|
20
|
+
export * from './gitops-write-guard'
|
|
20
21
|
export * from './view-transition'
|
|
21
22
|
export * from './validators'
|
|
22
23
|
export * from './gitops-owner'
|
|
@@ -31,3 +32,4 @@ export * from './bulk-workload-actions'
|
|
|
31
32
|
export * from './inClusterConsent'
|
|
32
33
|
export * from './workflow-execution'
|
|
33
34
|
export * from './network-policy'
|
|
35
|
+
export * from './resource-refs'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ResourceRef } from '../types'
|
|
2
|
+
|
|
3
|
+
interface IngressClassParametersReference {
|
|
4
|
+
name: string
|
|
5
|
+
kind: string
|
|
6
|
+
apiGroup?: string
|
|
7
|
+
scope?: 'Cluster' | 'Namespace'
|
|
8
|
+
namespace?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** IngressClass parameters default to cluster scope and the core API group.
|
|
12
|
+
* A namespaced reference requires its own namespace; the class has none.
|
|
13
|
+
*/
|
|
14
|
+
export function ingressClassParametersResourceRef(ref: IngressClassParametersReference): ResourceRef | null {
|
|
15
|
+
if (ref.scope === 'Namespace' && !ref.namespace) return null
|
|
16
|
+
return {
|
|
17
|
+
kind: ref.kind,
|
|
18
|
+
group: ref.apiGroup ?? '',
|
|
19
|
+
namespace: ref.scope === 'Namespace' ? ref.namespace! : '',
|
|
20
|
+
name: ref.name,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -305,6 +305,19 @@ describe('refToSelectedResource', () => {
|
|
|
305
305
|
})
|
|
306
306
|
})
|
|
307
307
|
|
|
308
|
+
test('resolves AWS IngressClassParams without discovery', () => {
|
|
309
|
+
expect(refToSelectedResource({ kind: 'IngressClassParams', name: 'alb', group: 'elbv2.k8s.aws' })).toEqual({
|
|
310
|
+
kind: 'ingressclassparams',
|
|
311
|
+
name: 'alb',
|
|
312
|
+
namespace: '',
|
|
313
|
+
group: 'elbv2.k8s.aws',
|
|
314
|
+
})
|
|
315
|
+
expect(knownKindForPluralWithGroup('ingressclassparams', 'elbv2.k8s.aws')).toBe('IngressClassParams')
|
|
316
|
+
// Hosts without discovery fold the plural back to a Kind and re-pluralize it.
|
|
317
|
+
expect(pluralToKind('ingressclassparams')).toBe('IngressClassParams')
|
|
318
|
+
expect(kindToPlural(pluralToKind('ingressclassparams'))).toBe('ingressclassparams')
|
|
319
|
+
})
|
|
320
|
+
|
|
308
321
|
test('normalizes an omitted namespace for cluster-scoped references', () => {
|
|
309
322
|
expect(refToSelectedResource({ kind: 'NodePool', name: 'spot' })).toEqual({
|
|
310
323
|
kind: 'nodepools',
|
package/src/utils/navigation.ts
CHANGED
|
@@ -41,6 +41,10 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
|
|
|
41
41
|
clusterrolebindings: 'ClusterRoleBinding',
|
|
42
42
|
serviceaccounts: 'ServiceAccount',
|
|
43
43
|
networkpolicies: 'NetworkPolicy',
|
|
44
|
+
// AWS Load Balancer Controller's IngressClass parameters. The Kind is
|
|
45
|
+
// singular and plural at once, so English rules cannot recover it in hosts
|
|
46
|
+
// that never load discovery.
|
|
47
|
+
ingressclassparams: 'IngressClassParams',
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
|
|
@@ -54,6 +58,7 @@ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
|
|
|
54
58
|
'metrics.k8s.io/pods': 'PodMetrics',
|
|
55
59
|
'metrics.k8s.io/nodes': 'NodeMetrics',
|
|
56
60
|
'scheduling.k8s.io/podgroups': 'PodGroup',
|
|
61
|
+
'elbv2.k8s.aws/ingressclassparams': 'IngressClassParams',
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// Dynamic map built from API discovery — populated by initNavigationMap().
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { getNodeLifecycle } from './node-lifecycle'
|
|
5
|
+
import { getNodeConditions, getNodeStatus } from '../components/resources/resource-utils'
|
|
6
|
+
|
|
7
|
+
const fixture = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../pkg/health/testdata/node_lifecycle.json', import.meta.url)), 'utf8'))
|
|
8
|
+
|
|
9
|
+
describe('node lifecycle shared Go/TS scenarios', () => {
|
|
10
|
+
for (const vector of fixture.vectors) {
|
|
11
|
+
it(vector.name, () => {
|
|
12
|
+
expect(getNodeLifecycle(vector.node, Date.parse(fixture.now))).toEqual({
|
|
13
|
+
label: vector.label, level: vector.level, readinessFailed: vector.readinessFailed, delayed: vector.delayed,
|
|
14
|
+
removing: vector.removing, actor: vector.actor, startedAt: vector.startedAt ? Date.parse(vector.startedAt) : undefined, problems: vector.problems,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
it('handles the actions bar loading before node data arrives', () => {
|
|
19
|
+
expect(getNodeLifecycle(undefined)).toMatchObject({ level: 'unknown', removing: false })
|
|
20
|
+
})
|
|
21
|
+
it('keeps badges and table warning icons calm for expected readiness loss', () => {
|
|
22
|
+
vi.useFakeTimers()
|
|
23
|
+
vi.setSystemTime(new Date(fixture.now))
|
|
24
|
+
try {
|
|
25
|
+
const node = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-Unknown').node
|
|
26
|
+
expect(getNodeStatus(node)).toMatchObject({ text: 'Removing (cluster autoscaler)', level: 'neutral' })
|
|
27
|
+
expect(getNodeConditions(node).problems).toEqual([])
|
|
28
|
+
expect(getNodeConditions(node).readinessLabel).toBe('Readiness unknown during removal')
|
|
29
|
+
const shutdown = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-False').node
|
|
30
|
+
expect(getNodeConditions(shutdown).readinessLabel).toBe('Not ready (expected during removal)')
|
|
31
|
+
expect(getNodeConditions({ status: { conditions: [] } })).toMatchObject({ healthy: false, readinessLabel: 'Readiness unknown' })
|
|
32
|
+
const failed = fixture.vectors.find((vector: any) => vector.name === 'preexisting-failure').node
|
|
33
|
+
expect(getNodeConditions(failed).problems).toContain('NotReady')
|
|
34
|
+
} finally { vi.useRealTimers() }
|
|
35
|
+
})
|
|
36
|
+
})
|