@skyhook-io/k8s-ui 1.6.0 → 1.6.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/package.json +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +65 -12
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/types/core.ts +31 -6
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
|
@@ -724,7 +724,8 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
724
724
|
(relationships.configRefs && relationships.configRefs.length > 0) ||
|
|
725
725
|
(relationships.consumers && relationships.consumers.length > 0) ||
|
|
726
726
|
(relationships.scalers && relationships.scalers.length > 0) ||
|
|
727
|
-
(relationships.
|
|
727
|
+
(relationships.pdbs && relationships.pdbs.length > 0) ||
|
|
728
|
+
(relationships.networkPolicies && relationships.networkPolicies.length > 0) ||
|
|
728
729
|
relationships.scaleTarget
|
|
729
730
|
|
|
730
731
|
if (!hasRelationships) return null
|
|
@@ -765,25 +766,12 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
765
766
|
{relationships.scalers && relationships.scalers.length > 0 && (
|
|
766
767
|
<RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
|
|
767
768
|
)}
|
|
768
|
-
{relationships.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
<>
|
|
775
|
-
{pdbs.length > 0 && (
|
|
776
|
-
<RelationshipGroup label="Disruption Budget" refs={dedupeRefs(pdbs)} onNavigate={onNavigate} />
|
|
777
|
-
)}
|
|
778
|
-
{netpols.length > 0 && (
|
|
779
|
-
<RelationshipGroup label="Network Policies" refs={dedupeRefs(netpols)} onNavigate={onNavigate} />
|
|
780
|
-
)}
|
|
781
|
-
{other.length > 0 && (
|
|
782
|
-
<RelationshipGroup label="Policies" refs={dedupeRefs(other)} onNavigate={onNavigate} />
|
|
783
|
-
)}
|
|
784
|
-
</>
|
|
785
|
-
)
|
|
786
|
-
})()}
|
|
769
|
+
{relationships.pdbs && relationships.pdbs.length > 0 && (
|
|
770
|
+
<RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.pdbs)} onNavigate={onNavigate} />
|
|
771
|
+
)}
|
|
772
|
+
{relationships.networkPolicies && relationships.networkPolicies.length > 0 && (
|
|
773
|
+
<RelationshipGroup label="Network Policies" refs={dedupeRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
|
|
774
|
+
)}
|
|
787
775
|
{relationships.scaleTarget && (
|
|
788
776
|
<RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
|
|
789
777
|
)}
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom } from '../../types'
|
|
24
24
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
25
25
|
import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
|
|
26
|
-
import {
|
|
26
|
+
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
27
27
|
import { gitOpsRouteForResource } from '../../utils/gitops-route'
|
|
28
28
|
import { isChangeEvent, isHistoricalEvent } from '../../types'
|
|
29
29
|
import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
|
|
@@ -311,7 +311,7 @@ export function WorkloadView({
|
|
|
311
311
|
|
|
312
312
|
// Metadata
|
|
313
313
|
const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
|
|
314
|
-
const gitopsOwner = useMemo(() =>
|
|
314
|
+
const gitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
|
|
315
315
|
// When the resource itself is a portal GitOps CR (Application, Kustomization,
|
|
316
316
|
// HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
|
|
317
317
|
// the drawer's renderer is thorough but the tab has the tree + insights +
|
package/src/types/core.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Topology types matching the Go backend
|
|
2
2
|
|
|
3
|
-
// Per-resource-type RBAC permissions
|
|
3
|
+
// Per-resource-type RBAC permissions. Field names must match the JSON keys
|
|
4
|
+
// produced by ResourcePermissions in internal/k8s/capabilities.go — there
|
|
5
|
+
// is no automated check across the Go/TS boundary.
|
|
4
6
|
export interface ResourcePermissions {
|
|
5
7
|
pods: boolean
|
|
6
8
|
services: boolean
|
|
@@ -12,20 +14,39 @@ export interface ResourcePermissions {
|
|
|
12
14
|
configMaps: boolean
|
|
13
15
|
secrets: boolean
|
|
14
16
|
events: boolean
|
|
15
|
-
|
|
17
|
+
persistentVolumeClaims: boolean
|
|
16
18
|
nodes: boolean
|
|
17
19
|
namespaces: boolean
|
|
18
20
|
jobs: boolean
|
|
19
21
|
cronJobs: boolean
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
horizontalPodAutoscalers: boolean
|
|
23
|
+
persistentVolumes: boolean
|
|
24
|
+
storageClasses: boolean
|
|
25
|
+
podDisruptionBudgets: boolean
|
|
26
|
+
networkPolicies: boolean
|
|
27
|
+
serviceAccounts: boolean
|
|
23
28
|
roles: boolean
|
|
24
29
|
clusterRoles: boolean
|
|
25
30
|
roleBindings: boolean
|
|
26
31
|
clusterRoleBindings: boolean
|
|
32
|
+
limitRanges: boolean
|
|
33
|
+
gateways: boolean
|
|
34
|
+
httpRoutes: boolean
|
|
35
|
+
verticalPodAutoscalers: boolean
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
// Keys in ResourcePermissions that represent optional CRDs Radar can monitor
|
|
39
|
+
// when they're installed in the cluster. A `false` here means "CRD not
|
|
40
|
+
// installed (or RBAC denied)" — NOT "Radar is missing data the user expects",
|
|
41
|
+
// so banners about RBAC restrictions should ignore these keys.
|
|
42
|
+
//
|
|
43
|
+
// Keep in sync with dynamicCapabilityKinds in internal/k8s/capabilities_alignment_test.go.
|
|
44
|
+
export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray<keyof ResourcePermissions> = [
|
|
45
|
+
'gateways',
|
|
46
|
+
'httpRoutes',
|
|
47
|
+
'verticalPodAutoscalers',
|
|
48
|
+
]
|
|
49
|
+
|
|
29
50
|
// Feature capabilities based on RBAC permissions
|
|
30
51
|
export interface Capabilities {
|
|
31
52
|
exec: boolean // Terminal feature (pods/exec)
|
|
@@ -394,6 +415,7 @@ export interface ResourceRef {
|
|
|
394
415
|
export interface Relationships {
|
|
395
416
|
owner?: ResourceRef
|
|
396
417
|
deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
|
|
418
|
+
managedBy?: ResourceRef[] // Topmost meaningful manager(s): GitOps controller (ArgoCD Application / Flux Kustomization / Flux HelmRelease), Helm release, or the topmost K8s owner. Synthesized server-side; replaces client-side detectGitOpsOwner.
|
|
397
419
|
children?: ResourceRef[]
|
|
398
420
|
services?: ResourceRef[]
|
|
399
421
|
ingresses?: ResourceRef[]
|
|
@@ -403,8 +425,11 @@ export interface Relationships {
|
|
|
403
425
|
consumers?: ResourceRef[]
|
|
404
426
|
scalers?: ResourceRef[]
|
|
405
427
|
scaleTarget?: ResourceRef
|
|
406
|
-
|
|
428
|
+
pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
|
|
429
|
+
networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
|
|
407
430
|
pods?: ResourceRef[]
|
|
431
|
+
serviceAccount?: ResourceRef // For Pods: derived from pod.spec.serviceAccountName
|
|
432
|
+
node?: ResourceRef // For scheduled Pods: derived from pod.spec.nodeName
|
|
408
433
|
}
|
|
409
434
|
|
|
410
435
|
// Parsed X.509 certificate metadata (from backend cert parsing)
|
|
@@ -286,6 +286,31 @@ export function getHelmStatusColor(status: string): string {
|
|
|
286
286
|
return HELM_STATUS_COLORS[statusLower] || 'bg-theme-hover/50 text-theme-text-secondary'
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Helm release statuses where the row UI should signpost the user
|
|
291
|
+
* toward the drawer (history / rollback / logs).
|
|
292
|
+
*
|
|
293
|
+
* Currently `failed` only. The `pending-*` statuses (install /
|
|
294
|
+
* upgrade / rollback) are excluded deliberately: they're Helm's
|
|
295
|
+
* normal in-flight states during every routine operation. Treating
|
|
296
|
+
* them as "actionable" would briefly attach an alarming chevron +
|
|
297
|
+
* tooltip to every install while it ran — indistinguishable from
|
|
298
|
+
* the genuinely-stuck case (controller crashed mid-flight). Until
|
|
299
|
+
* we have release age available client-side to disambiguate
|
|
300
|
+
* "in-flight" from "stuck > N min", we give up the stuck-detect
|
|
301
|
+
* signpost rather than wrongly alarm the common case.
|
|
302
|
+
*
|
|
303
|
+
* @see https://github.com/helm/helm/blob/dev-v3/pkg/release/status.go
|
|
304
|
+
*/
|
|
305
|
+
const ACTIONABLE_HELM_STATUSES: ReadonlySet<string> = new Set([
|
|
306
|
+
'failed',
|
|
307
|
+
])
|
|
308
|
+
|
|
309
|
+
export function isHelmReleaseActionable(status: string | null | undefined): boolean {
|
|
310
|
+
if (!status) return false
|
|
311
|
+
return ACTIONABLE_HELM_STATUSES.has(status.toLowerCase())
|
|
312
|
+
}
|
|
313
|
+
|
|
289
314
|
// =============================================================================
|
|
290
315
|
// VULNERABILITY SEVERITY COLORS - for Trivy and other security scanners
|
|
291
316
|
// =============================================================================
|
|
@@ -1,136 +1,95 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { detectGitOpsOwner } from './gitops-owner'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
const FLUX_HELM_NS = 'helm.toolkit.fluxcd.io/namespace'
|
|
6
|
-
const FLUX_KUSTOMIZE_NAME = 'kustomize.toolkit.fluxcd.io/name'
|
|
7
|
-
const FLUX_KUSTOMIZE_NS = 'kustomize.toolkit.fluxcd.io/namespace'
|
|
8
|
-
const ARGO_TRACKING_ID = 'argocd.argoproj.io/tracking-id'
|
|
9
|
-
const ARGO_INSTANCE = 'argocd.argoproj.io/instance'
|
|
10
|
-
const HELM_INSTANCE = 'app.kubernetes.io/instance'
|
|
3
|
+
import type { Relationships } from '../types/core'
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
return { metadata: { labels, annotations } }
|
|
14
|
-
}
|
|
5
|
+
import { gitOpsOwnerFromRelationships } from './gitops-owner'
|
|
15
6
|
|
|
16
|
-
describe('
|
|
17
|
-
it('returns null
|
|
18
|
-
expect(
|
|
19
|
-
expect(detectGitOpsOwner(undefined)).toBeNull()
|
|
20
|
-
expect(detectGitOpsOwner('string')).toBeNull()
|
|
7
|
+
describe('gitOpsOwnerFromRelationships', () => {
|
|
8
|
+
it('returns null when relationships is undefined', () => {
|
|
9
|
+
expect(gitOpsOwnerFromRelationships(undefined)).toBeNull()
|
|
21
10
|
})
|
|
22
11
|
|
|
23
|
-
it('returns null when
|
|
24
|
-
expect(
|
|
12
|
+
it('returns null when managedBy is absent', () => {
|
|
13
|
+
expect(gitOpsOwnerFromRelationships({})).toBeNull()
|
|
25
14
|
})
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const got = detectGitOpsOwner(resource({
|
|
30
|
-
[FLUX_HELM_NAME]: 'podinfo',
|
|
31
|
-
[FLUX_HELM_NS]: 'flux-system',
|
|
32
|
-
}))
|
|
33
|
-
expect(got).toEqual({ tool: 'fluxcd', kind: 'helmreleases', namespace: 'flux-system', name: 'podinfo' })
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('wins over Flux Kustomize labels when both are present (most-direct owner)', () => {
|
|
37
|
-
const got = detectGitOpsOwner(resource({
|
|
38
|
-
[FLUX_HELM_NAME]: 'podinfo',
|
|
39
|
-
[FLUX_HELM_NS]: 'flux-system',
|
|
40
|
-
[FLUX_KUSTOMIZE_NAME]: 'parent',
|
|
41
|
-
[FLUX_KUSTOMIZE_NS]: 'flux-system',
|
|
42
|
-
}))
|
|
43
|
-
expect(got?.kind).toBe('helmreleases')
|
|
44
|
-
expect(got?.name).toBe('podinfo')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('requires both name and namespace', () => {
|
|
48
|
-
expect(detectGitOpsOwner(resource({ [FLUX_HELM_NAME]: 'podinfo' }))).toBeNull()
|
|
49
|
-
expect(detectGitOpsOwner(resource({ [FLUX_HELM_NS]: 'flux-system' }))).toBeNull()
|
|
50
|
-
})
|
|
16
|
+
it('returns null when managedBy is empty', () => {
|
|
17
|
+
expect(gitOpsOwnerFromRelationships({ managedBy: [] })).toBeNull()
|
|
51
18
|
})
|
|
52
19
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
20
|
+
it('maps an ArgoCD Application ref', () => {
|
|
21
|
+
const rel: Relationships = {
|
|
22
|
+
managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'storefront' }],
|
|
23
|
+
}
|
|
24
|
+
expect(gitOpsOwnerFromRelationships(rel)).toEqual({
|
|
25
|
+
tool: 'argocd',
|
|
26
|
+
kind: 'applications',
|
|
27
|
+
namespace: 'argocd',
|
|
28
|
+
name: 'storefront',
|
|
60
29
|
})
|
|
61
30
|
})
|
|
62
31
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const got = detectGitOpsOwner(resource({}, {
|
|
73
|
-
[ARGO_TRACKING_ID]: 'my-app:apps/Deployment:default/web',
|
|
74
|
-
}))
|
|
75
|
-
// Empty namespace is the contract — caller defaults / routes accordingly.
|
|
76
|
-
expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: '', name: 'my-app' })
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('returns null on a malformed tracking-id (no colon)', () => {
|
|
80
|
-
const got = detectGitOpsOwner(resource({}, { [ARGO_TRACKING_ID]: 'just-garbage-no-colon' }))
|
|
81
|
-
// Falls through to label scan; with no labels present, returns null.
|
|
82
|
-
expect(got).toBeNull()
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('returns null on tracking-id with empty name after underscore', () => {
|
|
86
|
-
const got = detectGitOpsOwner(resource({}, { [ARGO_TRACKING_ID]: 'my-ns_:apps/Deployment:default/web' }))
|
|
87
|
-
expect(got).toBeNull()
|
|
32
|
+
it('maps a Flux Kustomization ref', () => {
|
|
33
|
+
const rel: Relationships = {
|
|
34
|
+
managedBy: [{ kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', namespace: 'flux-system', name: 'prod-apps' }],
|
|
35
|
+
}
|
|
36
|
+
expect(gitOpsOwnerFromRelationships(rel)).toEqual({
|
|
37
|
+
tool: 'fluxcd',
|
|
38
|
+
kind: 'kustomizations',
|
|
39
|
+
namespace: 'flux-system',
|
|
40
|
+
name: 'prod-apps',
|
|
88
41
|
})
|
|
42
|
+
})
|
|
89
43
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
44
|
+
it('maps a Flux HelmRelease ref', () => {
|
|
45
|
+
const rel: Relationships = {
|
|
46
|
+
managedBy: [{ kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', namespace: 'flux-system', name: 'cert-manager' }],
|
|
47
|
+
}
|
|
48
|
+
expect(gitOpsOwnerFromRelationships(rel)).toEqual({
|
|
49
|
+
tool: 'fluxcd',
|
|
50
|
+
kind: 'helmreleases',
|
|
51
|
+
namespace: 'flux-system',
|
|
52
|
+
name: 'cert-manager',
|
|
97
53
|
})
|
|
98
54
|
})
|
|
99
55
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
56
|
+
it('disambiguates Flux HelmRelease from native Helm via API group', () => {
|
|
57
|
+
// A native Helm release would not carry the helm.toolkit.fluxcd.io group;
|
|
58
|
+
// server-side SynthesizeManagedBy only assigns that group for Flux.
|
|
59
|
+
const rel: Relationships = {
|
|
60
|
+
managedBy: [{ kind: 'HelmRelease', group: 'helm.sh', namespace: 'default', name: 'native-helm' }],
|
|
61
|
+
}
|
|
62
|
+
expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
|
|
63
|
+
})
|
|
105
64
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
})
|
|
65
|
+
it('returns null for native K8s owners (no GitOps chip)', () => {
|
|
66
|
+
const rel: Relationships = {
|
|
67
|
+
managedBy: [{ kind: 'Deployment', namespace: 'prod', name: 'web' }],
|
|
68
|
+
}
|
|
69
|
+
expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
|
|
70
|
+
})
|
|
113
71
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
expect(got?.name).toBe('argo-pick')
|
|
120
|
-
})
|
|
72
|
+
it('returns null for an unrecognized manager kind', () => {
|
|
73
|
+
const rel: Relationships = {
|
|
74
|
+
managedBy: [{ kind: 'CustomController', group: 'example.com', namespace: 'prod', name: 'thing' }],
|
|
75
|
+
}
|
|
76
|
+
expect(gitOpsOwnerFromRelationships(rel)).toBeNull()
|
|
121
77
|
})
|
|
122
78
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
},
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
79
|
+
it('only considers the first managedBy entry', () => {
|
|
80
|
+
// The server emits the topmost meaningful manager first; if there are
|
|
81
|
+
// multiple, downstream chips intentionally only render the primary.
|
|
82
|
+
const rel: Relationships = {
|
|
83
|
+
managedBy: [
|
|
84
|
+
{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'primary' },
|
|
85
|
+
{ kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', namespace: 'flux-system', name: 'secondary' },
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
expect(gitOpsOwnerFromRelationships(rel)).toEqual({
|
|
89
|
+
tool: 'argocd',
|
|
90
|
+
kind: 'applications',
|
|
91
|
+
namespace: 'argocd',
|
|
92
|
+
name: 'primary',
|
|
134
93
|
})
|
|
135
94
|
})
|
|
136
95
|
})
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// drawer can render a "Managed by <app>" affordance.
|
|
1
|
+
// Map a resource's server-side `Relationships.managedBy` to a navigable
|
|
2
|
+
// GitOps owner ref, used by the drawer to render the "Managed by <app>" chip.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// The detection (label/annotation parsing, Argo tracking-id decoding, Flux
|
|
5
|
+
// label inspection, Helm release annotation, owner-chain walk) lives server-
|
|
6
|
+
// side in pkg/topology.SynthesizeManagedBy. This file is now a thin mapper
|
|
7
|
+
// from the structured ref into the discriminated union the chip + router need.
|
|
8
|
+
|
|
9
|
+
import type { Relationships, ResourceRef } from '../types/core'
|
|
11
10
|
|
|
12
11
|
// GitOpsOwnerRef is a discriminated union — the tool determines which kind is
|
|
13
12
|
// valid. Modeling it this way prevents callers (and consumers of the returned
|
|
@@ -20,73 +19,37 @@ export type GitOpsOwnerRef =
|
|
|
20
19
|
// Vocabulary mirrors `pkg/gitops/tree.Tool` so the wire labels match end-to-end.
|
|
21
20
|
export type GitOpsOwnerTool = GitOpsOwnerRef['tool']
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// hint produced false positives on plain Helm-installed resources, which
|
|
29
|
-
// surfaced as a misleading "Managed by <release>" chip on ordinary workload
|
|
30
|
-
// drawers. Argo installs that rely on this default can still be detected
|
|
31
|
-
// via the tracking-id annotation; the `argocd.argoproj.io/instance` label
|
|
32
|
-
// (above) covers Argo deployments that explicitly set their own label key.
|
|
33
|
-
|
|
34
|
-
const FLUX_KUSTOMIZE_NAME = 'kustomize.toolkit.fluxcd.io/name'
|
|
35
|
-
const FLUX_KUSTOMIZE_NS = 'kustomize.toolkit.fluxcd.io/namespace'
|
|
36
|
-
const FLUX_HELM_NAME = 'helm.toolkit.fluxcd.io/name'
|
|
37
|
-
const FLUX_HELM_NS = 'helm.toolkit.fluxcd.io/namespace'
|
|
38
|
-
|
|
39
|
-
export function detectGitOpsOwner(resource: unknown): GitOpsOwnerRef | null {
|
|
40
|
-
if (!resource || typeof resource !== 'object') return null
|
|
41
|
-
const meta = (resource as { metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> } }).metadata
|
|
42
|
-
const labels = meta?.labels ?? {}
|
|
43
|
-
const annotations = meta?.annotations ?? {}
|
|
44
|
-
|
|
45
|
-
const helmName = labels[FLUX_HELM_NAME]
|
|
46
|
-
const helmNs = labels[FLUX_HELM_NS]
|
|
47
|
-
if (helmName && helmNs) {
|
|
48
|
-
return { tool: 'fluxcd', kind: 'helmreleases', namespace: helmNs, name: helmName }
|
|
49
|
-
}
|
|
50
|
-
const kustName = labels[FLUX_KUSTOMIZE_NAME]
|
|
51
|
-
const kustNs = labels[FLUX_KUSTOMIZE_NS]
|
|
52
|
-
if (kustName && kustNs) {
|
|
53
|
-
return { tool: 'fluxcd', kind: 'kustomizations', namespace: kustNs, name: kustName }
|
|
54
|
-
}
|
|
22
|
+
// API groups for GitOps manager kinds. Used to disambiguate Flux HelmRelease
|
|
23
|
+
// from a future native-Helm kind, etc.
|
|
24
|
+
const ARGO_APPLICATION_GROUP = 'argoproj.io'
|
|
25
|
+
const FLUX_KUSTOMIZE_GROUP = 'kustomize.toolkit.fluxcd.io'
|
|
26
|
+
const FLUX_HELM_GROUP = 'helm.toolkit.fluxcd.io'
|
|
55
27
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return null
|
|
28
|
+
// gitOpsOwnerFromRelationships reads relationships.managedBy[0] and maps it to
|
|
29
|
+
// a GitOpsOwnerRef when the manager is a GitOps controller. Returns null when
|
|
30
|
+
// the manager is a native K8s owner (Deployment, ReplicaSet, etc.), a plain
|
|
31
|
+
// Helm release, or when no manager is reported.
|
|
32
|
+
//
|
|
33
|
+
// Old radar binaries (pre-T2) emit no managedBy field; this returns null in
|
|
34
|
+
// that case and the drawer silently skips the chip — natural pressure to
|
|
35
|
+
// upgrade rather than carry a label-parsing fallback in the client.
|
|
36
|
+
export function gitOpsOwnerFromRelationships(
|
|
37
|
+
rel: Relationships | undefined | null,
|
|
38
|
+
): GitOpsOwnerRef | null {
|
|
39
|
+
const refs = rel?.managedBy
|
|
40
|
+
if (!refs || refs.length === 0) return null
|
|
41
|
+
return refToGitOpsOwner(refs[0])
|
|
72
42
|
}
|
|
73
43
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const sep = head.indexOf('_')
|
|
85
|
-
if (sep < 0) {
|
|
86
|
-
return head ? { namespace: '', name: head } : null
|
|
44
|
+
function refToGitOpsOwner(ref: ResourceRef): GitOpsOwnerRef | null {
|
|
45
|
+
switch (true) {
|
|
46
|
+
case ref.kind === 'Application' && ref.group === ARGO_APPLICATION_GROUP:
|
|
47
|
+
return { tool: 'argocd', kind: 'applications', namespace: ref.namespace, name: ref.name }
|
|
48
|
+
case ref.kind === 'Kustomization' && ref.group === FLUX_KUSTOMIZE_GROUP:
|
|
49
|
+
return { tool: 'fluxcd', kind: 'kustomizations', namespace: ref.namespace, name: ref.name }
|
|
50
|
+
case ref.kind === 'HelmRelease' && ref.group === FLUX_HELM_GROUP:
|
|
51
|
+
return { tool: 'fluxcd', kind: 'helmreleases', namespace: ref.namespace, name: ref.name }
|
|
52
|
+
default:
|
|
53
|
+
return null
|
|
87
54
|
}
|
|
88
|
-
const namespace = head.slice(0, sep)
|
|
89
|
-
const name = head.slice(sep + 1)
|
|
90
|
-
if (!name) return null
|
|
91
|
-
return { namespace, name }
|
|
92
55
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { isHelmReleaseActionable } from './badge-colors'
|
|
3
|
+
|
|
4
|
+
// Pin the membership set so adding a new Helm status becomes a
|
|
5
|
+
// deliberate decision here.
|
|
6
|
+
|
|
7
|
+
describe('isHelmReleaseActionable', () => {
|
|
8
|
+
it('returns true for `failed`', () => {
|
|
9
|
+
expect(isHelmReleaseActionable('failed')).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('is case-insensitive (matches Helm SDK serialisation variants)', () => {
|
|
13
|
+
expect(isHelmReleaseActionable('FAILED')).toBe(true)
|
|
14
|
+
expect(isHelmReleaseActionable('Failed')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns FALSE for the pending-* in-flight statuses', () => {
|
|
18
|
+
// These are Helm's NORMAL in-flight states during every
|
|
19
|
+
// routine install/upgrade/rollback. If we treated them as
|
|
20
|
+
// actionable, every routine install would briefly attach an
|
|
21
|
+
// alarming chevron + tooltip to its own row. Until we have
|
|
22
|
+
// release age available client-side to distinguish
|
|
23
|
+
// "transient" from "stuck > N min", we give up the
|
|
24
|
+
// stuck-controller signpost rather than alarm the common case.
|
|
25
|
+
expect(isHelmReleaseActionable('pending-install')).toBe(false)
|
|
26
|
+
expect(isHelmReleaseActionable('pending-upgrade')).toBe(false)
|
|
27
|
+
expect(isHelmReleaseActionable('pending-rollback')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns false for the success / normal statuses', () => {
|
|
31
|
+
expect(isHelmReleaseActionable('deployed')).toBe(false)
|
|
32
|
+
expect(isHelmReleaseActionable('superseded')).toBe(false)
|
|
33
|
+
expect(isHelmReleaseActionable('uninstalled')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns false for `uninstalling` (in-progress, not stuck)', () => {
|
|
37
|
+
expect(isHelmReleaseActionable('uninstalling')).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns false for null / undefined / empty', () => {
|
|
41
|
+
expect(isHelmReleaseActionable(null)).toBe(false)
|
|
42
|
+
expect(isHelmReleaseActionable(undefined)).toBe(false)
|
|
43
|
+
expect(isHelmReleaseActionable('')).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns false for unknown strings (defensive)', () => {
|
|
47
|
+
expect(isHelmReleaseActionable('mystery-status')).toBe(false)
|
|
48
|
+
expect(isHelmReleaseActionable('error')).toBe(false) // Helm uses 'failed', not 'error'
|
|
49
|
+
})
|
|
50
|
+
})
|