@skyhook-io/k8s-ui 1.18.0 → 1.18.2
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 +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type GitOpsPathEvidence,
|
|
10
10
|
type GitOpsWrite,
|
|
11
11
|
type GitOpsWriteEvidence,
|
|
12
|
+
gitOpsWriteHeadline,
|
|
12
13
|
type GitOpsWriteGuardInput,
|
|
13
14
|
type GitOpsWritePolicyEvidence,
|
|
14
15
|
} from './gitops-write-guard'
|
|
@@ -399,3 +400,334 @@ describe('copy', () => {
|
|
|
399
400
|
}
|
|
400
401
|
})
|
|
401
402
|
})
|
|
403
|
+
|
|
404
|
+
describe('manager answer composition', () => {
|
|
405
|
+
const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
|
|
406
|
+
const b = { ...a, name: 'b' }
|
|
407
|
+
const subject = { kind: 'Pod', namespace: 'db', name: 'pg-1' }
|
|
408
|
+
const parent = { kind: 'Deployment', group: 'apps', namespace: 'db', name: 'pg' }
|
|
409
|
+
const claim = { manager: a, source: 'argo-instance-label' as const, check: 'omitted' as const, weak: true }
|
|
410
|
+
const shared: import('../types/core').ManagerAnswer = { state: 'shared', lead: a, direct: { object: subject, recorders: [a, b] } }
|
|
411
|
+
const via: import('../types/core').ManagerAnswer = { state: 'listed', lead: b, direct: { object: subject }, via: { object: parent, recorders: [b] } }
|
|
412
|
+
const run = (manager: import('../types/core').ManagerAnswer, writes = [hibernate], extra: Partial<GitOpsWriteGuardInput> = {}) =>
|
|
413
|
+
guard({ manager, writes, evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'effective' })]), ...extra })
|
|
414
|
+
|
|
415
|
+
it('disagree field writes warn with both names despite a self-heal policy', () => {
|
|
416
|
+
const g = run({ state: 'disagree', direct: { object: subject, recorders: [b], claims: [claim] } })
|
|
417
|
+
expect(g.level).toBe('may-revert')
|
|
418
|
+
expect(g.summary).toContain('Application argocd/a and Application argocd/b')
|
|
419
|
+
expect(g.owner).toBeNull()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('shared field writes cannot use one candidate’s ignore rule or policy exemption', () => {
|
|
423
|
+
const g = run(shared)
|
|
424
|
+
expect(g.level).toBe('may-revert')
|
|
425
|
+
expect(g.requiresAck).toBe(true)
|
|
426
|
+
expect(g.summary).toContain('argocd/a')
|
|
427
|
+
expect(g.summary).toContain('argocd/b')
|
|
428
|
+
expect(g.syncPolicy).toBeNull()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('several claims without a lead warn in the plural', () => {
|
|
432
|
+
const g = run({ state: 'claimed', direct: { object: subject, claims: [claim, { ...claim, manager: b }] } })
|
|
433
|
+
expect(g.level).toBe('may-revert')
|
|
434
|
+
expect(g.summary).toContain('argocd/a and Application argocd/b')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it.each(['metadata', 'spec', 'delete'] as const)('via without an active direct claim makes %s informational without acknowledgment', scope => {
|
|
438
|
+
const g = run({ ...via, direct: { object: subject, claims: [{ ...claim, copied: true }] } }, [{ scope, paths: [HIBERNATE] }])
|
|
439
|
+
expect(g.level).toBe('info')
|
|
440
|
+
expect(g.requiresAck).toBe(false)
|
|
441
|
+
expect(g.summary).toContain('Deployment db/pg')
|
|
442
|
+
expect(g.summary).toContain('Application argocd/b')
|
|
443
|
+
if (scope === 'delete') expect(g.summary).toContain('controller recreates it from')
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('native Helm annotations do not compete with a GitOps ancestor', () => {
|
|
447
|
+
const helm = { kind: 'HelmRelease', group: '', namespace: 'db', name: 'chart' }
|
|
448
|
+
const g = run({ ...via, direct: { object: subject, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording', weak: true }] } })
|
|
449
|
+
expect(g.level).toBe('info')
|
|
450
|
+
expect(g.requiresAck).toBe(false)
|
|
451
|
+
expect(g.summary).not.toContain('HelmRelease')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it.each(['disagree', 'shared', 'claimed'] as const)('%s deletes without a lead warn with every candidate', state => {
|
|
455
|
+
const direct = state === 'claimed' ? { object: subject, claims: [claim, { ...claim, manager: b }] } : { object: subject, recorders: state === 'shared' ? [a, b] : [b], claims: [claim] }
|
|
456
|
+
const g = run({ state, direct }, [{ scope: 'delete' }])
|
|
457
|
+
expect(g.level).toBe('may-revert')
|
|
458
|
+
expect(g.summary).toContain('Application argocd/a')
|
|
459
|
+
expect(g.summary).toContain('Application argocd/b')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('via with a weak non-copied direct claim keeps the field warning', () => {
|
|
463
|
+
const g = run({ ...via, direct: { object: subject, claims: [claim] } })
|
|
464
|
+
expect(g.level).toBe('may-revert')
|
|
465
|
+
expect(g.requiresAck).toBe(true)
|
|
466
|
+
expect(g.summary).toContain('argocd/a')
|
|
467
|
+
expect(g.summary).toContain('Deployment db/pg')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it.each([shared, via])('status writes remain none', manager => {
|
|
471
|
+
expect(run(manager, [{ scope: 'status' }]).level).toBe('none')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('shared create-child stays informational', () => {
|
|
475
|
+
expect(run(shared, [{ scope: 'create-child' }]).level).toBe('info')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('operator-owned delete stays informational', () => {
|
|
479
|
+
expect(run(shared, [{ scope: 'delete' }], { operatorOwner: { kind: 'Cluster', name: 'pg' } }).level).toBe('info')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it.each([shared, via])('empty wildcard stays none', manager => {
|
|
483
|
+
expect(run(manager, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ empty: true })]) }).level).toBe('none')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('a single agreeing candidate with declared field evidence can still prove will-revert', () => {
|
|
487
|
+
const manager = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const }] } }
|
|
488
|
+
expect(run(manager, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) }).level).toBe('will-revert')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// A list alone can name an object its manager never syncs: a copy carrying
|
|
492
|
+
// another object's tracking ID, a CRD a HelmRelease skips.
|
|
493
|
+
it.each([hibernate, { scope: 'delete' as const }])('a list without metadata naming its manager promises no revert (%o)', write => {
|
|
494
|
+
const manager = { state: 'listed' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: b, source: 'argo-tracking-id' as const, check: 'missing' as const, copied: true }] } }
|
|
495
|
+
const g = run(manager, [write], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
496
|
+
expect(g.level).toBe('may-revert')
|
|
497
|
+
expect(g.summary).toContain("Radar can't confirm Application argocd/a applies this object")
|
|
498
|
+
// The field evidence stays, behind the condition.
|
|
499
|
+
expect(g.summary).toMatch(/If it does: .+/)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('a parent the managers disagree on says which names it and which lists it', () => {
|
|
503
|
+
const g = run({ state: 'disagree', direct: { object: subject }, via: { object: parent, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } })
|
|
504
|
+
expect(g.level).toBe('info')
|
|
505
|
+
expect(g.summary).toContain('whose metadata names Application argocd/a, while Application argocd/b lists it in its status')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// A CronJob's Job runs once: deleting it, even one created by hand, gets no
|
|
509
|
+
// replacement, whatever manages the CronJob.
|
|
510
|
+
it("a CronJob's Job is not said to be recreated", () => {
|
|
511
|
+
const cron = { kind: 'CronJob', group: 'batch', namespace: 'db', name: 'backup' }
|
|
512
|
+
const g = run({ state: 'agree', lead: a, direct: { object: subject }, via: { object: cron, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }, [{ scope: 'delete' }], { operatorOwner: { kind: 'CronJob', name: 'backup' } })
|
|
513
|
+
expect(g.level).toBe('info')
|
|
514
|
+
expect(g.summary).toContain('nothing is expected to recreate it')
|
|
515
|
+
expect(g.summary).toContain('CronJob db/backup can still create new ones on its schedule')
|
|
516
|
+
expect(g.summary).not.toContain('recreates it')
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
// A newer Radar's state with no lead, or one candidate of a kind this UI
|
|
520
|
+
// can't open, still warns.
|
|
521
|
+
it.each([
|
|
522
|
+
['an unfamiliar state without a lead', { state: 'future-state' as never, direct: { object: subject, recorders: [a] } }],
|
|
523
|
+
['an unfamiliar manager kind', { state: 'claimed' as const, lead: { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }, direct: { object: subject, claims: [{ manager: { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }, source: 'argo-tracking-id' as never, check: 'omitted' as const }] } }],
|
|
524
|
+
])('%s still warns on field writes and deletes', (_, manager) => {
|
|
525
|
+
for (const write of [hibernate, { scope: 'delete' as const }]) {
|
|
526
|
+
const g = run(manager, [write])
|
|
527
|
+
expect(g.level).toBe('may-revert')
|
|
528
|
+
expect(g.requiresAck).toBe(true)
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('a chain end this UI does not know keeps the conservative warning', () => {
|
|
533
|
+
const g = run({ state: 'unknown', direct: { object: subject }, chainEnd: 'future-stop' as never }, [hibernate])
|
|
534
|
+
expect(g.level).toBe('may-revert')
|
|
535
|
+
expect(g.requiresAck).toBe(true)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('an agreeing answer whose manager kind this UI does not know still warns', () => {
|
|
539
|
+
const stack = { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }
|
|
540
|
+
for (const write of [hibernate, { scope: 'delete' as const }]) {
|
|
541
|
+
const g = run({ state: 'agree', lead: stack, direct: { object: subject, recorders: [stack], claims: [{ manager: stack, source: 'argo-tracking-id', check: 'recorded' }] } }, [write])
|
|
542
|
+
expect(g.level).toBe('may-revert')
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
// A fallback via whose walk stopped at an unreadable owner may have missed
|
|
547
|
+
// a manager above it: a delete isn't just informational.
|
|
548
|
+
it('a delete through a fallback via with an unread chain keeps the warning', () => {
|
|
549
|
+
const g = run({ state: 'claimed', lead: a, direct: { object: subject }, via: { object: parent, claims: [{ manager: a, source: 'argo-instance-label', check: 'omitted', weak: true }] }, chainEnd: 'unfollowed', stop: { at: parent, state: 'unknown' } }, [{ scope: 'delete' }])
|
|
550
|
+
expect(g.level).toBe('may-revert')
|
|
551
|
+
expect(g.requiresAck).toBe(true)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('a state this UI does not know is read as claimed', () => {
|
|
555
|
+
const g = run({ state: 'future-state' as never, lead: a, direct: { object: subject, recorders: [a] } }, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
556
|
+
expect(g.level).toBe('may-revert')
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
// The schedule belongs to the Job's own CronJob, even when the answer came
|
|
560
|
+
// from an ancestor above it.
|
|
561
|
+
it("names the Job's own CronJob for future runs, not the answering ancestor", () => {
|
|
562
|
+
const op = { kind: 'Widget', group: 'example.io', namespace: 'db', name: 'pg' }
|
|
563
|
+
const g = run({ state: 'agree', lead: a, direct: { object: subject }, via: { object: op, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }, [{ scope: 'delete' }], { operatorOwner: { kind: 'CronJob', name: 'backup' } })
|
|
564
|
+
expect(g.summary).toContain('but CronJob ')
|
|
565
|
+
expect(g.summary).toContain('backup can still create new ones on its schedule')
|
|
566
|
+
expect(g.summary).not.toContain('Widget db/pg can still')
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('an informational note on a list-only answer says listed, not managed', () => {
|
|
570
|
+
const g = run({ state: 'listed', lead: a, direct: { object: subject, recorders: [a] } }, [{ scope: 'create-child' }])
|
|
571
|
+
expect(g.level).toBe('info')
|
|
572
|
+
expect(gitOpsWriteHeadline(g)).toBe('Listed by Argo CD Application argocd/a.')
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('a parent the metadata alone names is not said to be managed', () => {
|
|
576
|
+
const g = run({ state: 'claimed', lead: a, direct: { object: subject }, via: { object: parent, claims: [{ manager: a, source: 'argo-tracking-id', check: 'missing' }] } })
|
|
577
|
+
expect(g.level).toBe('info')
|
|
578
|
+
expect(g.summary).toContain('whose metadata names Application argocd/a')
|
|
579
|
+
expect(g.summary).not.toContain('manages')
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('an adopted Helm release keeps its upgrade risk past an Argo CD ignore rule', () => {
|
|
583
|
+
const helm = { kind: 'HelmRelease', group: '', namespace: 'db', name: 'chart' }
|
|
584
|
+
const manager = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [
|
|
585
|
+
{ manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const },
|
|
586
|
+
{ manager: helm, source: 'helm-release' as const, check: 'not-recording' as const },
|
|
587
|
+
] } }
|
|
588
|
+
const g = run(manager)
|
|
589
|
+
expect(g.level).toBe('may-revert')
|
|
590
|
+
expect(g.summary).toContain('Helm release db/chart')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
const agree = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const }] } }
|
|
594
|
+
|
|
595
|
+
it('a refused live answer drops the cached answer and the owner hint', () => {
|
|
596
|
+
const g = run(agree, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'effective' })], { managerUnavailable: 'objects_changed' }) })
|
|
597
|
+
expect(g.level).toBe('may-revert')
|
|
598
|
+
expect(g.owner).toBeNull()
|
|
599
|
+
expect(g.summary).toContain("Radar couldn't check what manages this resource right now (the read failed)")
|
|
600
|
+
expect(g.summary).not.toContain('objects_changed')
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it.each([
|
|
604
|
+
['kind_sync_pending', '(still loading)', true],
|
|
605
|
+
['read_error', '(the read failed)', true],
|
|
606
|
+
['user_forbidden', '(you may not list it)', false],
|
|
607
|
+
// Spellings an older Radar sends read as the code they became.
|
|
608
|
+
['kind_sync_failed', '(loading is overdue)', true],
|
|
609
|
+
['rbac_denied', '(you may not list it)', false],
|
|
610
|
+
])('a refused live answer explains %s in words', (code, words, reopen) => {
|
|
611
|
+
const g = run(agree, [hibernate], { evidence: evidence(selfHeal, [], { managerUnavailable: code }) })
|
|
612
|
+
expect(g.summary).toContain(words)
|
|
613
|
+
expect(g.summary).not.toContain(code)
|
|
614
|
+
expect(g.summary.includes('Close and reopen this dialog to check again.')).toBe(reopen)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it("a failed request keeps the request's own message", () => {
|
|
618
|
+
const g = run(agree, [hibernate], { evidence: undefined, evidenceError: 'Request timed out' })
|
|
619
|
+
expect(g.summary).toContain('(Request timed out)')
|
|
620
|
+
expect(g.summary).not.toContain('reopen')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("a failed live check doesn't keep a cached via answer's reassurance", () => {
|
|
624
|
+
const g = run(via, [hibernate], { evidence: undefined, evidenceError: 'forbidden' })
|
|
625
|
+
expect(g.level).toBe('may-revert')
|
|
626
|
+
expect(g.requiresAck).toBe(true)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('a failed live check of a cached none stays quiet', () => {
|
|
630
|
+
const g = run({ state: 'none', direct: { object: subject } }, [hibernate], { evidence: undefined, evidenceError: 'timeout' })
|
|
631
|
+
expect(g.level).toBe('none')
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it.each(['unknown', 'none'] as const)('server %s replaces client owner, Helm and manager inputs', state => {
|
|
635
|
+
const negative = { state, direct: { object: subject } }
|
|
636
|
+
const g = run(shared, [hibernate], { helmRelease: { namespace: 'db', name: 'old' }, evidence: evidence(selfHeal, [], { manager: negative }) })
|
|
637
|
+
expect(g.level).toBe('none')
|
|
638
|
+
expect(g.owner).toBeNull()
|
|
639
|
+
expect(g.helmRelease).toBeNull()
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('a stopped ancestor read retains conservative ownershipError behavior', () => {
|
|
643
|
+
const g = run({ state: 'unknown', direct: { object: subject }, stop: { at: subject, state: 'unknown', owner: parent } })
|
|
644
|
+
expect(g.level).toBe('may-revert')
|
|
645
|
+
expect(g.ownershipError).toContain("couldn't follow")
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// A gone or replaced owner ends the chain with an answer: nothing above it
|
|
649
|
+
// was missed, so an object no manager names gets no warning.
|
|
650
|
+
it.each(['gone', 'replaced'] as const)('a walk ended by a %s owner is not an unread chain', state => {
|
|
651
|
+
const g = run({ state: 'none', direct: { object: subject }, chainEnd: 'unfollowed', stop: { at: subject, state, owner: parent } })
|
|
652
|
+
expect(g.level).toBe('none')
|
|
653
|
+
expect(g.ownershipError).toBeNull()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('a stopped walk with a weak claim cannot use that candidate’s exemption', () => {
|
|
657
|
+
const g = run({ state: 'claimed', lead: a, direct: { object: subject, claims: [claim] }, stop: { at: subject, state: 'unknown', owner: parent } })
|
|
658
|
+
expect(g.level).toBe('may-revert')
|
|
659
|
+
expect(g.ownershipError).toContain("couldn't follow")
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('a failed target read retains conservative ownershipError behavior', () => {
|
|
663
|
+
const g = run({ state: 'unknown', direct: { object: subject } }, [hibernate], { ownershipError: 'Target read failed' })
|
|
664
|
+
expect(g.level).toBe('may-revert')
|
|
665
|
+
expect(g.ownershipError).toBe('Target read failed')
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('absent manager preserves legacy evidence verdicts', () => {
|
|
669
|
+
for (const policy of [selfHeal, manual, null]) {
|
|
670
|
+
const input = { writes: [hibernate], evidence: evidence(policy, [pathEvidence({ lastApplied: 'present' })]) }
|
|
671
|
+
expect(guard({ ...input, manager: undefined })).toEqual(guard(input))
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
// A ReplicaSet carries its Helm-installed Deployment's release annotations;
|
|
678
|
+
// they are the Deployment's, so the ReplicaSet gets the generated-from note.
|
|
679
|
+
it('a ReplicaSet of a plain Helm Deployment is generated from it, not claimed', () => {
|
|
680
|
+
const rs = { kind: 'ReplicaSet', group: 'apps', namespace: 'db', name: 'web-1' }
|
|
681
|
+
const release = { kind: 'HelmRelease', namespace: 'db', name: 'web' }
|
|
682
|
+
const g = guard({
|
|
683
|
+
target: rs,
|
|
684
|
+
manager: { state: 'claimed', lead: release,
|
|
685
|
+
direct: { object: rs, claims: [{ manager: release, source: 'helm-release', check: 'not-recording', weak: true }] },
|
|
686
|
+
via: { object: { kind: 'Deployment', group: 'apps', namespace: 'db', name: 'web' }, claims: [{ manager: release, source: 'helm-release', check: 'not-recording' }] } },
|
|
687
|
+
writes: [hibernate],
|
|
688
|
+
})
|
|
689
|
+
expect(g.level).toBe('info')
|
|
690
|
+
expect(g.requiresAck).toBe(false)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
describe('unconfirmed manager boundaries', () => {
|
|
694
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'candidate' }
|
|
695
|
+
const claimed: import('../types/core').ManagerAnswer = { state: 'claimed', lead: app, direct: { object: target, claims: [{ manager: app, source: 'argo-tracking-id', check: 'omitted' }] } }
|
|
696
|
+
it.each(['omitted', 'remote', 'unreadable', 'withheld', 'missing'] as const)('%s cannot prove a revert or waive acknowledgment', check => {
|
|
697
|
+
const manager = { ...claimed, direct: { object: target, claims: [{ ...claimed.direct.claims![0], check }] } }
|
|
698
|
+
for (const ignored of [undefined, 'effective'] as const) {
|
|
699
|
+
const g = guard({ manager, writes: [hibernate, { scope: 'delete' }], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored })]) })
|
|
700
|
+
expect(g.perWrite.map(w => w.level)).toEqual(['may-revert', 'may-revert'])
|
|
701
|
+
expect(g.requiresAck).toBe(true)
|
|
702
|
+
expect(g.syncPolicy).toBeNull()
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
// An opt-out on the target's own annotations holds whether or not the
|
|
706
|
+
// claimed manager's list names it.
|
|
707
|
+
it('an unconfirmed claim keeps an opt-out on the target itself', () => {
|
|
708
|
+
const g = guard({ manager: claimed, writes: [hibernate, { scope: 'delete' }], evidence: evidence({ ...selfHeal, objectReconcile: 'ignore' }, [pathEvidence({ lastApplied: 'present' })]) })
|
|
709
|
+
expect(g.perWrite.map(w => w.level)).toEqual(['info', 'info'])
|
|
710
|
+
expect(g.requiresAck).toBe(false)
|
|
711
|
+
})
|
|
712
|
+
// A tracking ID and an instance label on one Application are one manager.
|
|
713
|
+
it('a namespaced and an unqualified claim on one Application are one candidate', () => {
|
|
714
|
+
const manager = { ...claimed, direct: { object: target, claims: [
|
|
715
|
+
{ manager: app, source: 'argo-tracking-id' as const, check: 'omitted' as const },
|
|
716
|
+
{ manager: { ...app, namespace: '' }, source: 'argo-instance-label' as const, check: 'unresolved' as const },
|
|
717
|
+
] } }
|
|
718
|
+
const g = guard({ manager, writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
|
|
719
|
+
expect(g.summary).not.toContain(' and ')
|
|
720
|
+
expect(g.summary).not.toContain("A single manager's ignore rules")
|
|
721
|
+
})
|
|
722
|
+
it.each(['cycle', 'truncated'] as const)('%s ancestry remains conservative without a stop object', chainEnd => {
|
|
723
|
+
const g = guard({ manager: { state: 'unknown', direct: { object: target }, chainEnd }, writes: [hibernate, { scope: 'delete' }] })
|
|
724
|
+
expect(g.perWrite.map(w => w.level)).toEqual(['may-revert', 'may-revert'])
|
|
725
|
+
expect(g.ownershipError).toContain("couldn't follow")
|
|
726
|
+
})
|
|
727
|
+
it('stopped claimed deletes retain the floor after the operator rule', () => {
|
|
728
|
+
const manager = { ...claimed, stop: { at: target, state: 'unknown' } }
|
|
729
|
+
const input = { manager, writes: [{ scope: 'delete' as const }], evidence: evidence(selfHeal) }
|
|
730
|
+
expect(guard(input).perWrite[0].reason).toContain("couldn't follow")
|
|
731
|
+
expect(guard({ ...input, operatorOwner: { kind: 'Cluster', name: 'pg' } }).level).toBe('info')
|
|
732
|
+
})
|
|
733
|
+
})
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// field missing from both can still be in the source. So absence of evidence
|
|
10
10
|
// is "unknown" (may be overwritten), never "safe".
|
|
11
11
|
|
|
12
|
-
import type
|
|
12
|
+
import { activeManagerClaims, gitOpsOwnerFromRef, managerCandidates, managerRefLabel, type GitOpsOwnerRef, type GitOpsOwnerTool } from './gitops-owner'
|
|
13
|
+
import type { ManagerAnswer, ResourceRef } from '../types/core'
|
|
14
|
+
import { dedupeResourceRefs } from './resource-refs'
|
|
15
|
+
import { gapReasonText, readActionFor } from './read-actions'
|
|
13
16
|
|
|
14
17
|
export type GitOpsWriteScope = 'status' | 'metadata' | 'spec' | 'create-child' | 'delete'
|
|
15
18
|
|
|
@@ -100,6 +103,9 @@ export interface GitOpsWritePolicyEvidence {
|
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
export interface GitOpsWriteEvidence {
|
|
106
|
+
manager?: ManagerAnswer
|
|
107
|
+
/** Why the server couldn't answer what manages the live object; the owner hint wasn't used either. */
|
|
108
|
+
managerUnavailable?: string
|
|
103
109
|
uid: string
|
|
104
110
|
resourceVersion: string
|
|
105
111
|
owner: { kind: string; group?: string; namespace: string; name: string } | null
|
|
@@ -114,6 +120,7 @@ export interface GitOpsWriteEvidence {
|
|
|
114
120
|
// ---------------------------------------------------------------------------
|
|
115
121
|
|
|
116
122
|
export interface GitOpsWriteGuardInput {
|
|
123
|
+
manager?: ManagerAnswer
|
|
117
124
|
target: GitOpsWriteTarget
|
|
118
125
|
owner: GitOpsOwnerRef | null
|
|
119
126
|
/** Native Helm release that manages the object when no GitOps tool does. */
|
|
@@ -141,6 +148,7 @@ export interface GitOpsWriteAssessment {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
export interface GitOpsWriteGuard {
|
|
151
|
+
manager?: ManagerAnswer
|
|
144
152
|
owner: GitOpsOwnerRef | null
|
|
145
153
|
helmRelease: GitOpsWriteHelmRelease | null
|
|
146
154
|
pending: boolean
|
|
@@ -413,10 +421,68 @@ function classifyWrite(input: GitOpsWriteGuardInput, owner: GitOpsOwnerRef | nul
|
|
|
413
421
|
if (write.scope === 'status') {
|
|
414
422
|
return { level: 'none', reason: 'This writes controller status; ordinary GitOps sync does not manage these fields.' }
|
|
415
423
|
}
|
|
424
|
+
const manager = input.manager
|
|
425
|
+
if (manager) {
|
|
426
|
+
const candidates = managerCandidates(manager)
|
|
427
|
+
// A Helm release's annotations on an object answered through an ancestor
|
|
428
|
+
// are its Deployment's, copied onto it.
|
|
429
|
+
const directClaims = activeManagerClaims(manager.direct).filter(c => !manager.via || c.source !== 'helm-release')
|
|
430
|
+
const names = dedupeResourceRefs([...candidates, ...(manager.via ? directClaims.map(c => c.manager) : [])]).map(managerRefLabel).join(' and ')
|
|
431
|
+
if (write.scope === 'create-child' && candidates.length) {
|
|
432
|
+
return { level: 'info', reason: `A new object isn't in the GitOps source, so ${names} is not expected to revert it.` }
|
|
433
|
+
}
|
|
434
|
+
if (write.scope === 'metadata' || write.scope === 'spec') {
|
|
435
|
+
if (write.paths?.length && write.paths.every(path => input.evidence?.paths.find(p => p.path === path)?.empty)) {
|
|
436
|
+
return { level: 'none', reason: '' }
|
|
437
|
+
}
|
|
438
|
+
if (chainUnread(manager)) {
|
|
439
|
+
return { level: 'may-revert', reason: input.ownershipError! }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// A walk that couldn't read its chain may have missed a manager above;
|
|
443
|
+
// a controller that recreates the object makes that moot.
|
|
444
|
+
if (write.scope === 'delete' && chainUnread(manager) && !operatorOwnerOf(input)) {
|
|
445
|
+
return { level: 'may-revert', reason: input.ownershipError! }
|
|
446
|
+
}
|
|
447
|
+
if (manager.via && !directClaims.length) {
|
|
448
|
+
const source = managerRefLabel(manager.via.object)
|
|
449
|
+
const parent = `${source}, ${parentManagedBy(manager, names)}`
|
|
450
|
+
return {
|
|
451
|
+
level: 'info',
|
|
452
|
+
reason: write.scope === 'delete'
|
|
453
|
+
? !NON_RECREATING_CONTROLLERS.includes(operatorOwnerOf(input)?.kind ?? '')
|
|
454
|
+
? `Its controller recreates it from ${parent}.`
|
|
455
|
+
: `It was created from ${parent}; nothing is expected to recreate it, but ${operatorOwnerOf(input)!.kind} ${input.target.namespace ? `${input.target.namespace}/` : ''}${operatorOwnerOf(input)!.name} can still create new ones on its schedule.`
|
|
456
|
+
: `This resource is generated from ${parent}; changes to the parent may replace it.`,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if ((write.scope === 'metadata' || write.scope === 'spec') &&
|
|
460
|
+
(manager.state === 'disagree' || manager.state === 'shared' || candidates.length > 1 || manager.via && directClaims.length > 0)) {
|
|
461
|
+
return { level: 'may-revert', reason: `${names} may overwrite these fields. A single manager's ignore rules or sync policy cannot exempt this change.${manager.via ? ` The controller also generates it from ${managerRefLabel(manager.via.object)}.` : ''}` }
|
|
462
|
+
}
|
|
463
|
+
if (unconfirmed(manager, owner, input, candidates) && !targetOptedOut(input) && (write.scope === 'metadata' || write.scope === 'spec')) {
|
|
464
|
+
return { level: 'may-revert', reason: `This resource's metadata names ${names}, but its recorded membership isn't confirmed. A sync may overwrite this change.` }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
416
467
|
if (write.scope === 'delete') {
|
|
417
468
|
const operator = operatorOwnerOf(input)
|
|
418
469
|
if (operator) {
|
|
419
|
-
return {
|
|
470
|
+
return {
|
|
471
|
+
level: 'info',
|
|
472
|
+
reason: !NON_RECREATING_CONTROLLERS.includes(operator.kind)
|
|
473
|
+
? `${operator.kind} ${operator.name} will recreate this ${input.target.kind}.`
|
|
474
|
+
: `${operator.kind} ${operator.name} created this ${input.target.kind}; it is not expected to recreate it.`,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (manager && write.scope === 'delete') {
|
|
479
|
+
const candidates = managerCandidates(manager)
|
|
480
|
+
if (chainUnread(manager)) return { level: 'may-revert', reason: input.ownershipError! }
|
|
481
|
+
if (unconfirmed(manager, owner, input, candidates) && !targetOptedOut(input)) {
|
|
482
|
+
return { level: 'may-revert', reason: `This resource's metadata names ${candidates.map(managerRefLabel).join(' and ')}, but its recorded membership isn't confirmed. A sync may recreate it.` }
|
|
483
|
+
}
|
|
484
|
+
if (manager.state === 'disagree' || manager.state === 'shared' || candidates.length > 1) {
|
|
485
|
+
return { level: 'may-revert', reason: `${candidates.map(managerRefLabel).join(' and ')} may recreate this resource on sync.` }
|
|
420
486
|
}
|
|
421
487
|
}
|
|
422
488
|
if (owner) {
|
|
@@ -458,7 +524,8 @@ function classifyWrite(input: GitOpsWriteGuardInput, owner: GitOpsOwnerRef | nul
|
|
|
458
524
|
return { level: 'none', reason: '' }
|
|
459
525
|
}
|
|
460
526
|
|
|
461
|
-
function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>, level: GitOpsWriteLevel): string {
|
|
527
|
+
function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease' | 'manager'>, level: GitOpsWriteLevel): string {
|
|
528
|
+
if (guard.manager?.via && level === 'info') return `Generated from ${managerRefLabel(guard.manager.via.object)}.`
|
|
462
529
|
const who = guard.owner
|
|
463
530
|
? describeGitOpsOwner(guard.owner)
|
|
464
531
|
: guard.helmRelease
|
|
@@ -466,7 +533,8 @@ function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>, level:
|
|
|
466
533
|
: 'A GitOps tool'
|
|
467
534
|
if (level === 'will-revert') return `${who} will revert this change.`
|
|
468
535
|
if (level === 'may-revert') return `${who} may revert this change.`
|
|
469
|
-
|
|
536
|
+
// A list alone isn't management: say what the chip says.
|
|
537
|
+
if (level === 'info') return guard.owner || guard.helmRelease ? `${guard.manager?.state === 'listed' ? 'Listed by' : 'Managed by'} ${who}.` : ''
|
|
470
538
|
return ''
|
|
471
539
|
}
|
|
472
540
|
|
|
@@ -474,15 +542,110 @@ export function gitOpsWriteHeadline(guard: GitOpsWriteGuard): string {
|
|
|
474
542
|
return headline(guard, guard.level)
|
|
475
543
|
}
|
|
476
544
|
|
|
545
|
+
// unconfirmed reports whether the answer names candidates without confirming
|
|
546
|
+
// one Radar can act on: a claim, or a candidate this UI can't open (no lead,
|
|
547
|
+
// or a manager kind it doesn't know). A native Helm lead has its own rules.
|
|
548
|
+
function unconfirmed(manager: ManagerAnswer, owner: GitOpsOwnerRef | null, input: GitOpsWriteGuardInput, candidates: ResourceRef[]): boolean {
|
|
549
|
+
if (!candidates.length || !owner && input.helmRelease) return false
|
|
550
|
+
return manager.state === 'claimed' || !owner && manager.state !== 'none' && manager.state !== 'unknown'
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// parentManagedBy says what the evidence says of a parent, in the words its
|
|
554
|
+
// state allows: only a list and metadata agreeing say a manager manages it.
|
|
555
|
+
function parentManagedBy(manager: ManagerAnswer, names: string): string {
|
|
556
|
+
const evidence = manager.via ?? manager.direct
|
|
557
|
+
const label = (refs: ResourceRef[]) => dedupeResourceRefs(refs).map(managerRefLabel).join(' and ')
|
|
558
|
+
const listing = (refs: ResourceRef[]) => `${label(refs)} ${refs.length > 1 ? 'list it in their statuses' : 'lists it in its status'}`
|
|
559
|
+
switch (manager.state) {
|
|
560
|
+
case 'disagree':
|
|
561
|
+
return `whose metadata names ${label(activeManagerClaims(evidence).map(c => c.manager))}, while ${listing(evidence.recorders ?? [])}`
|
|
562
|
+
case 'agree':
|
|
563
|
+
return `which ${names} manages`
|
|
564
|
+
case 'listed':
|
|
565
|
+
case 'shared':
|
|
566
|
+
return `which ${names} ${managerCandidates(manager).length > 1 ? 'list' : 'lists'} in ${managerCandidates(manager).length > 1 ? 'their statuses' : 'its status'}`
|
|
567
|
+
default:
|
|
568
|
+
return `whose metadata names ${names}`
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// adoptedHelmRelease: a native Helm release's annotations on an object a GitOps
|
|
573
|
+
// manager other than Flux's HelmRelease answers for. Argo CD renders charts
|
|
574
|
+
// with helm template, and a Kustomization applies plain manifests, so the
|
|
575
|
+
// annotations mean a Helm install was adopted: its release can still upgrade
|
|
576
|
+
// or roll back the object, whatever the GitOps manager ignores.
|
|
577
|
+
function adoptedHelmRelease(manager: ManagerAnswer | undefined): string | null {
|
|
578
|
+
if (!manager?.lead || manager.via) return null
|
|
579
|
+
if (manager.lead.kind === 'HelmRelease') return null
|
|
580
|
+
const claim = (manager.direct.claims ?? []).find(c => c.source === 'helm-release' && !c.copied && !c.weak)
|
|
581
|
+
return claim ? `${claim.manager.namespace ? `${claim.manager.namespace}/` : ''}${claim.manager.name}` : null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// targetOptedOut: the target's own annotations opt it out of its manager's
|
|
585
|
+
// reconciliation, which holds whether or not that manager's list names it.
|
|
586
|
+
function targetOptedOut(input: GitOpsWriteGuardInput): boolean {
|
|
587
|
+
return input.evidence?.policy?.objectReconcile === 'ignore'
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// chainUnread: the walk up the controller chain stopped at an owner Radar
|
|
591
|
+
// couldn't read or decide, so a manager above it may be missed. An owner that is
|
|
592
|
+
// gone or was replaced ends the chain with an answer.
|
|
593
|
+
function chainUnread(manager: ManagerAnswer): boolean {
|
|
594
|
+
// A chain end this UI doesn't know (from a newer Radar) stopped short too.
|
|
595
|
+
if (manager.chainEnd && manager.chainEnd !== 'unfollowed') return true
|
|
596
|
+
return Boolean(manager.stop && !['gone', 'replaced', 'excluded'].includes(manager.stop.state))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// The states this UI knows. A newer Radar may send another; it is read as
|
|
600
|
+
// claimed, which never proves a revert or waives a warning.
|
|
601
|
+
const MANAGER_STATES: Array<string> = ['shared', 'disagree', 'agree', 'listed', 'claimed', 'unknown', 'none']
|
|
602
|
+
|
|
603
|
+
// Controllers that don't replace an object of theirs that is deleted: a
|
|
604
|
+
// CronJob's Job (scheduled or created by hand) runs once.
|
|
605
|
+
const NON_RECREATING_CONTROLLERS = ['CronJob']
|
|
606
|
+
|
|
477
607
|
export function evaluateGitOpsWriteGuard(input: GitOpsWriteGuardInput): GitOpsWriteGuard {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
608
|
+
// A live check the server couldn't answer, or one that failed while the
|
|
609
|
+
// cached answer names a manager, leaves that answer unconfirmed: an exemption
|
|
610
|
+
// read from it could belong to the object as it was.
|
|
611
|
+
const cachedNamesManager = Boolean(input.manager && (input.manager.via || !['none', 'unknown'].includes(input.manager.state)))
|
|
612
|
+
const unavailable = input.evidence?.managerUnavailable
|
|
613
|
+
const failed = !input.evidence && input.evidenceError && cachedNamesManager ? input.evidenceError : null
|
|
614
|
+
const unconfirmed = unavailable || failed
|
|
615
|
+
const answered = unconfirmed ? undefined : input.evidence?.manager ?? input.manager
|
|
616
|
+
const manager = answered && !MANAGER_STATES.includes(answered.state) ? { ...answered, state: 'claimed' as const } : answered
|
|
617
|
+
const owner = unconfirmed ? null : manager ? manager.lead ? gitOpsOwnerFromRef(manager.lead) : null : input.owner ?? null
|
|
618
|
+
const helmRelease = unconfirmed ? null : manager
|
|
619
|
+
? manager.lead?.kind === 'HelmRelease' && !manager.lead.group ? { namespace: manager.lead.namespace, name: manager.lead.name } : null
|
|
620
|
+
: owner ? null : (input.helmRelease ?? null)
|
|
621
|
+
const ownershipError = unconfirmed
|
|
622
|
+
? `Radar couldn't check what manages this resource right now (${unavailable ? gapReasonText(unavailable) : failed}), so it can't tell whether a GitOps tool or Helm reverts this change.${unavailable && ['retry', 'wait'].includes(readActionFor(unavailable) ?? '') ? ' Close and reopen this dialog to check again.' : ''}`
|
|
623
|
+
: manager && chainUnread(manager)
|
|
624
|
+
? "Radar couldn't follow this resource's controller chain, so it can't tell whether a GitOps tool or Helm manages it."
|
|
625
|
+
: owner || helmRelease ? null : (input.ownershipError ?? null)
|
|
626
|
+
const normalized: GitOpsWriteGuardInput = { ...input, manager, owner, helmRelease, ownershipError, ownerMatchedByName: manager ? false : input.ownerMatchedByName }
|
|
627
|
+
const adopted = adoptedHelmRelease(manager)
|
|
482
628
|
|
|
483
629
|
const perWrite = input.writes.map((write) => {
|
|
484
630
|
const verdict = classifyWrite(normalized, owner, write)
|
|
485
|
-
|
|
631
|
+
// A list alone doesn't show its manager syncs the object (a copy carrying
|
|
632
|
+
// another object's tracking ID, a CRD a HelmRelease skips): no promise,
|
|
633
|
+
// in the verdict or in its reason.
|
|
634
|
+
if (manager?.state === 'listed' && !manager.via && (verdict.level === 'will-revert' || verdict.level === 'may-revert')) {
|
|
635
|
+
return {
|
|
636
|
+
write,
|
|
637
|
+
level: 'may-revert' as const,
|
|
638
|
+
reason: `Radar can't confirm ${managerRefLabel(manager.lead!)} applies this object: its status lists it, but the object's metadata doesn't name it. If it does: ${verdict.reason}`,
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (adopted && verdict.level === 'info' && (write.scope === 'metadata' || write.scope === 'spec' || write.scope === 'delete')) {
|
|
642
|
+
return {
|
|
643
|
+
write,
|
|
644
|
+
level: 'may-revert' as const,
|
|
645
|
+
reason: `${verdict.reason} Its metadata also says Helm release ${adopted} installed it; the next helm upgrade or rollback of that release may ${write.scope === 'delete' ? 'recreate it' : 'overwrite this change'}.`,
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (owner && normalized.ownerMatchedByName && verdict.level === 'info' && write.scope !== 'create-child') {
|
|
486
649
|
return {
|
|
487
650
|
write,
|
|
488
651
|
level: 'may-revert' as const,
|
|
@@ -493,16 +656,17 @@ export function evaluateGitOpsWriteGuard(input: GitOpsWriteGuardInput): GitOpsWr
|
|
|
493
656
|
})
|
|
494
657
|
const level = perWrite.reduce<GitOpsWriteLevel>((max, entry) => maxLevel(max, entry.level), 'none')
|
|
495
658
|
const top = perWrite.find((entry) => entry.level === level)
|
|
496
|
-
const summary = level === 'none' ? '' : [headline({ owner, helmRelease }, level), top?.reason ?? ''].filter(Boolean).join(' ')
|
|
659
|
+
const summary = level === 'none' ? '' : [headline({ owner, helmRelease, manager }, level), top?.reason ?? ''].filter(Boolean).join(' ')
|
|
497
660
|
|
|
498
661
|
return {
|
|
662
|
+
...(manager ? { manager } : {}),
|
|
499
663
|
owner,
|
|
500
664
|
helmRelease,
|
|
501
665
|
pending: Boolean(input.ownerPending),
|
|
502
666
|
level,
|
|
503
667
|
perWrite,
|
|
504
668
|
summary,
|
|
505
|
-
syncPolicy: owner ? gitOpsSyncPolicy(input.evidence?.policy) : null,
|
|
669
|
+
syncPolicy: owner && (!manager || manager.state !== 'claimed' && managerCandidates(manager).length === 1) ? gitOpsSyncPolicy(input.evidence?.policy) : null,
|
|
506
670
|
requiresAck: level === 'will-revert' || level === 'may-revert',
|
|
507
671
|
ownershipError,
|
|
508
672
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export * from './log-export'
|
|
|
11
11
|
export * from './log-format'
|
|
12
12
|
export * from './download'
|
|
13
13
|
export * from './clipboard'
|
|
14
|
+
export * from './touch-intent'
|
|
14
15
|
export * from './env-from'
|
|
15
16
|
export * from './extended-resources'
|
|
16
17
|
export * from './api-resources'
|
|
@@ -35,5 +36,7 @@ export * from './network-policy'
|
|
|
35
36
|
export * from './cnpg-backup'
|
|
36
37
|
export * from './url-path'
|
|
37
38
|
export * from './resource-refs'
|
|
39
|
+
export * from './resource-coverage'
|
|
40
|
+
export * from './read-actions'
|
|
38
41
|
|
|
39
42
|
export * from './grant'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { canonicalReadCode, READ_ACTIONS, readActionFor } from './read-actions'
|
|
6
|
+
|
|
7
|
+
const FIXTURE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../pkg/readreason/testdata/actions.json')
|
|
8
|
+
|
|
9
|
+
describe('read actions', () => {
|
|
10
|
+
it('mirror the server table exactly', () => {
|
|
11
|
+
expect(READ_ACTIONS).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8')))
|
|
12
|
+
})
|
|
13
|
+
it('maps an incomplete findings scan to changing the request', () => {
|
|
14
|
+
expect(READ_ACTIONS.findings_scan_incomplete).toBe('change_request')
|
|
15
|
+
})
|
|
16
|
+
it('read the spellings older Radars sent as the codes they became', () => {
|
|
17
|
+
expect(canonicalReadCode('kind_sync_failed')).toBe('kind_sync_timed_out')
|
|
18
|
+
expect(canonicalReadCode('rbac_denied')).toBe('user_forbidden')
|
|
19
|
+
expect(readActionFor('kind_sync_failed')).toBe('wait')
|
|
20
|
+
expect(readActionFor('rbac_denied')).toBe('user_access')
|
|
21
|
+
})
|
|
22
|
+
})
|