@skyhook-io/radar-app 1.13.5 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +321 -16
  6. package/src/api/diagnose.ts +1 -0
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +179 -42
  13. package/src/components/CloudFunnelButton.tsx +107 -36
  14. package/src/components/SyncProgressPanel.tsx +73 -0
  15. package/src/components/audit/AuditView.tsx +2 -0
  16. package/src/components/cloudConnectHandoff.test.ts +140 -0
  17. package/src/components/cloudConnectHandoff.ts +124 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  19. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  20. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  21. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  22. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  23. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  24. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  25. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  26. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  35. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  36. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  37. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  38. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  39. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  40. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  41. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  42. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  43. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  44. package/src/components/diagnose/target.test.ts +19 -0
  45. package/src/components/diagnose/target.ts +3 -2
  46. package/src/components/gitops/GitOpsView.tsx +7 -0
  47. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  48. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  49. package/src/components/resource/PrometheusCharts.tsx +4 -3
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  51. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  52. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  53. package/src/components/resource/RightsizingStrip.tsx +15 -3
  54. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  55. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  56. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  57. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  58. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  59. package/src/components/resource/workloadMetricValues.ts +30 -0
  60. package/src/components/resources/ResourcesView.tsx +44 -6
  61. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  63. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  64. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  65. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  66. package/src/components/settings/SettingsDialog.tsx +94 -14
  67. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  68. package/src/components/traffic/TrafficGraph.tsx +6 -6
  69. package/src/components/traffic/TrafficView.tsx +10 -9
  70. package/src/components/traffic/trafficFilters.test.ts +28 -1
  71. package/src/components/traffic/trafficFilters.ts +31 -0
  72. package/src/components/workload/WorkloadView.tsx +19 -8
  73. package/src/context/ConnectionContext.tsx +48 -3
  74. package/src/context/DiagnoseCustomization.tsx +1 -0
  75. package/src/index.css +17 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/topology-namespace.test.ts +64 -0
  78. package/src/utils/topology-namespace.ts +18 -0
  79. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  80. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  81. package/src/components/gitops/GitOpsActions.tsx +0 -1
  82. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  83. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  84. package/src/components/gitops/SyncCountdown.tsx +0 -1
  85. package/src/components/gitops/index.ts +0 -4
  86. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  87. package/src/components/logs/JsonLogLine.tsx +0 -1
  88. package/src/components/logs/LogCore.tsx +0 -2
  89. package/src/components/logs/useLogBuffer.ts +0 -2
  90. package/src/components/logs/useLogSearch.ts +0 -1
  91. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  92. package/src/components/resources/drawer-components.tsx +0 -1
  93. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  94. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  95. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  96. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  97. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  98. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  99. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  100. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  101. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  102. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  103. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  104. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  105. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  106. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  107. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  108. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  109. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  110. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  111. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  112. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  113. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  114. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  115. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  116. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  117. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  118. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  119. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  120. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  121. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  122. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  162. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  169. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  170. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  171. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  172. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  173. package/src/components/resources/renderers/index.ts +0 -94
  174. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  175. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  176. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  177. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  178. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  179. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  180. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  181. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  182. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  183. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  184. package/src/components/resources/resource-utils-argo.ts +0 -2
  185. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  186. package/src/components/resources/resource-utils-eso.ts +0 -2
  187. package/src/components/resources/resource-utils-flux.ts +0 -2
  188. package/src/components/resources/resource-utils-istio.ts +0 -2
  189. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  190. package/src/components/resources/resource-utils-knative.ts +0 -2
  191. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  192. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  193. package/src/components/resources/resource-utils-traefik.ts +0 -1
  194. package/src/components/resources/resource-utils-trivy.ts +0 -2
  195. package/src/components/resources/resource-utils-velero.ts +0 -2
  196. package/src/components/shared/EditableYamlView.tsx +0 -24
  197. package/src/components/timeline/DiffViewer.tsx +0 -1
  198. package/src/components/traffic/index.ts +0 -3
  199. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  200. package/src/components/ui/MetricsChart.tsx +0 -1
  201. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,96 @@
