@skyhook-io/k8s-ui 1.6.0 → 1.6.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/package.json +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- 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 +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- 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
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
|
@@ -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
|
+
})
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Theme-aware badge classes for RBAC display (verbs, resources, API groups).
|
|
2
|
+
// Reuses the same light/dark pairs as Badge.tsx's SEVERITY/KIND maps so the
|
|
3
|
+
// RBAC views read with the same contrast as the rest of the app — the
|
|
4
|
+
// inherited `text-*-400` foreground without a light counterpart was washing
|
|
5
|
+
// out on light backgrounds.
|
|
6
|
+
//
|
|
7
|
+
// Centralized here so SA / Pod / RoleBinding renderers and the
|
|
8
|
+
// MyPermissions dialog all stay in sync; previous duplicate copies drifted.
|
|
9
|
+
|
|
10
|
+
import { BADGE_SEVERITY_COLORS, BADGE_KIND_COLORS } from '../components/ui/Badge'
|
|
11
|
+
import {
|
|
12
|
+
RBAC_BLAST_READ_VERBS,
|
|
13
|
+
RBAC_BLAST_WRITE_VERBS,
|
|
14
|
+
RBAC_BLAST_DELETE_VERBS,
|
|
15
|
+
RBAC_BLAST_ESCALATION_VERBS,
|
|
16
|
+
} from './rbac-blast-radius'
|
|
17
|
+
|
|
18
|
+
/** Theme-aware classes for an RBAC verb chip. */
|
|
19
|
+
export function rbacVerbBadgeClass(verb: string): string {
|
|
20
|
+
if (verb === '*') return BADGE_SEVERITY_COLORS.error
|
|
21
|
+
if (RBAC_BLAST_ESCALATION_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
|
|
22
|
+
if (RBAC_BLAST_DELETE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
|
|
23
|
+
if (RBAC_BLAST_WRITE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.warning
|
|
24
|
+
if (RBAC_BLAST_READ_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.success
|
|
25
|
+
return BADGE_SEVERITY_COLORS.info
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Theme-aware classes for an RBAC resource chip (pods, configmaps, …). */
|
|
29
|
+
export const rbacResourceBadgeClass: string =
|
|
30
|
+
BADGE_KIND_COLORS.HTTPRoute // purple-100/-700 in light, purple-950/-400 in dark
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Theme-aware kind badge for the four RBAC kinds. We can't put these in
|
|
34
|
+
* Badge.tsx's KIND map without churn there, so reuse the closest existing
|
|
35
|
+
* palette entry: blue/info for namespaced (Role/RoleBinding), purple for
|
|
36
|
+
* cluster-scoped (ClusterRole/ClusterRoleBinding).
|
|
37
|
+
*/
|
|
38
|
+
export function rbacKindBadgeClass(kind: string): string {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case 'Role':
|
|
41
|
+
case 'RoleBinding':
|
|
42
|
+
return BADGE_KIND_COLORS.Service // blue-100/-700 light, blue-950/-400 dark
|
|
43
|
+
case 'ClusterRole':
|
|
44
|
+
case 'ClusterRoleBinding':
|
|
45
|
+
return BADGE_KIND_COLORS.HelmRelease // purple-100/-700 light, purple-950/-400 dark
|
|
46
|
+
default:
|
|
47
|
+
return BADGE_KIND_COLORS.HelmRelease
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Theme-aware classes for an API group chip (notification.toolkit.fluxcd.io, …). */
|
|
52
|
+
export const rbacApiGroupBadgeClass: string =
|
|
53
|
+
'bg-theme-hover/60 text-theme-text-secondary border-theme-border'
|
|
54
|
+
|
|
55
|
+
/** Theme-aware classes for a resourceName chip (rare — `resourceNames: [...]`). */
|
|
56
|
+
export const rbacResourceNameBadgeClass: string =
|
|
57
|
+
BADGE_KIND_COLORS.PersistentVolumeClaim // cyan, distinct from resources
|
|
58
|
+
|
|
59
|
+
/** Theme-aware classes for a non-resource URL chip (/healthz etc). */
|
|
60
|
+
export const rbacNonResourceUrlBadgeClass: string =
|
|
61
|
+
BADGE_KIND_COLORS.Application // orange, distinct from everything above
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { detectBlastRadius, rulePermissivenessScore } from './rbac-blast-radius'
|
|
3
|
+
import type { RBACBindingRules, RBACSubjectResponse } from '../types/rbac'
|
|
4
|
+
|
|
5
|
+
// Skeleton helpers — keep fixtures terse so each test reads as a contract.
|
|
6
|
+
function binding(over: Partial<RBACBindingRules> = {}): RBACBindingRules {
|
|
7
|
+
return {
|
|
8
|
+
binding: { kind: 'RoleBinding', namespace: 'default', name: 'b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
|
|
9
|
+
role: { kind: 'Role', namespace: 'default', name: 'r' },
|
|
10
|
+
rules: [],
|
|
11
|
+
...over,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function subject(direct: RBACBindingRules[] = [], inherited: { groupName: string; bindings: RBACBindingRules[] }[] = []): RBACSubjectResponse {
|
|
16
|
+
return {
|
|
17
|
+
subject: { kind: 'ServiceAccount', namespace: 'default', name: 'app-sa' },
|
|
18
|
+
direct,
|
|
19
|
+
inheritedFromGroups: inherited,
|
|
20
|
+
flat: [],
|
|
21
|
+
truncated: false,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('detectBlastRadius', () => {
|
|
26
|
+
// The five documented triggers; each is a security-UX invariant the PR
|
|
27
|
+
// description and inline comments call load-bearing. If any of these
|
|
28
|
+
// silently regresses, the Pod blast-radius banner stops alarming on the
|
|
29
|
+
// exact scenarios it was added for.
|
|
30
|
+
|
|
31
|
+
it('flags cluster-admin role-name match', () => {
|
|
32
|
+
const b = binding({ role: { kind: 'ClusterRole', name: 'cluster-admin', namespace: '' } })
|
|
33
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
34
|
+
expect(reasons).toHaveLength(1)
|
|
35
|
+
expect(reasons[0].reason).toMatch(/cluster-admin/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('flags verb wildcards', () => {
|
|
39
|
+
const b = binding({
|
|
40
|
+
rules: [{ verbs: ['*'], resources: ['pods'], apiGroups: [''] }],
|
|
41
|
+
})
|
|
42
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
43
|
+
expect(reasons).toHaveLength(1)
|
|
44
|
+
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('flags escalation verbs', () => {
|
|
48
|
+
const b = binding({
|
|
49
|
+
rules: [{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }],
|
|
50
|
+
})
|
|
51
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
52
|
+
expect(reasons).toHaveLength(1)
|
|
53
|
+
expect(reasons[0].reason).toMatch(/escalate/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('flags impersonate and bind too', () => {
|
|
57
|
+
const b1 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b1', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['impersonate'], resources: ['users'], apiGroups: [''] }] })
|
|
58
|
+
const b2 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b2', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['bind'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }] })
|
|
59
|
+
const reasons = detectBlastRadius(subject([b1, b2]))
|
|
60
|
+
expect(reasons).toHaveLength(2)
|
|
61
|
+
expect(reasons[0].reason).toMatch(/impersonate/)
|
|
62
|
+
expect(reasons[1].reason).toMatch(/bind/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('flags cluster-wide "create pods" but NOT namespace-scoped "create pods"', () => {
|
|
66
|
+
// The cluster-wide form is the lateral-movement vector — a Pod with
|
|
67
|
+
// namespace-scoped pod-create can only move within its namespace.
|
|
68
|
+
const cluster = binding({
|
|
69
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
70
|
+
rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
|
|
71
|
+
})
|
|
72
|
+
const namespaced = binding({
|
|
73
|
+
binding: { kind: 'RoleBinding', namespace: 'default', name: 'ns-b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
|
|
74
|
+
rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
|
|
75
|
+
})
|
|
76
|
+
expect(detectBlastRadius(subject([cluster]))).toHaveLength(1)
|
|
77
|
+
expect(detectBlastRadius(subject([namespaced]))).toHaveLength(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does NOT flag resource-only wildcards', () => {
|
|
81
|
+
// The `view` ClusterRole grants get/list/watch on `*` resources in
|
|
82
|
+
// every fluxcd CRD. Flagging this would alarm on every authenticated
|
|
83
|
+
// SA — calibration call documented in the source.
|
|
84
|
+
const b = binding({
|
|
85
|
+
rules: [{ verbs: ['get', 'list', 'watch'], resources: ['*'], apiGroups: ['source.toolkit.fluxcd.io'] }],
|
|
86
|
+
})
|
|
87
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('dedupes by binding identity when multiple risky rules match', () => {
|
|
91
|
+
const b = binding({
|
|
92
|
+
rules: [
|
|
93
|
+
{ verbs: ['*'], resources: ['pods'], apiGroups: [''] },
|
|
94
|
+
{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
98
|
+
expect(reasons).toHaveLength(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('includes inherited-from-group bindings, not just direct', () => {
|
|
102
|
+
const b = binding({
|
|
103
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'inherited', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
104
|
+
rules: [{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] }],
|
|
105
|
+
})
|
|
106
|
+
const reasons = detectBlastRadius(subject([], [{ groupName: 'system:authenticated', bindings: [b] }]))
|
|
107
|
+
expect(reasons).toHaveLength(1)
|
|
108
|
+
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns empty for benign bindings', () => {
|
|
112
|
+
const b = binding({
|
|
113
|
+
rules: [{ verbs: ['get', 'list'], resources: ['configmaps'], apiGroups: [''] }],
|
|
114
|
+
})
|
|
115
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('rulePermissivenessScore', () => {
|
|
120
|
+
it('scores wildcards highest', () => {
|
|
121
|
+
const wildcard = rulePermissivenessScore({ verbs: ['*'], resources: ['*'], apiGroups: ['*'] })
|
|
122
|
+
const benign = rulePermissivenessScore({ verbs: ['get'], resources: ['configmaps'], apiGroups: [''] })
|
|
123
|
+
expect(wildcard).toBeGreaterThan(benign)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('boosts cluster-wide "create pods"', () => {
|
|
127
|
+
const createPods = rulePermissivenessScore({ verbs: ['create'], resources: ['pods'], apiGroups: [''] })
|
|
128
|
+
const createOthers = rulePermissivenessScore({ verbs: ['create'], resources: ['configmaps'], apiGroups: [''] })
|
|
129
|
+
expect(createPods).toBeGreaterThan(createOthers)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('scores escalation verbs higher than plain writes', () => {
|
|
133
|
+
const escalate = rulePermissivenessScore({ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] })
|
|
134
|
+
const patch = rulePermissivenessScore({ verbs: ['patch'], resources: ['configmaps'], apiGroups: [''] })
|
|
135
|
+
expect(escalate).toBeGreaterThan(patch)
|
|
136
|
+
})
|
|
137
|
+
})
|