@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.
Files changed (79) hide show
  1. package/package.json +7 -2
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  25. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  26. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  27. package/src/components/gitops/RollbackDialog.tsx +117 -0
  28. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  29. package/src/components/gitops/detail-helpers.test.ts +97 -0
  30. package/src/components/gitops/detail-helpers.ts +112 -0
  31. package/src/components/gitops/index.ts +52 -1
  32. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  33. package/src/components/gitops/tree/index.ts +2 -0
  34. package/src/components/gitops/tree/merge.test.ts +240 -0
  35. package/src/components/gitops/tree/merge.ts +160 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  37. package/src/components/resources/ResourcesView.tsx +403 -69
  38. package/src/components/resources/index.ts +1 -0
  39. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  40. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  41. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  42. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  43. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  44. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  45. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  46. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  48. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  49. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  50. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  52. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  53. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  54. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  55. package/src/components/resources/renderers/index.ts +8 -0
  56. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  57. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  58. package/src/components/resources/resource-utils-flux.ts +14 -0
  59. package/src/components/resources/resource-utils.ts +1 -0
  60. package/src/components/resources/resources-column-filter.test.ts +74 -0
  61. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  62. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  63. package/src/components/ui/YamlEditor.tsx +28 -15
  64. package/src/components/ui/drawer-components.tsx +8 -20
  65. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  66. package/src/components/workload/WorkloadView.tsx +2 -2
  67. package/src/index.ts +3 -0
  68. package/src/types/core.ts +31 -6
  69. package/src/types/index.ts +1 -0
  70. package/src/types/rbac.ts +99 -0
  71. package/src/utils/api-resources.ts +9 -1
  72. package/src/utils/badge-colors.ts +25 -0
  73. package/src/utils/gitops-owner.test.ts +70 -111
  74. package/src/utils/gitops-owner.ts +37 -74
  75. package/src/utils/helm-status.test.ts +50 -0
  76. package/src/utils/index.ts +1 -0
  77. package/src/utils/rbac-badges.ts +61 -0
  78. package/src/utils/rbac-blast-radius.test.ts +137 -0
  79. 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
- const FLUX_HELM_NAME = 'helm.toolkit.fluxcd.io/name'
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
- function resource(labels: Record<string, string> = {}, annotations: Record<string, string> = {}) {
13
- return { metadata: { labels, annotations } }
14
- }
5
+ import { gitOpsOwnerFromRelationships } from './gitops-owner'
15
6
 