1
+ import {
2
+ ProjectionBuilder,
3
+ addEvents,
4
+ addNarrowHint,
5
+ invalidPayload,
6
+ scopeFromArgs,
7
+ sourceArgsRelevance,
8
+ } from "../observations";
9
+ import { event, nonEmptyString, record, stringArray } from "../parse";
10
+ import {
11
+ type InvestigationEventEvidence,
12
+ type InvestigationEvidenceSource,
13
+ } from "../types";
14
+
15
+ export function adaptEvents(
16
+ builder: ProjectionBuilder,
17
+ source: InvestigationEvidenceSource,
18
+ payload: unknown,
19
+ ): void {
20
+ const value = record(payload);
21
+ // The producer serializes an empty result as a nil slice, which is null.
22
+ const eventsRaw = value?.events === null ? [] : value?.events;
23
+ if (!value || !Array.isArray(eventsRaw)) {
24
+ invalidPayload(builder, source);
25
+ return;
26
+ }
27
+ const events = eventsRaw
28
+ .map(event)
29
+ .filter((item): item is InvestigationEventEvidence => Boolean(item));
30
+ if (events.length !== eventsRaw.length) {
31
+ invalidPayload(builder, source);
32
+ return;
33
+ }
34
+ addNarrowHint(builder, source, value);
35
+ // The producer answers a namespace the caller cannot read with an empty
36
+ // list and marks it. Only that case is a coverage gap. Any other complete,
37
+ // successful empty read answers the question it asked and is filed as a
38
+ // checked receipt; a gap there would contradict an events card from another
39
+ // call in the same turn. The receipt names the one remaining ambiguity for
40
+ // producers that predate the marker.
41
+ if (value.accessDenied === true) {
42
+ builder.limit(
43
+ source,
44
+ "Events",
45
+ `Events in ${scopeFromArgs(source)} are not readable with your permissions.`,
46
+ "error",
47
+ );
48
+ return;
49
+ }
50
+ // A cluster-wide read the producer narrowed to the caller's namespaces
51
+ // answers for those alone, so neither its empty receipt nor its card may
52
+ // stand for the cluster.
53
+ const narrowedTo = narrowedEventScope(value);
54
+ if (narrowedTo && events.length > 0) {
55
+ builder.limit(
56
+ source,
57
+ "Events",
58
+ `This cluster-wide events read covered only the namespaces you can read (${narrowedTo}).`,
59
+ "unknown",
60
+ );
61
+ }
62
+ addEvents(
63
+ builder,
64
+ source,
65
+ events,
66
+ `events:${source.args ?? scopeFromArgs(source)}`,
67
+ !nonEmptyString(value.narrowHint),
68
+ true,
69
+ sourceArgsRelevance(builder, source),
70
+ narrowedTo
71
+ ? {
72
+ title: "No events in the namespaces you can read",
73
+ message: `Read ${narrowedTo}. Namespaces outside your permissions were not read, so this does not clear the cluster.`,
74
+ }
75
+ : {
76
+ title: "No events in this window",
77
+ message:
78
+ "Events outside this window or its filters are not covered, and a namespace you cannot read also returns nothing.",
79
+ },
80
+ );
81
+ }
82
+ /**
83
+ * Names the namespaces a cluster-wide events read was actually narrowed to,
84
+ * or undefined when the read covered everything the query asked for.
85
+ */
86
+ function narrowedEventScope(
87
+ value: Record<string, unknown>,
88
+ ): string | undefined {
89
+ if (value.partialScope !== true) return undefined;
90
+ const namespaces = (stringArray(value.scopeNamespaces) ?? []).filter(
91
+ nonEmptyString,
92
+ );
93
+ return namespaces.length > 0
94
+ ? namespaces.join(", ")
95
+ : "the namespaces you can read";
96
+ }
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { investigationEvidenceSubjectRef } from "../index";
3
+ import { groupsOf, project, tool } from "../evidenceFixtures";
4
+
5
+ const helmRelease = {
6
+ name: "shop",
7
+ namespace: "shop",
8
+ chart: "shop",
9
+ chartVersion: "1.4.2",
10
+ appVersion: "2.0.0",
11
+ status: "deployed",
12
+ revision: 7,
13
+ updated: "2026-09-07T07:00:00Z",
14
+ description: "Upgrade complete",
15
+ resources: [
16
+ {
17
+ kind: "Deployment",
18
+ apiVersion: "apps/v1",
19
+ name: "api",
20
+ namespace: "shop",
21
+ status: "Running",
22
+ ready: "0/1",
23
+ },
24
+ {
25
+ kind: "Service",
26
+ apiVersion: "v1",
27
+ name: "api",
28
+ namespace: "shop",
29
+ status: "Active",
30
+ },
31
+ ],
32
+ };
33
+
34
+ describe("helm release adapter", () => {
35
+ it("relates a release through the resources it manages", () => {
36
+ const projection = project([
37
+ tool("helm", "get_helm_release", helmRelease, {
38
+ summary: JSON.stringify({ namespace: "shop", name: "shop" }),
39
+ }),
40
+ tool(
41
+ "helm-other",
42
+ "get_helm_release",
43
+ {
44
+ ...helmRelease,
45
+ name: "ingress",
46
+ resources: [
47
+ {
48
+ kind: "Deployment",
49
+ apiVersion: "apps/v1",
50
+ name: "api",
51
+ namespace: "ingress",
52
+ },
53
+ ],
54
+ },
55
+ { summary: JSON.stringify({ namespace: "shop", name: "ingress" }) },
56
+ ),
57
+ ]);
58
+ const releases = groupsOf(projection.groups, "helm");
59
+ expect(releases.map((group) => group.latest.relevance)).toEqual([
60
+ "producer-related",
61
+ "broader",
62
+ ]);
63
+ expect(releases[0].latest).toMatchObject({
64
+ tier: "context",
65
+ tone: "info",
66
+ title: "Helm release shop/shop",
67
+ summary: "shop 1.4.2 · deployed · revision 7",
68
+ });
69
+ expect(investigationEvidenceSubjectRef(releases[0].latest.data)).toEqual({
70
+ kind: "HelmRelease",
71
+ group: "helm.sh",
72
+ namespace: "shop",
73
+ name: "shop",
74
+ });
75
+ });
76
+
77
+ it("never fills a missing owned-resource API version from the target", () => {
78
+ const projection = project([
79
+ tool(
80
+ "helm-no-version",
81
+ "get_helm_release",
82
+ {
83
+ ...helmRelease,
84
+ resources: [{ kind: "Deployment", name: "api", namespace: "shop" }],
85
+ },
86
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
87
+ ),
88
+ tool(
89
+ "helm-other-group",
90
+ "get_helm_release",
91
+ {
92
+ ...helmRelease,
93
+ name: "shop-x",
94
+ resources: [
95
+ {
96
+ kind: "Deployment",
97
+ apiVersion: "example.io/v1",
98
+ name: "api",
99
+ namespace: "shop",
100
+ },
101
+ ],
102
+ },
103
+ { summary: JSON.stringify({ namespace: "shop", name: "shop-x" }) },
104
+ ),
105
+ ]);
106
+ expect(
107
+ groupsOf(projection.groups, "helm").map(
108
+ (group) => group.latest.relevance,
109
+ ),
110
+ ).toEqual(["broader", "broader"]);
111
+ });
112
+
113
+ it("keys a release by its storage namespace and withholds a link it cannot express", () => {
114
+ const projection = project([
115
+ tool(
116
+ "helm-a",
117
+ "get_helm_release",
118
+ { ...helmRelease, storageNamespace: "flux-a" },
119
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
120
+ ),
121
+ tool(
122
+ "helm-b",
123
+ "get_helm_release",
124
+ { ...helmRelease, storageNamespace: "flux-b", revision: 9 },
125
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
126
+ ),
127
+ ]);
128
+ const releases = groupsOf(projection.groups, "helm");
129
+ expect(releases.map((group) => group.identity)).toEqual([
130
+ "helm:flux-a:shop:shop",
131
+ "helm:flux-b:shop:shop",
132
+ ]);
133
+ expect(releases[0].latest.data).toMatchObject({
134
+ release: { storageNamespace: "flux-a" },
135
+ });
136
+ expect(investigationEvidenceSubjectRef(releases[0].latest.data)).toBe(
137
+ undefined,
138
+ );
139
+ });
140
+
141
+ it("treats a non-deployed status, a health issue, or a failed operation as adverse", () => {
142
+ const projection = project([
143
+ tool(
144
+ "helm-failed",
145
+ "get_helm_release",
146
+ { ...helmRelease, status: "failed", description: "Upgrade failed" },
147
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
148
+ ),
149
+ tool(
150
+ "helm-issue",
151
+ "get_helm_release",
152
+ {
153
+ ...helmRelease,
154
+ name: "shop-cache",
155
+ resourceHealth: "unhealthy",
156
+ healthIssue: "Deployment shop/cache has 0/1 ready replicas",
157
+ healthSummary: "1 of 2 resources unhealthy",
158
+ },
159
+ { summary: JSON.stringify({ namespace: "shop", name: "shop-cache" }) },
160
+ ),
161
+ tool(
162
+ "helm-stuck",
163
+ "get_helm_release",
164
+ {
165
+ ...helmRelease,
166
+ name: "shop-jobs",
167
+ status: "pending-upgrade",
168
+ lastOperation: {
169
+ kind: "pending",
170
+ status: "stuck_pending",
171
+ source: "helm_status",
172
+ confidence: "high",
173
+ message: "Revision 8 has been pending-upgrade for 42m",
174
+ revision: 8,
175
+ },
176
+ valuesError:
177
+ 'Radar Cloud role "viewer" cannot view Helm release values (requires member or higher)',
178
+ diffError:
179
+ 'Radar Cloud role "viewer" cannot view Helm release diffs (requires member or higher)',
180
+ valuesDiffError: "values diff failed",
181
+ notesDiffError: "notes diff failed",
182
+ resourceDiffError: "resource diff failed",
183
+ },
184
+ { summary: JSON.stringify({ namespace: "shop", name: "shop-jobs" }) },
185
+ ),
186
+ ]);
187
+ const releases = groupsOf(projection.groups, "helm");
188
+ expect(
189
+ releases.map((group) => [group.latest.tier, group.latest.tone]),
190
+ ).toEqual([
191
+ ["supporting", "error"],
192
+ ["supporting", "warning"],
193
+ ["supporting", "warning"],
194
+ ]);
195
+ expect(releases[1].latest.summary).toBe(
196
+ "deployed · Deployment shop/cache has 0/1 ready replicas",
197
+ );
198
+ expect(releases[2].latest.data).toMatchObject({
199
+ type: "helm",
200
+ release: {
201
+ lastOperation: { kind: "pending", status: "stuck_pending" },
202
+ },
203
+ });
204
+ // Every comparison the producer could not finish has to reach the reader:
205
+ // three of these are role denials, and a release card that omits a withheld
206
+ // diff without saying so reads as a complete one.
207
+ expect(projection.limitations).toEqual([
208
+ expect.objectContaining({ source: "Helm values", kind: "error" }),
209
+ expect.objectContaining({ source: "Helm values diff", kind: "error" }),
210
+ expect.objectContaining({ source: "Helm manifest diff", kind: "error" }),
211
+ expect.objectContaining({ source: "Helm notes diff", kind: "error" }),
212
+ expect.objectContaining({ source: "Helm resource diff", kind: "error" }),
213
+ ]);
214
+ });
215
+
216
+ it("rejects malformed release payloads", () => {
217
+ const projection = project([
218
+ tool(
219
+ "helm-bad",
220
+ "get_helm_release",
221
+ { ...helmRelease, revision: "7" },
222
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
223
+ ),
224
+ tool(
225
+ "helm-bad-resource",
226
+ "get_helm_release",
227
+ { ...helmRelease, resources: [{ kind: "Deployment" }] },
228
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
229
+ ),
230
+ tool(
231
+ "helm-bad-operation",
232
+ "get_helm_release",
233
+ { ...helmRelease, lastOperation: { kind: "rollback" } },
234
+ { summary: JSON.stringify({ namespace: "shop", name: "shop" }) },
235
+ ),
236
+ ]);
237
+ expect(projection.groups).toHaveLength(0);
238
+ expect(
239
+ projection.limitations.map((limitation) => limitation.source),
240
+ ).toEqual(["Helm release", "Helm resources", "Helm operation"]);
241
+ });
242
+ });
@@ -0,0 +1,208 @@
1
+ import { apiVersionToGroup } from "../../../../utils/navigation";
2
+ import {
3
+ ProjectionBuilder,
4
+ evidenceTierForRelevance,
5
+ invalidPayload,
6
+ resourceMatchesTarget,
7
+ } from "../observations";
8
+ import { nonEmptyString, record } from "../parse";
9
+ import {
10
+ type InvestigationEvidenceRelevance,
11
+ type InvestigationEvidenceSource,
12
+ type InvestigationHelmOperation,
13
+ type InvestigationHelmOwnedResource,
14
+ type InvestigationHelmRelease,
15
+ } from "../types";
16
+
17
+ function helmOwnedResource(
18
+ value: unknown,
19
+ ): InvestigationHelmOwnedResource | undefined {
20
+ const candidate = record(value);
21
+ if (
22
+ !candidate ||
23
+ !nonEmptyString(candidate.kind) ||
24
+ !nonEmptyString(candidate.name) ||
25
+ typeof candidate.namespace !== "string"
26
+ ) {
27
+ return undefined;
28
+ }
29
+ for (const field of [
30
+ "apiVersion",
31
+ "status",
32
+ "ready",
33
+ "message",
34
+ "summary",
35
+ "issue",
36
+ ] as const) {
37
+ if (candidate[field] !== undefined && typeof candidate[field] !== "string")
38
+ return undefined;
39
+ }
40
+ return candidate as unknown as InvestigationHelmOwnedResource;
41
+ }
42
+ function helmOperation(value: unknown): InvestigationHelmOperation | undefined {
43
+ const candidate = record(value);
44
+ if (
45
+ !candidate ||
46
+ !nonEmptyString(candidate.kind) ||
47
+ !nonEmptyString(candidate.status) ||
48
+ typeof candidate.message !== "string"
49
+ ) {
50
+ return undefined;
51
+ }
52
+ for (const field of [
53
+ "revision",
54
+ "failedRevision",
55
+ "rollbackRevision",
56
+ ] as const) {
57
+ if (candidate[field] !== undefined && typeof candidate[field] !== "number")
58
+ return undefined;
59
+ }
60
+ if (candidate.updated !== undefined && typeof candidate.updated !== "string")
61
+ return undefined;
62
+ return candidate as unknown as InvestigationHelmOperation;
63
+ }
64
+ export function adaptHelmRelease(
65
+ builder: ProjectionBuilder,
66
+ source: InvestigationEvidenceSource,
67
+ payload: unknown,
68
+ ): void {
69
+ const value = record(payload);
70
+ if (
71
+ !value ||
72
+ !nonEmptyString(value.name) ||
73
+ !nonEmptyString(value.namespace) ||
74
+ typeof value.chart !== "string" ||
75
+ typeof value.chartVersion !== "string" ||
76
+ !nonEmptyString(value.status) ||
77
+ typeof value.revision !== "number" ||
78
+ typeof value.updated !== "string"
79
+ ) {
80
+ invalidPayload(builder, source);
81
+ return;
82
+ }
83
+ for (const field of [
84
+ "appVersion",
85
+ "description",
86
+ "storageNamespace",
87
+ "resourceHealth",
88
+ "healthIssue",
89
+ "healthSummary",
90
+ "managedByFluxHelmRelease",
91
+ ] as const) {
92
+ if (value[field] !== undefined && typeof value[field] !== "string") {
93
+ invalidPayload(builder, source);
94
+ return;
95
+ }
96
+ }
97
+ const resourcesRaw =
98
+ value.resources === undefined || value.resources === null
99
+ ? []
100
+ : value.resources;
101
+ if (!Array.isArray(resourcesRaw)) {
102
+ invalidPayload(builder, source, "Helm resources");
103
+ return;
104
+ }
105
+ const resources = resourcesRaw
106
+ .map(helmOwnedResource)
107
+ .filter((item): item is InvestigationHelmOwnedResource => Boolean(item));
108
+ if (resources.length !== resourcesRaw.length) {
109
+ invalidPayload(builder, source, "Helm resources");
110
+ return;
111
+ }
112
+ const lastOperation =
113
+ value.lastOperation === undefined
114
+ ? undefined
115
+ : helmOperation(value.lastOperation);
116
+ if (value.lastOperation !== undefined && !lastOperation) {
117
+ invalidPayload(builder, source, "Helm operation");
118
+ return;
119
+ }
120
+ const release: InvestigationHelmRelease = {
121
+ name: value.name,
122
+ namespace: value.namespace,
123
+ chart: value.chart,
124
+ chartVersion: value.chartVersion,
125
+ appVersion: value.appVersion as string | undefined,
126
+ status: value.status,
127
+ revision: value.revision,
128
+ updated: value.updated,
129
+ description: value.description as string | undefined,
130
+ ...(nonEmptyString(value.storageNamespace)
131
+ ? { storageNamespace: value.storageNamespace }
132
+ : {}),
133
+ resourceHealth: value.resourceHealth as string | undefined,
134
+ healthIssue: value.healthIssue as string | undefined,
135
+ healthSummary: value.healthSummary as string | undefined,
136
+ managedByFluxHelmRelease: value.managedByFluxHelmRelease as
137
+ string | undefined,
138
+ lastOperation,
139
+ resources,
140
+ };
141
+ // The release manages the target when the target is among the resources
142
+ // Helm rendered for it; a release is never the investigated object itself.
143
+ // Without an API version the owned row cannot tell colliding kinds apart
144
+ // (CNPG vs CAPI Cluster), so it establishes nothing.
145
+ const relevance: InvestigationEvidenceRelevance = resources.some(
146
+ (owned) =>
147
+ owned.apiVersion !== undefined &&
148
+ resourceMatchesTarget(builder.target, {
149
+ kind: owned.kind,
150
+ group: apiVersionToGroup(owned.apiVersion),
151
+ namespace: owned.namespace || undefined,
152
+ name: owned.name,
153
+ }),
154
+ )
155
+ ? "producer-related"
156
+ : "broader";
157
+ const status = release.status.toLowerCase();
158
+ const operationFailed =
159
+ lastOperation?.status === "failed" ||
160
+ lastOperation?.status === "stuck_pending";
161
+ const adverse =
162
+ status !== "deployed" || Boolean(release.healthIssue) || operationFailed;
163
+ const chartLabel = `${release.chart}${release.chartVersion ? ` ${release.chartVersion}` : ""}`;
164
+ // Helm keys a release by where its metadata is stored; two releases with
165
+ // one name and namespace can live in different storage namespaces.
166
+ const storage = release.storageNamespace ?? release.namespace;
167
+ builder.observe(
168
+ `helm:${storage}:${release.namespace}:${release.name}`,
169
+ "helm",
170
+ source,
171
+ {
172
+ tier: evidenceTierForRelevance(
173
+ adverse ? "supporting" : "context",
174
+ relevance,
175
+ ),
176
+ relevance,
177
+ tone:
178
+ status.includes("failed") || lastOperation?.status === "failed"
179
+ ? "error"
180
+ : adverse
181
+ ? "warning"
182
+ : "info",
183
+ title: `Helm release ${release.namespace}/${release.name}`,
184
+ summary: release.healthIssue
185
+ ? `${release.status} · ${release.healthIssue}`
186
+ : `${chartLabel} · ${release.status} · revision ${release.revision}`,
187
+ data: { type: "helm", release },
188
+ },
189
+ );
190
+ // Every comparison the producer attempted and could not finish, not just the
191
+ // values read. Four of these are Cloud-role denials, so dropping any of them
192
+ // lets a reader see a release card whose comparison was withheld and take it
193
+ // for complete. The labels follow the producer's own fields: `diff` is the
194
+ // manifest, `valuesDiff` the values — naming one for the other sends a reader
195
+ // to the wrong thing.
196
+ for (const [field, label] of [
197
+ ["valuesError", "Helm values"],
198
+ ["valuesDiffError", "Helm values diff"],
199
+ ["diffError", "Helm manifest diff"],
200
+ ["notesDiffError", "Helm notes diff"],
201
+ ["resourceDiffError", "Helm resource diff"],
202
+ ] as const) {
203
+ const message = value[field];
204
+ if (nonEmptyString(message)) {
205
+ builder.limit(source, label, message, "error");
206
+ }
207
+ }
208
+ }