@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.
- package/package.json +8 -8
- package/src/App.tsx +60 -24
- package/src/api/client.authRedirect.test.ts +12 -1
- package/src/api/client.prometheusStatus.test.ts +24 -0
- package/src/api/client.ts +321 -16
- package/src/api/diagnose.ts +1 -0
- package/src/api/drain-plan.test.ts +86 -0
- package/src/api/quotas.test.ts +26 -0
- package/src/api/quotas.ts +20 -0
- package/src/api/workloadMetrics.test.ts +40 -0
- package/src/api/workloadMetrics.ts +68 -0
- package/src/components/CloudConnectFlow.tsx +179 -42
- package/src/components/CloudFunnelButton.tsx +107 -36
- package/src/components/SyncProgressPanel.tsx +73 -0
- package/src/components/audit/AuditView.tsx +2 -0
- package/src/components/cloudConnectHandoff.test.ts +140 -0
- package/src/components/cloudConnectHandoff.ts +124 -0
- package/src/components/diagnose/DiagnoseContext.tsx +2 -1
- package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
- package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
- package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
- package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
- package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
- package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
- package/src/components/diagnose/investigationEvidence/index.ts +55 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
- package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
- package/src/components/diagnose/investigationEvidence/types.ts +512 -0
- package/src/components/diagnose/target.test.ts +19 -0
- package/src/components/diagnose/target.ts +3 -2
- package/src/components/gitops/GitOpsView.tsx +7 -0
- package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
- package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
- package/src/components/resource/PrometheusCharts.tsx +4 -3
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
- package/src/components/resource/RightsizingStrip.test.ts +38 -0
- package/src/components/resource/RightsizingStrip.tsx +15 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
- package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
- package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
- package/src/components/resource/workloadMetricValues.test.ts +65 -0
- package/src/components/resource/workloadMetricValues.ts +30 -0
- package/src/components/resources/ResourcesView.tsx +44 -6
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
- package/src/components/settings/SettingsDialog.tsx +94 -14
- package/src/components/traffic/TrafficFlowList.tsx +229 -70
- package/src/components/traffic/TrafficGraph.tsx +6 -6
- package/src/components/traffic/TrafficView.tsx +10 -9
- package/src/components/traffic/trafficFilters.test.ts +28 -1
- package/src/components/traffic/trafficFilters.ts +31 -0
- package/src/components/workload/WorkloadView.tsx +19 -8
- package/src/context/ConnectionContext.tsx +48 -3
- package/src/context/DiagnoseCustomization.tsx +1 -0
- package/src/index.css +17 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/topology-namespace.test.ts +64 -0
- package/src/utils/topology-namespace.ts +18 -0
- package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
- package/src/components/diagnose/investigationEvidence.ts +0 -5572
- package/src/components/gitops/GitOpsActions.tsx +0 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
- package/src/components/gitops/ManagedResourcesList.tsx +0 -1
- package/src/components/gitops/SyncCountdown.tsx +0 -1
- package/src/components/gitops/index.ts +0 -4
- package/src/components/helm/ManifestDiffViewer.tsx +0 -69
- package/src/components/logs/JsonLogLine.tsx +0 -1
- package/src/components/logs/LogCore.tsx +0 -2
- package/src/components/logs/useLogBuffer.ts +0 -2
- package/src/components/logs/useLogSearch.ts +0 -1
- package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
- package/src/components/resources/drawer-components.tsx +0 -1
- package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
- package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/EventRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/JobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
- package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
- package/src/components/resources/renderers/VPARenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
- package/src/components/resources/renderers/argo-cells.tsx +0 -1
- package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
- package/src/components/resources/renderers/eso-cells.tsx +0 -1
- package/src/components/resources/renderers/flux-cells.tsx +0 -1
- package/src/components/resources/renderers/index.ts +0 -94
- package/src/components/resources/renderers/istio-cells.tsx +0 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
- package/src/components/resources/renderers/keda-cells.tsx +0 -1
- package/src/components/resources/renderers/knative-cells.tsx +0 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
- package/src/components/resources/renderers/traefik-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-cells.tsx +0 -1
- package/src/components/resources/renderers/trivy-shared.tsx +0 -1
- package/src/components/resources/renderers/velero-cells.tsx +0 -1
- package/src/components/resources/resource-utils-argo.ts +0 -2
- package/src/components/resources/resource-utils-certmanager.ts +0 -2
- package/src/components/resources/resource-utils-eso.ts +0 -2
- package/src/components/resources/resource-utils-flux.ts +0 -2
- package/src/components/resources/resource-utils-istio.ts +0 -2
- package/src/components/resources/resource-utils-karpenter.ts +0 -2
- package/src/components/resources/resource-utils-knative.ts +0 -2
- package/src/components/resources/resource-utils-kyverno.ts +0 -2
- package/src/components/resources/resource-utils-prometheus.ts +0 -2
- package/src/components/resources/resource-utils-traefik.ts +0 -1
- package/src/components/resources/resource-utils-trivy.ts +0 -2
- package/src/components/resources/resource-utils-velero.ts +0 -2
- package/src/components/shared/EditableYamlView.tsx +0 -24
- package/src/components/timeline/DiffViewer.tsx +0 -1
- package/src/components/traffic/index.ts +0 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
- package/src/components/ui/MetricsChart.tsx +0 -1
- 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
|
+
}
|