16
- describe('detectGitOpsOwner', () => {
17
- it('returns null for non-objects', () => {
18
- expect(detectGitOpsOwner(null)).toBeNull()
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 no GitOps labels/annotations present', () => {
24
- expect(detectGitOpsOwner(resource({ app: 'foo' }))).toBeNull()
12
+ it('returns null when managedBy is absent', () => {
13
+ expect(gitOpsOwnerFromRelationships({})).toBeNull()
25
14
  })
26
15
 
27
- describe('Flux HelmRelease labels', () => {
28
- it('extracts name + namespace', () => {
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
- describe('Flux Kustomize labels', () => {
54
- it('extracts name + namespace', () => {
55
- const got = detectGitOpsOwner(resource({
56
- [FLUX_KUSTOMIZE_NAME]: 'infra',
57
- [FLUX_KUSTOMIZE_NS]: 'flux-system',
58
- }))
59
- expect(got).toEqual({ tool: 'fluxcd', kind: 'kustomizations', namespace: 'flux-system', name: 'infra' })
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
- describe('Argo tracking-id annotation', () => {
64
- it('parses the namespaced form (<ns>_<name>:...)', () => {
65
- const got = detectGitOpsOwner(resource({}, {
66
- [ARGO_TRACKING_ID]: 'argocd_my-app:apps/Deployment:default/web',
67
- }))
68
- expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'my-app' })
69
- })
70
-
71
- it('parses the legacy single-name form (no underscore)', () => {
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
- it('wins over the bare instance label (most authoritative)', () => {
91
- const got = detectGitOpsOwner(resource(
92
- { [ARGO_INSTANCE]: 'wrong-app' },
93
- { [ARGO_TRACKING_ID]: 'argocd_right-app:apps/Deployment:default/web' },
94
- ))
95
- expect(got?.name).toBe('right-app')
96
- expect(got?.namespace).toBe('argocd')
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
- describe('Argo instance label fallback', () => {
101
- it('extracts name from argocd-specific instance label', () => {
102
- const got = detectGitOpsOwner(resource({ [ARGO_INSTANCE]: 'guestbook' }))
103
- expect(got).toEqual({ tool: 'argocd', kind: 'applications', namespace: '', name: 'guestbook' })
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
- it('does NOT fall back to standard k8s instance label', () => {
107
- // app.kubernetes.io/instance is stamped by virtually every Helm chart.
108
- // Treating it as an Argo signal produced a false "Managed by <release>"
109
- // chip on plain Helm-installed resources, so detection now requires an
110
- // Argo-specific signal (tracking-id annotation or argocd.argoproj.io/instance).
111
- expect(detectGitOpsOwner(resource({ [HELM_INSTANCE]: 'guestbook-healthy' }))).toBeNull()
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
- it('argocd-specific label still wins when both labels present', () => {
115
- const got = detectGitOpsOwner(resource({
116
- [ARGO_INSTANCE]: 'argo-pick',
117
- [HELM_INSTANCE]: 'helm-pick',
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
- describe('Flux labels beat Argo annotations', () => {
124
- it('Flux HelmRelease wins over Argo tracking-id', () => {
125
- const got = detectGitOpsOwner(resource(
126
- {
127
- [FLUX_HELM_NAME]: 'podinfo',
128
- [FLUX_HELM_NS]: 'flux-system',
129
- },
130
- { [ARGO_TRACKING_ID]: 'argocd_some-app:apps/Deployment:default/web' },
131
- ))
132
- expect(got?.tool).toBe('fluxcd')
133
- expect(got?.kind).toBe('helmreleases')
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
- // Detect whether a Kubernetes resource is managed by a GitOps controller
2
- // (ArgoCD or FluxCD) and return a navigable ref to its owning GitOps CR so the
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
- // Precedence (most-specific wins):
6
- // 1. Flux HelmRelease labels
7
- // 2. Flux Kustomize labels
8
- // 3. Argo tracking-id annotation
9
- // 4. Argo-specific instance label
10
- // 5. Standard k8s instance label (best-effort; false positives possible)
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
- const ARGO_TRACKING_ID_ANNOTATION = 'argocd.argoproj.io/tracking-id'
24
- const ARGO_INSTANCE_LABEL = 'argocd.argoproj.io/instance'
25
- // app.kubernetes.io/instance is intentionally NOT a fallback signal here.
26
- // It's the standard k8s recommended label and stamped by virtually every
27
- // Helm chart in existence, not just Argo. Treating it as an Argo ownership
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
- const trackingID = annotations[ARGO_TRACKING_ID_ANNOTATION]
57
- if (trackingID) {
58
- const parsed = parseArgoTrackingID(trackingID)
59
- if (parsed) {
60
- return { tool: 'argocd', kind: 'applications', namespace: parsed.namespace, name: parsed.name }
61
- }
62
- }
63
-
64
- const instance = labels[ARGO_INSTANCE_LABEL]
65
- if (instance) {
66
- // App namespace unknown without tracking-id; emit empty so the consumer can
67
- // either skip the link or default to a well-known namespace.
68
- return { tool: 'argocd', kind: 'applications', namespace: '', name: instance }
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
- // Argo CD writes its tracking-id in one of two forms depending on whether
75
- // installationID / namespaced-install is configured:
76
- // "<appName>:<group>/<kind>:<resourceNs>/<resourceName>" default
77
- // "<appNamespace>_<appName>:<group>/<kind>:<resourceNs>/<resourceName>" namespaced install
78
- // We accept both. The legacy single-name form yields an empty namespace so the
79
- // caller can route to a "find this app" search instead of guessing.
80
- function parseArgoTrackingID(value: string): { namespace: string; name: string } | null {
81
- const firstColon = value.indexOf(':')
82
- if (firstColon < 0) return null
83
- const head = value.slice(0, firstColon)
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
+ })
@@ -16,3 +16,4 @@ export * from './view-transition'
16
16
  export * from './validators'
17
17
  export * from './gitops-owner'
18
18
  export * from './gitops-route'
19
+ export * from './rbac-badges'
@@ -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
+ })