@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,935 @@
|
|
|
1
|
+
import { type Issue, type IssueRecentChange } from "@skyhook-io/k8s-ui";
|
|
2
|
+
import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
|
|
3
|
+
import { type TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
|
|
4
|
+
import { apiVersionToGroup } from "../../../../utils/navigation";
|
|
5
|
+
import {
|
|
6
|
+
type DiagnosisDNSContext,
|
|
7
|
+
type DiagnosisPodContainerRef,
|
|
8
|
+
type DiagnosisPodLogEntry,
|
|
9
|
+
type DiagnosisResourceRef,
|
|
10
|
+
type DiagnosisStartupBlocker,
|
|
11
|
+
diagnosisSeverityTone,
|
|
12
|
+
} from "../../diagnoseEvidenceTypes";
|
|
13
|
+
import { metricsWindowLabel } from "./prometheus";
|
|
14
|
+
import {
|
|
15
|
+
ProjectionBuilder,
|
|
16
|
+
addChanges,
|
|
17
|
+
addEvents,
|
|
18
|
+
addIssueObservation,
|
|
19
|
+
addLogs,
|
|
20
|
+
addNarrowHint,
|
|
21
|
+
addResourceObservation,
|
|
22
|
+
contextFrom,
|
|
23
|
+
evidenceTierForRelevance,
|
|
24
|
+
invalidPayload,
|
|
25
|
+
isDiagnosableWorkloadKind,
|
|
26
|
+
relevanceForResource,
|
|
27
|
+
resourceMatchesTarget,
|
|
28
|
+
scopeFromArgs,
|
|
29
|
+
} from "../observations";
|
|
30
|
+
import {
|
|
31
|
+
crashCause,
|
|
32
|
+
diagnosisChangeContext,
|
|
33
|
+
event,
|
|
34
|
+
gitOpsDiagnosis,
|
|
35
|
+
issue,
|
|
36
|
+
kubernetesResource,
|
|
37
|
+
networkEvidence,
|
|
38
|
+
nonEmptyString,
|
|
39
|
+
nonNegativeInteger,
|
|
40
|
+
parseJSON,
|
|
41
|
+
parseLogEntry,
|
|
42
|
+
podContainerRef,
|
|
43
|
+
recentChange,
|
|
44
|
+
record,
|
|
45
|
+
startupBlocker,
|
|
46
|
+
stringArray,
|
|
47
|
+
timeSeries,
|
|
48
|
+
} from "../parse";
|
|
49
|
+
import {
|
|
50
|
+
type InvestigationEventEvidence,
|
|
51
|
+
type InvestigationEvidenceRelevance,
|
|
52
|
+
type InvestigationEvidenceSource,
|
|
53
|
+
type InvestigationMetricsEvidence,
|
|
54
|
+
} from "../types";
|
|
55
|
+
|
|
56
|
+
function diagnoseCrashQueryCoversTarget(
|
|
57
|
+
source: InvestigationEvidenceSource,
|
|
58
|
+
): boolean {
|
|
59
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
60
|
+
if (!args) return false;
|
|
61
|
+
|
|
62
|
+
// Crash retirement is target-wide. It therefore requires the producer's
|
|
63
|
+
// normal all-container, full-time-window read. A caller-selected container,
|
|
64
|
+
// since window, or shorter-than-default tail can validate that slice only;
|
|
65
|
+
// it cannot clear a smoking gun from a stream it never revisited.
|
|
66
|
+
if (nonEmptyString(args.container) || nonEmptyString(args.since))
|
|
67
|
+
return false;
|
|
68
|
+
if (args.tail_lines === undefined) return true;
|
|
69
|
+
return (
|
|
70
|
+
nonNegativeInteger(args.tail_lines) &&
|
|
71
|
+
(args.tail_lines === 0 || args.tail_lines >= 100)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A missing crash candidate is meaningful only when diagnose actually read the
|
|
76
|
+
* complete log surface that its crash classifier consumes. Keep this tied to
|
|
77
|
+
* the current producer contract: a partial pod sample, a capped response, or a
|
|
78
|
+
* failed stream is absence of evidence and must not clear earlier crash proof.
|
|
79
|
+
*/
|
|
80
|
+
function diagnoseCrashCoverageComplete(
|
|
81
|
+
value: Record<string, unknown>,
|
|
82
|
+
): boolean {
|
|
83
|
+
if (
|
|
84
|
+
!nonNegativeInteger(value.pods) ||
|
|
85
|
+
value.pods === 0 ||
|
|
86
|
+
value.logsError !== undefined ||
|
|
87
|
+
(value.crashCauseTruncated !== undefined &&
|
|
88
|
+
typeof value.crashCauseTruncated !== "boolean") ||
|
|
89
|
+
value.crashCauseTruncated === true
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const coverage = record(value.logCoverage);
|
|
95
|
+
if (
|
|
96
|
+
!coverage ||
|
|
97
|
+
!nonNegativeInteger(coverage.resolvedPods) ||
|
|
98
|
+
!nonNegativeInteger(coverage.selectedPods) ||
|
|
99
|
+
!nonNegativeInteger(coverage.shownLines) ||
|
|
100
|
+
!nonNegativeInteger(coverage.totalLines) ||
|
|
101
|
+
!nonNegativeInteger(coverage.shownPods) ||
|
|
102
|
+
!nonNegativeInteger(coverage.totalPods) ||
|
|
103
|
+
coverage.resolvedPods !== value.pods ||
|
|
104
|
+
coverage.selectedPods !== coverage.resolvedPods ||
|
|
105
|
+
(coverage.selectionTruncated !== undefined &&
|
|
106
|
+
typeof coverage.selectionTruncated !== "boolean") ||
|
|
107
|
+
coverage.selectionTruncated === true ||
|
|
108
|
+
(coverage.contentTruncated !== undefined &&
|
|
109
|
+
typeof coverage.contentTruncated !== "boolean") ||
|
|
110
|
+
coverage.contentTruncated === true ||
|
|
111
|
+
coverage.shownLines !== coverage.totalLines ||
|
|
112
|
+
coverage.shownPods !== coverage.totalPods
|
|
113
|
+
) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const currentRaw = value.logsCurrent;
|
|
118
|
+
const previousRaw = value.logsPrevious;
|
|
119
|
+
if (
|
|
120
|
+
!Array.isArray(currentRaw) ||
|
|
121
|
+
currentRaw.length === 0 ||
|
|
122
|
+
!Array.isArray(previousRaw)
|
|
123
|
+
) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const current = currentRaw.map(parseLogEntry);
|
|
128
|
+
const previous = previousRaw.map(parseLogEntry);
|
|
129
|
+
if (
|
|
130
|
+
current.some(
|
|
131
|
+
(entry) => !entry || entry.error !== undefined || !entry.logs,
|
|
132
|
+
) ||
|
|
133
|
+
previous.some((entry) => !entry || !entry.logs)
|
|
134
|
+
) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const currentEntries = current as DiagnosisPodLogEntry[];
|
|
139
|
+
const previousEntries = previous as DiagnosisPodLogEntry[];
|
|
140
|
+
const streamKey = (entry: DiagnosisPodContainerRef) =>
|
|
141
|
+
`${entry.pod}\u0000${entry.container}`;
|
|
142
|
+
const currentKeys = new Set(currentEntries.map(streamKey));
|
|
143
|
+
const previousKeys = new Set(previousEntries.map(streamKey));
|
|
144
|
+
if (
|
|
145
|
+
currentKeys.size !== current.length ||
|
|
146
|
+
previousKeys.size !== previous.length ||
|
|
147
|
+
currentKeys.size !== previousKeys.size ||
|
|
148
|
+
[...currentKeys].some((key) => !previousKeys.has(key))
|
|
149
|
+
) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const absencesRaw = value.expectedPreviousLogAbsences;
|
|
154
|
+
if (absencesRaw !== undefined && !Array.isArray(absencesRaw)) return false;
|
|
155
|
+
const absences = Array.isArray(absencesRaw)
|
|
156
|
+
? absencesRaw.map(podContainerRef)
|
|
157
|
+
: [];
|
|
158
|
+
if (absences.some((entry) => !entry)) return false;
|
|
159
|
+
const absenceEntries = absences as DiagnosisPodContainerRef[];
|
|
160
|
+
const absenceKeys = new Set(absenceEntries.map(streamKey));
|
|
161
|
+
if ([...absenceKeys].some((key) => !previousKeys.has(key))) return false;
|
|
162
|
+
|
|
163
|
+
return previousEntries.every((entry) => {
|
|
164
|
+
if (entry.error === undefined) return true;
|
|
165
|
+
return nonEmptyString(entry.error) && absenceKeys.has(streamKey(entry));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export function adaptDiagnose(
|
|
169
|
+
builder: ProjectionBuilder,
|
|
170
|
+
source: InvestigationEvidenceSource,
|
|
171
|
+
payload: unknown,
|
|
172
|
+
): void {
|
|
173
|
+
const value = record(payload);
|
|
174
|
+
if (value) {
|
|
175
|
+
const network = networkEvidence(value);
|
|
176
|
+
if (network) {
|
|
177
|
+
const relevance = relevanceForResource(builder, network.subject);
|
|
178
|
+
const adverse =
|
|
179
|
+
network.verdict === "broken" || network.verdict === "degraded";
|
|
180
|
+
builder.observe(
|
|
181
|
+
`network:${network.subject.group ?? ""}:${network.subject.kind}:${network.subject.namespace ?? ""}:${network.subject.name}`,
|
|
182
|
+
"network",
|
|
183
|
+
source,
|
|
184
|
+
{
|
|
185
|
+
tier: evidenceTierForRelevance(
|
|
186
|
+
adverse ? "supporting" : "context",
|
|
187
|
+
relevance,
|
|
188
|
+
),
|
|
189
|
+
relevance,
|
|
190
|
+
tone:
|
|
191
|
+
network.verdict === "broken"
|
|
192
|
+
? "error"
|
|
193
|
+
: network.verdict === "degraded"
|
|
194
|
+
? "warning"
|
|
195
|
+
: network.verdict === "healthy"
|
|
196
|
+
? "info"
|
|
197
|
+
: "neutral",
|
|
198
|
+
title: `${network.subject.kind} path · ${network.subject.namespace ? `${network.subject.namespace}/` : ""}${network.subject.name}`,
|
|
199
|
+
summary:
|
|
200
|
+
network.diagnosis?.summary ||
|
|
201
|
+
network.reason ||
|
|
202
|
+
network.summary.headline,
|
|
203
|
+
data: { type: "network", network },
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
if (network.summary.skipped > 0) {
|
|
207
|
+
builder.limit(
|
|
208
|
+
source,
|
|
209
|
+
"Network path coverage",
|
|
210
|
+
`${network.summary.skipped} intended route${network.summary.skipped === 1 ? " was" : "s were"} not tested. ${network.summary.headline}`,
|
|
211
|
+
"unknown",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const resource = kubernetesResource(value?.resource);
|
|
218
|
+
if (!value || !resource) {
|
|
219
|
+
invalidPayload(builder, source);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const bundleRelevance = relevanceForResource(builder, {
|
|
223
|
+
kind: resource.kind,
|
|
224
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
225
|
+
namespace: resource.metadata.namespace,
|
|
226
|
+
name: resource.metadata.name,
|
|
227
|
+
});
|
|
228
|
+
const relatedRelevance: InvestigationEvidenceRelevance =
|
|
229
|
+
bundleRelevance === "target" ? "producer-related" : "broader";
|
|
230
|
+
|
|
231
|
+
const bundledRowRelevance = (
|
|
232
|
+
kind: string,
|
|
233
|
+
name: string,
|
|
234
|
+
): InvestigationEvidenceRelevance => {
|
|
235
|
+
if (bundleRelevance !== "target") return "broader";
|
|
236
|
+
const sameAsRoot =
|
|
237
|
+
kind.toLowerCase() === resource.kind.toLowerCase() &&
|
|
238
|
+
name === resource.metadata.name;
|
|
239
|
+
const group = sameAsRoot
|
|
240
|
+
? apiVersionToGroup(resource.apiVersion)
|
|
241
|
+
: kind.toLowerCase() === "pod"
|
|
242
|
+
? ""
|
|
243
|
+
: undefined;
|
|
244
|
+
return resourceMatchesTarget(builder.target, {
|
|
245
|
+
kind,
|
|
246
|
+
group,
|
|
247
|
+
namespace: resource.metadata.namespace,
|
|
248
|
+
name,
|
|
249
|
+
})
|
|
250
|
+
? "target"
|
|
251
|
+
: "producer-related";
|
|
252
|
+
};
|
|
253
|
+
addNarrowHint(builder, source, value);
|
|
254
|
+
|
|
255
|
+
const relatedRaw = value.relatedIssues;
|
|
256
|
+
const related = Array.isArray(relatedRaw)
|
|
257
|
+
? relatedRaw.map(issue).filter((item): item is Issue => Boolean(item))
|
|
258
|
+
: [];
|
|
259
|
+
const relatedValid =
|
|
260
|
+
relatedRaw === undefined ||
|
|
261
|
+
(Array.isArray(relatedRaw) && related.length === relatedRaw.length);
|
|
262
|
+
if (!relatedValid) {
|
|
263
|
+
invalidPayload(builder, source, "Classified issues");
|
|
264
|
+
}
|
|
265
|
+
const blockersRaw = value.startupBlockers;
|
|
266
|
+
let blockersValid = blockersRaw === undefined || Array.isArray(blockersRaw);
|
|
267
|
+
// Pods of one workload usually share a blocker word for word. Merge those
|
|
268
|
+
// into one finding with the pod list; anything else keeps its own card and
|
|
269
|
+
// its own identity so saved runs match as before.
|
|
270
|
+
const blockerGroups: Array<{
|
|
271
|
+
blocker: DiagnosisStartupBlocker;
|
|
272
|
+
pods: string[];
|
|
273
|
+
foldedInto?: Issue;
|
|
274
|
+
}> = [];
|
|
275
|
+
if (Array.isArray(blockersRaw)) {
|
|
276
|
+
const merged = new Map<string, (typeof blockerGroups)[number]>();
|
|
277
|
+
for (const raw of blockersRaw) {
|
|
278
|
+
const blocker = startupBlocker(raw);
|
|
279
|
+
if (!blocker) {
|
|
280
|
+
blockersValid = false;
|
|
281
|
+
invalidPayload(builder, source, "Startup evidence");
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const key =
|
|
285
|
+
blocker.kind === "Pod"
|
|
286
|
+
? `${blocker.reason} ${blocker.severity} ${blocker.message}`
|
|
287
|
+
: undefined;
|
|
288
|
+
const existing = key ? merged.get(key) : undefined;
|
|
289
|
+
if (existing) {
|
|
290
|
+
if (!existing.pods.includes(blocker.name)) {
|
|
291
|
+
existing.pods.push(blocker.name);
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const entry = { blocker, pods: [blocker.name] };
|
|
296
|
+
if (key) merged.set(key, entry);
|
|
297
|
+
blockerGroups.push(entry);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const item of related) {
|
|
301
|
+
// `diagnose` is itself scoped to one resource and declares these rows as
|
|
302
|
+
// related evidence. That producer contract is stronger than a broad
|
|
303
|
+
// `issues` query, even when the related row is a child Pod.
|
|
304
|
+
// A classified issue and the pods' startup blocker are the same fact
|
|
305
|
+
// seen from the workload and from its pods; one card carries both.
|
|
306
|
+
const folded = blockerGroups.find(
|
|
307
|
+
(group) =>
|
|
308
|
+
!group.foldedInto &&
|
|
309
|
+
group.blocker.kind === "Pod" &&
|
|
310
|
+
group.blocker.reason === item.reason &&
|
|
311
|
+
(group.blocker.message === item.message ||
|
|
312
|
+
group.blocker.message === item.cause),
|
|
313
|
+
);
|
|
314
|
+
if (folded) folded.foldedInto = item;
|
|
315
|
+
addIssueObservation(builder, source, item, relatedRelevance, folded?.pods);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const context = contextFrom(value.resourceContext);
|
|
319
|
+
if (value.resourceContext !== undefined && !context) {
|
|
320
|
+
invalidPayload(builder, source, "Resource context");
|
|
321
|
+
}
|
|
322
|
+
const gitOps =
|
|
323
|
+
value.gitopsDiagnosis === undefined
|
|
324
|
+
? undefined
|
|
325
|
+
: gitOpsDiagnosis(value.gitopsDiagnosis);
|
|
326
|
+
if (value.gitopsDiagnosis !== undefined && !gitOps) {
|
|
327
|
+
invalidPayload(builder, source, "GitOps status");
|
|
328
|
+
}
|
|
329
|
+
if (
|
|
330
|
+
source.confirmedSuccess &&
|
|
331
|
+
related.length === 0 &&
|
|
332
|
+
relatedValid &&
|
|
333
|
+
isDiagnosableWorkloadKind(resource.kind)
|
|
334
|
+
) {
|
|
335
|
+
const scope = scopeFromArgs(source);
|
|
336
|
+
builder.observe(`issues:diagnose:${scope}`, "receipt", source, {
|
|
337
|
+
tier: evidenceTierForRelevance("checked", bundleRelevance),
|
|
338
|
+
relevance: bundleRelevance,
|
|
339
|
+
tone: "neutral",
|
|
340
|
+
title: "Radar's diagnosis found no live issues",
|
|
341
|
+
summary: scope,
|
|
342
|
+
data: {
|
|
343
|
+
type: "receipt",
|
|
344
|
+
checked: "issues",
|
|
345
|
+
scope,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const warnings = stringArray(value.warnings) ?? [];
|
|
350
|
+
addResourceObservation(
|
|
351
|
+
builder,
|
|
352
|
+
source,
|
|
353
|
+
resource,
|
|
354
|
+
context,
|
|
355
|
+
warnings,
|
|
356
|
+
gitOps,
|
|
357
|
+
related.some((item) => item.severity === "critical"),
|
|
358
|
+
bundleRelevance,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (Array.isArray(blockersRaw)) {
|
|
362
|
+
for (const { blocker, pods, foldedInto } of blockerGroups) {
|
|
363
|
+
if (foldedInto) continue;
|
|
364
|
+
// One blocker keeps one identity however many pods share it. Keying a
|
|
365
|
+
// merged card differently from a single-pod one made a re-diagnose that
|
|
366
|
+
// crossed one pod look like a new fact, so the same reason appeared
|
|
367
|
+
// twice; a Pod blocker is the same finding whether it holds one pod or
|
|
368
|
+
// nine.
|
|
369
|
+
const grouped = blocker.kind === "Pod" && pods.length > 1;
|
|
370
|
+
builder.observe(
|
|
371
|
+
blocker.kind === "Pod"
|
|
372
|
+
? `startup:Pod:${blocker.reason}:${fnv1a32(blocker.message).toString(36)}`
|
|
373
|
+
: `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
|
|
374
|
+
"startup",
|
|
375
|
+
source,
|
|
376
|
+
{
|
|
377
|
+
tier: evidenceTierForRelevance(
|
|
378
|
+
"key",
|
|
379
|
+
bundledRowRelevance(blocker.kind, blocker.name),
|
|
380
|
+
),
|
|
381
|
+
relevance: bundledRowRelevance(blocker.kind, blocker.name),
|
|
382
|
+
tone: diagnosisSeverityTone(blocker.severity),
|
|
383
|
+
title: blocker.reason,
|
|
384
|
+
summary: grouped
|
|
385
|
+
? `${pods.length} pods · ${blocker.message}`
|
|
386
|
+
: blocker.message,
|
|
387
|
+
data: {
|
|
388
|
+
type: "startup",
|
|
389
|
+
blocker,
|
|
390
|
+
// Always carry the pods, whether one or nine: they are what puts
|
|
391
|
+
// this workload's pods into the set a later Prometheus query is
|
|
392
|
+
// proved against, and a merged card that dropped them quietly
|
|
393
|
+
// weakened attribution elsewhere.
|
|
394
|
+
pods,
|
|
395
|
+
subject: grouped
|
|
396
|
+
? undefined
|
|
397
|
+
: (() => {
|
|
398
|
+
const namespace = resource.metadata.namespace;
|
|
399
|
+
if (!namespace) return undefined;
|
|
400
|
+
const rootGroup = apiVersionToGroup(resource.apiVersion);
|
|
401
|
+
const group =
|
|
402
|
+
blocker.kind === resource.kind
|
|
403
|
+
? rootGroup
|
|
404
|
+
: blocker.kind === "Pod"
|
|
405
|
+
? ""
|
|
406
|
+
: blocker.kind === "ReplicaSet"
|
|
407
|
+
? "apps"
|
|
408
|
+
: undefined;
|
|
409
|
+
if (group === undefined) return undefined;
|
|
410
|
+
return {
|
|
411
|
+
kind: blocker.kind,
|
|
412
|
+
...(group ? { group } : {}),
|
|
413
|
+
namespace,
|
|
414
|
+
name: blocker.name,
|
|
415
|
+
};
|
|
416
|
+
})(),
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} else if (blockersRaw !== undefined) {
|
|
422
|
+
invalidPayload(builder, source, "Startup evidence");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const crashesRaw = value.crashCause;
|
|
426
|
+
let crashesValid =
|
|
427
|
+
(crashesRaw === undefined || Array.isArray(crashesRaw)) &&
|
|
428
|
+
(value.crashCauseTruncated === undefined ||
|
|
429
|
+
typeof value.crashCauseTruncated === "boolean");
|
|
430
|
+
if (Array.isArray(crashesRaw)) {
|
|
431
|
+
for (const raw of crashesRaw) {
|
|
432
|
+
const crash = crashCause(raw);
|
|
433
|
+
if (!crash) {
|
|
434
|
+
crashesValid = false;
|
|
435
|
+
invalidPayload(builder, source, "Crash evidence");
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const pods = [...crash.pods].sort().join(",");
|
|
439
|
+
const crashRelevance =
|
|
440
|
+
crash.pods.length === 1
|
|
441
|
+
? bundledRowRelevance("Pod", crash.pods[0])
|
|
442
|
+
: relatedRelevance;
|
|
443
|
+
builder.observe(
|
|
444
|
+
`crash:${pods}:${crash.container}:${crash.state}:${crash.reason ?? ""}`,
|
|
445
|
+
"crash",
|
|
446
|
+
source,
|
|
447
|
+
{
|
|
448
|
+
tier: evidenceTierForRelevance("key", crashRelevance),
|
|
449
|
+
relevance: crashRelevance,
|
|
450
|
+
tone: "error",
|
|
451
|
+
title: `${crash.container} ${crash.reason || crash.state}`,
|
|
452
|
+
summary: crash.logLine,
|
|
453
|
+
data: {
|
|
454
|
+
type: "crash",
|
|
455
|
+
crash,
|
|
456
|
+
namespace: resource.metadata.namespace,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
} else if (crashesRaw !== undefined) {
|
|
462
|
+
invalidPayload(builder, source, "Crash evidence");
|
|
463
|
+
}
|
|
464
|
+
if (value.crashCauseTruncated === true) {
|
|
465
|
+
builder.limit(
|
|
466
|
+
source,
|
|
467
|
+
"Crash evidence",
|
|
468
|
+
"Radar returned part of the crash-cause candidates.",
|
|
469
|
+
"truncated",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (
|
|
474
|
+
source.confirmedSuccess &&
|
|
475
|
+
bundleRelevance === "target" &&
|
|
476
|
+
isDiagnosableWorkloadKind(resource.kind)
|
|
477
|
+
) {
|
|
478
|
+
if (relatedValid) builder.coverSemantic(source, "issue");
|
|
479
|
+
if (blockersValid) builder.coverSemantic(source, "startup");
|
|
480
|
+
if (
|
|
481
|
+
crashesValid &&
|
|
482
|
+
diagnoseCrashQueryCoversTarget(source) &&
|
|
483
|
+
diagnoseCrashCoverageComplete(value)
|
|
484
|
+
) {
|
|
485
|
+
builder.coverSemantic(source, "crash");
|
|
486
|
+
}
|
|
487
|
+
// DNSContext is emitted only for positive symptoms/configuration. The
|
|
488
|
+
// producer has no explicit negative DNS coverage receipt, so its absence
|
|
489
|
+
// cannot safely retire earlier DNS evidence.
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const expectedAbsencesRaw = value.expectedPreviousLogAbsences;
|
|
493
|
+
const expectedAbsences = Array.isArray(expectedAbsencesRaw)
|
|
494
|
+
? expectedAbsencesRaw
|
|
495
|
+
.map(podContainerRef)
|
|
496
|
+
.filter((item): item is DiagnosisPodContainerRef => Boolean(item))
|
|
497
|
+
: [];
|
|
498
|
+
if (
|
|
499
|
+
expectedAbsencesRaw !== undefined &&
|
|
500
|
+
(!Array.isArray(expectedAbsencesRaw) ||
|
|
501
|
+
expectedAbsences.length !== expectedAbsencesRaw.length)
|
|
502
|
+
) {
|
|
503
|
+
invalidPayload(builder, source, "Previous-log status");
|
|
504
|
+
}
|
|
505
|
+
const expectedAbsenceKeys = new Set(
|
|
506
|
+
expectedAbsences.map((item) => `${item.pod}\u0000${item.container}`),
|
|
507
|
+
);
|
|
508
|
+
if (source.confirmedSuccess) {
|
|
509
|
+
for (const item of expectedAbsences) {
|
|
510
|
+
const logRelevance = bundledRowRelevance("Pod", item.pod);
|
|
511
|
+
builder.observe(
|
|
512
|
+
`previous-log-absence:${item.pod}:${item.container}`,
|
|
513
|
+
"receipt",
|
|
514
|
+
source,
|
|
515
|
+
{
|
|
516
|
+
tier: evidenceTierForRelevance("checked", logRelevance),
|
|
517
|
+
relevance: logRelevance,
|
|
518
|
+
tone: "neutral",
|
|
519
|
+
title: "No previous logs expected",
|
|
520
|
+
summary: `${item.pod} / ${item.container}`,
|
|
521
|
+
data: {
|
|
522
|
+
type: "receipt",
|
|
523
|
+
checked: "logs",
|
|
524
|
+
scope: `${item.pod} / ${item.container}`,
|
|
525
|
+
message:
|
|
526
|
+
"Captured container status shows zero restarts and no prior termination, so a previous log stream should not exist.",
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const [field, previous] of [
|
|
534
|
+
["logsCurrent", false],
|
|
535
|
+
["logsPrevious", true],
|
|
536
|
+
] as const) {
|
|
537
|
+
const raw = value[field];
|
|
538
|
+
if (Array.isArray(raw)) {
|
|
539
|
+
for (const item of raw) {
|
|
540
|
+
const entry = parseLogEntry(item);
|
|
541
|
+
if (
|
|
542
|
+
entry &&
|
|
543
|
+
previous &&
|
|
544
|
+
expectedAbsenceKeys.has(`${entry.pod}\u0000${entry.container}`) &&
|
|
545
|
+
(entry.logs?.lines?.length ?? 0) === 0
|
|
546
|
+
) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (entry) {
|
|
550
|
+
addLogs(
|
|
551
|
+
builder,
|
|
552
|
+
source,
|
|
553
|
+
entry,
|
|
554
|
+
previous,
|
|
555
|
+
[],
|
|
556
|
+
bundledRowRelevance("Pod", entry.pod),
|
|
557
|
+
resource.metadata.namespace,
|
|
558
|
+
);
|
|
559
|
+
} else
|
|
560
|
+
invalidPayload(
|
|
561
|
+
builder,
|
|
562
|
+
source,
|
|
563
|
+
previous ? "Previous logs" : "Current logs",
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (raw.length === 0 && field === "logsCurrent") {
|
|
567
|
+
builder.limit(
|
|
568
|
+
source,
|
|
569
|
+
"Current logs",
|
|
570
|
+
"No container logs were available, so Radar could not evaluate them.",
|
|
571
|
+
"unknown",
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
} else if (raw !== undefined) {
|
|
575
|
+
invalidPayload(
|
|
576
|
+
builder,
|
|
577
|
+
source,
|
|
578
|
+
previous ? "Previous logs" : "Current logs",
|
|
579
|
+
);
|
|
580
|
+
} else if (
|
|
581
|
+
field === "logsCurrent" &&
|
|
582
|
+
typeof value.pods === "number" &&
|
|
583
|
+
value.pods > 0 &&
|
|
584
|
+
!nonEmptyString(value.logsError)
|
|
585
|
+
) {
|
|
586
|
+
// Empty slices are omitted by the Go producer. With resolved pods this
|
|
587
|
+
// means the read yielded no stream rows, not that logs proved anything.
|
|
588
|
+
builder.limit(
|
|
589
|
+
source,
|
|
590
|
+
"Current logs",
|
|
591
|
+
"No container logs were available, so Radar could not evaluate them.",
|
|
592
|
+
"unknown",
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (nonEmptyString(value.logsError)) {
|
|
597
|
+
builder.limit(source, "Logs", value.logsError, "error");
|
|
598
|
+
}
|
|
599
|
+
const logCoverage = record(value.logCoverage);
|
|
600
|
+
if (logCoverage?.selectionTruncated === true) {
|
|
601
|
+
const selected =
|
|
602
|
+
typeof logCoverage.selectedPods === "number"
|
|
603
|
+
? logCoverage.selectedPods
|
|
604
|
+
: "some";
|
|
605
|
+
const resolved =
|
|
606
|
+
typeof logCoverage.resolvedPods === "number"
|
|
607
|
+
? logCoverage.resolvedPods
|
|
608
|
+
: "the resolved";
|
|
609
|
+
builder.limit(
|
|
610
|
+
source,
|
|
611
|
+
"Log pod coverage",
|
|
612
|
+
`Log collection selected ${selected} of ${resolved} pods.`,
|
|
613
|
+
"truncated",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (logCoverage?.contentTruncated === true) {
|
|
617
|
+
const shown =
|
|
618
|
+
typeof logCoverage.shownLines === "number"
|
|
619
|
+
? logCoverage.shownLines
|
|
620
|
+
: "a subset of";
|
|
621
|
+
const total =
|
|
622
|
+
typeof logCoverage.totalLines === "number"
|
|
623
|
+
? logCoverage.totalLines
|
|
624
|
+
: "the returned";
|
|
625
|
+
builder.limit(
|
|
626
|
+
source,
|
|
627
|
+
"Log excerpt coverage",
|
|
628
|
+
`The response includes ${shown} of ${total} selected log lines after the size limit; these are excerpts, not the container's full log history.`,
|
|
629
|
+
"truncated",
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const eventsRaw = value.events;
|
|
634
|
+
if (Array.isArray(eventsRaw)) {
|
|
635
|
+
const events = eventsRaw
|
|
636
|
+
.map(event)
|
|
637
|
+
.filter((item): item is InvestigationEventEvidence => Boolean(item));
|
|
638
|
+
if (events.length === eventsRaw.length) {
|
|
639
|
+
addEvents(
|
|
640
|
+
builder,
|
|
641
|
+
source,
|
|
642
|
+
events,
|
|
643
|
+
`events:diagnose:${scopeFromArgs(source)}`,
|
|
644
|
+
typeof value.eventsTotalGroups !== "number" ||
|
|
645
|
+
value.eventsTotalGroups <= events.length,
|
|
646
|
+
true,
|
|
647
|
+
relatedRelevance,
|
|
648
|
+
);
|
|
649
|
+
} else {
|
|
650
|
+
invalidPayload(builder, source, "Events");
|
|
651
|
+
}
|
|
652
|
+
} else if (
|
|
653
|
+
eventsRaw === undefined &&
|
|
654
|
+
value.gitopsDiagnosis === undefined &&
|
|
655
|
+
!nonEmptyString(value.eventsError)
|
|
656
|
+
) {
|
|
657
|
+
addEvents(
|
|
658
|
+
builder,
|
|
659
|
+
source,
|
|
660
|
+
[],
|
|
661
|
+
`events:diagnose:${scopeFromArgs(source)}`,
|
|
662
|
+
true,
|
|
663
|
+
true,
|
|
664
|
+
relatedRelevance,
|
|
665
|
+
);
|
|
666
|
+
} else if (eventsRaw !== undefined) {
|
|
667
|
+
invalidPayload(builder, source, "Events");
|
|
668
|
+
}
|
|
669
|
+
if (nonEmptyString(value.eventsError)) {
|
|
670
|
+
builder.limit(source, "Events", value.eventsError, "error");
|
|
671
|
+
}
|
|
672
|
+
if (
|
|
673
|
+
typeof value.eventsTotalGroups === "number" &&
|
|
674
|
+
Array.isArray(eventsRaw) &&
|
|
675
|
+
value.eventsTotalGroups > eventsRaw.length
|
|
676
|
+
) {
|
|
677
|
+
builder.limit(
|
|
678
|
+
source,
|
|
679
|
+
"Events",
|
|
680
|
+
`Radar received ${eventsRaw.length} of ${value.eventsTotalGroups} event groups.`,
|
|
681
|
+
"truncated",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const changesRaw = value.recentChanges;
|
|
686
|
+
const changesCoverageLimitedRaw = value.recentChangesCoverageLimited;
|
|
687
|
+
const changesCoverageLimitedValid =
|
|
688
|
+
changesCoverageLimitedRaw === undefined ||
|
|
689
|
+
typeof changesCoverageLimitedRaw === "boolean";
|
|
690
|
+
const changesCoverageLimited = changesCoverageLimitedRaw === true;
|
|
691
|
+
if (!changesCoverageLimitedValid) {
|
|
692
|
+
invalidPayload(builder, source, "Recent changes");
|
|
693
|
+
}
|
|
694
|
+
const changeContext =
|
|
695
|
+
value.changeContext === undefined
|
|
696
|
+
? undefined
|
|
697
|
+
: diagnosisChangeContext(value.changeContext);
|
|
698
|
+
if (value.changeContext !== undefined && !changeContext) {
|
|
699
|
+
invalidPayload(builder, source, "Change correlation");
|
|
700
|
+
}
|
|
701
|
+
const diagnoseChangesSubject = {
|
|
702
|
+
kind: resource.kind,
|
|
703
|
+
namespace: resource.metadata.namespace,
|
|
704
|
+
name: resource.metadata.name,
|
|
705
|
+
};
|
|
706
|
+
if (Array.isArray(changesRaw)) {
|
|
707
|
+
const changes = changesRaw
|
|
708
|
+
.map(recentChange)
|
|
709
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
710
|
+
if (changes.length === changesRaw.length) {
|
|
711
|
+
addChanges(
|
|
712
|
+
builder,
|
|
713
|
+
source,
|
|
714
|
+
changes,
|
|
715
|
+
`changes:diagnose:${scopeFromArgs(source)}`,
|
|
716
|
+
changeContext,
|
|
717
|
+
value.recentChangesSaturated !== true &&
|
|
718
|
+
!changesCoverageLimited &&
|
|
719
|
+
changesCoverageLimitedValid,
|
|
720
|
+
true,
|
|
721
|
+
relatedRelevance,
|
|
722
|
+
diagnoseChangesSubject,
|
|
723
|
+
);
|
|
724
|
+
} else {
|
|
725
|
+
invalidPayload(builder, source, "Recent changes");
|
|
726
|
+
}
|
|
727
|
+
} else if (
|
|
728
|
+
changesRaw === undefined &&
|
|
729
|
+
value.gitopsDiagnosis === undefined &&
|
|
730
|
+
!nonEmptyString(value.recentChangesError)
|
|
731
|
+
) {
|
|
732
|
+
addChanges(
|
|
733
|
+
builder,
|
|
734
|
+
source,
|
|
735
|
+
[],
|
|
736
|
+
`changes:diagnose:${scopeFromArgs(source)}`,
|
|
737
|
+
changeContext,
|
|
738
|
+
value.recentChangesSaturated !== true &&
|
|
739
|
+
!changesCoverageLimited &&
|
|
740
|
+
changesCoverageLimitedValid,
|
|
741
|
+
true,
|
|
742
|
+
relatedRelevance,
|
|
743
|
+
diagnoseChangesSubject,
|
|
744
|
+
);
|
|
745
|
+
} else if (changesRaw !== undefined) {
|
|
746
|
+
invalidPayload(builder, source, "Recent changes");
|
|
747
|
+
}
|
|
748
|
+
if (nonEmptyString(value.recentChangesError)) {
|
|
749
|
+
builder.limit(source, "Recent changes", value.recentChangesError, "error");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
addDiagnoseMetrics(
|
|
753
|
+
builder,
|
|
754
|
+
source,
|
|
755
|
+
value.metrics,
|
|
756
|
+
{
|
|
757
|
+
kind: resource.kind,
|
|
758
|
+
...(apiVersionToGroup(resource.apiVersion)
|
|
759
|
+
? { group: apiVersionToGroup(resource.apiVersion) }
|
|
760
|
+
: {}),
|
|
761
|
+
namespace: resource.metadata.namespace,
|
|
762
|
+
name: resource.metadata.name,
|
|
763
|
+
},
|
|
764
|
+
bundleRelevance,
|
|
765
|
+
);
|
|
766
|
+
if (value.recentChangesSaturated === true) {
|
|
767
|
+
builder.limit(
|
|
768
|
+
source,
|
|
769
|
+
"Recent changes",
|
|
770
|
+
"The recent-change result limit was reached; additional changes may exist in the requested window.",
|
|
771
|
+
"truncated",
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (changesCoverageLimited) {
|
|
775
|
+
builder.limit(
|
|
776
|
+
source,
|
|
777
|
+
"Recent changes",
|
|
778
|
+
"Recent-change coverage is limited because Radar could not confirm permission to read every referenced source. The visible result cannot prove that no recent changes exist.",
|
|
779
|
+
"unknown",
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const dns = record(value.dnsContext);
|
|
784
|
+
if (dns) {
|
|
785
|
+
const signals = stringArray(dns.signals) ?? [];
|
|
786
|
+
const findings = Array.isArray(dns.coreDNSFindings)
|
|
787
|
+
? dns.coreDNSFindings
|
|
788
|
+
: [];
|
|
789
|
+
if (signals.length > 0 || findings.length > 0) {
|
|
790
|
+
const adverse =
|
|
791
|
+
findings.length > 0 ||
|
|
792
|
+
signals.some((signal) =>
|
|
793
|
+
/\b(error|failed|failures?|timeouts?|nxdomain|servfail)\b/i.test(
|
|
794
|
+
signal,
|
|
795
|
+
),
|
|
796
|
+
);
|
|
797
|
+
builder.observe(`dns:${scopeFromArgs(source)}`, "dns", source, {
|
|
798
|
+
tier: evidenceTierForRelevance(
|
|
799
|
+
adverse ? "supporting" : "context",
|
|
800
|
+
relatedRelevance,
|
|
801
|
+
),
|
|
802
|
+
relevance: relatedRelevance,
|
|
803
|
+
tone: adverse ? "warning" : "info",
|
|
804
|
+
title: adverse ? "DNS failure signals" : "DNS configuration",
|
|
805
|
+
summary:
|
|
806
|
+
signals[0] ||
|
|
807
|
+
`${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
|
|
808
|
+
data: { type: "dns", dns: dns as unknown as DiagnosisDNSContext },
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
} else if (value.dnsContext !== undefined) {
|
|
812
|
+
invalidPayload(builder, source, "DNS context");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* One half of a Go↔TS contract: `diagnoseMetricsCategories` in
|
|
817
|
+
* internal/mcp/tools_diagnose_metrics.go decides which categories the producer
|
|
818
|
+
* captures, and a series whose category is missing here is discarded. Change
|
|
819
|
+
* both together.
|
|
820
|
+
*/
|
|
821
|
+
const DIAGNOSE_METRICS_LABELS: Record<string, string> = {
|
|
822
|
+
cpu: "CPU usage",
|
|
823
|
+
memory: "Memory working set",
|
|
824
|
+
restarts: "Restarts",
|
|
825
|
+
};
|
|
826
|
+
/**
|
|
827
|
+
* The vitals `diagnose` captured inside the same call: one chart per category
|
|
828
|
+
* over the exact pods the bundle covers. They take the bundle's relevance, so a
|
|
829
|
+
* neighbour's diagnose never charts as evidence for the target, and they carry
|
|
830
|
+
* the diagnosed resource as their subject so recorded changes can be marked
|
|
831
|
+
* on them. An absent field means Prometheus was not available or the read was
|
|
832
|
+
* not permitted, which is not a collection failure the producer reported.
|
|
833
|
+
*/
|
|
834
|
+
function addDiagnoseMetrics(
|
|
835
|
+
builder: ProjectionBuilder,
|
|
836
|
+
source: InvestigationEvidenceSource,
|
|
837
|
+
raw: unknown,
|
|
838
|
+
subject: DiagnosisResourceRef,
|
|
839
|
+
relevance: InvestigationEvidenceRelevance,
|
|
840
|
+
): void {
|
|
841
|
+
if (raw === undefined) return;
|
|
842
|
+
const value = record(raw);
|
|
843
|
+
const window = record(value?.window);
|
|
844
|
+
if (
|
|
845
|
+
!value ||
|
|
846
|
+
!window ||
|
|
847
|
+
!nonEmptyString(window.start) ||
|
|
848
|
+
!nonEmptyString(window.end) ||
|
|
849
|
+
!nonEmptyString(window.step) ||
|
|
850
|
+
!(Date.parse(window.end) > Date.parse(window.start)) ||
|
|
851
|
+
!nonNegativeInteger(value.pods) ||
|
|
852
|
+
(value.partial !== undefined && typeof value.partial !== "boolean") ||
|
|
853
|
+
(value.omittedPods !== undefined &&
|
|
854
|
+
!nonNegativeInteger(value.omittedPods)) ||
|
|
855
|
+
(value.error !== undefined && typeof value.error !== "string") ||
|
|
856
|
+
!Array.isArray(value.series)
|
|
857
|
+
) {
|
|
858
|
+
invalidPayload(builder, source, "Workload metrics");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const scope = scopeFromArgs(source);
|
|
862
|
+
const partial = value.partial === true;
|
|
863
|
+
// The pods the chart covers: the ones the workload controlled when the
|
|
864
|
+
// bundle was captured, which a rollout during the window can miss.
|
|
865
|
+
const podsLabel = partial
|
|
866
|
+
? `first ${value.pods} of ${typeof value.omittedPods === "number" ? value.pods + value.omittedPods : "the"} current pods`
|
|
867
|
+
: `${value.pods} current pod${value.pods === 1 ? "" : "s"}`;
|
|
868
|
+
const windowLabel = metricsWindowLabel({
|
|
869
|
+
mode: "range",
|
|
870
|
+
start: window.start,
|
|
871
|
+
end: window.end,
|
|
872
|
+
step: window.step,
|
|
873
|
+
});
|
|
874
|
+
for (const rawEntry of value.series) {
|
|
875
|
+
const entry = record(rawEntry);
|
|
876
|
+
const rawSeries = entry?.series;
|
|
877
|
+
const series = Array.isArray(rawSeries)
|
|
878
|
+
? rawSeries
|
|
879
|
+
.map(timeSeries)
|
|
880
|
+
.filter((item): item is TimeSeries => Boolean(item))
|
|
881
|
+
: undefined;
|
|
882
|
+
if (
|
|
883
|
+
!entry ||
|
|
884
|
+
!nonEmptyString(entry.category) ||
|
|
885
|
+
!Object.hasOwn(DIAGNOSE_METRICS_LABELS, entry.category) ||
|
|
886
|
+
!nonEmptyString(entry.query) ||
|
|
887
|
+
!nonEmptyString(entry.unit) ||
|
|
888
|
+
!Array.isArray(rawSeries) ||
|
|
889
|
+
!series ||
|
|
890
|
+
series.length !== rawSeries.length
|
|
891
|
+
) {
|
|
892
|
+
invalidPayload(builder, source, "Workload metrics");
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const label = DIAGNOSE_METRICS_LABELS[entry.category];
|
|
896
|
+
const data: InvestigationMetricsEvidence = {
|
|
897
|
+
type: "metrics",
|
|
898
|
+
origin: "diagnose",
|
|
899
|
+
query: entry.query,
|
|
900
|
+
mode: "range",
|
|
901
|
+
start: window.start,
|
|
902
|
+
end: window.end,
|
|
903
|
+
step: window.step,
|
|
904
|
+
unit: entry.unit,
|
|
905
|
+
label,
|
|
906
|
+
series,
|
|
907
|
+
truncated: false,
|
|
908
|
+
subject,
|
|
909
|
+
pods: value.pods,
|
|
910
|
+
partial,
|
|
911
|
+
};
|
|
912
|
+
builder.observe(
|
|
913
|
+
`metrics:diagnose:${subject.group ?? ""}:${subject.kind}:${subject.namespace ?? ""}:${subject.name}:${entry.category}`,
|
|
914
|
+
"metrics",
|
|
915
|
+
source,
|
|
916
|
+
{
|
|
917
|
+
tier: evidenceTierForRelevance("supporting", relevance),
|
|
918
|
+
relevance,
|
|
919
|
+
tone: "neutral",
|
|
920
|
+
title: `${label} · ${scope}`,
|
|
921
|
+
summary: [
|
|
922
|
+
series.length === 0 ? "No samples in the window" : podsLabel,
|
|
923
|
+
windowLabel,
|
|
924
|
+
partial ? "partial pod set" : undefined,
|
|
925
|
+
]
|
|
926
|
+
.filter((part): part is string => Boolean(part))
|
|
927
|
+
.join(" · "),
|
|
928
|
+
data,
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
if (nonEmptyString(value.error)) {
|
|
933
|
+
builder.limit(source, "Workload metrics", value.error, "error");
|
|
934
|
+
}
|
|
935
|
+
}
|