@skyhook-io/radar-app 1.13.1 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.tsx +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +72 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1900 -393
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +63 -0
- package/src/components/diagnose/investigationExplanation.ts +22 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
|
@@ -0,0 +1,3492 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CORE_RESOURCES,
|
|
3
|
+
defaultConditionTone,
|
|
4
|
+
displayKind,
|
|
5
|
+
englishPlural,
|
|
6
|
+
kindToPlural,
|
|
7
|
+
stripAnsi,
|
|
8
|
+
type Issue,
|
|
9
|
+
type IssueRecentChange,
|
|
10
|
+
type Topology,
|
|
11
|
+
} from "@skyhook-io/k8s-ui";
|
|
12
|
+
import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
|
|
13
|
+
import { apiVersionToGroup } from "../../utils/navigation";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
diagnosisSeverityTone,
|
|
17
|
+
type DiagnosisChangeContext,
|
|
18
|
+
type DiagnosisCrashCause,
|
|
19
|
+
type DiagnosisDNSContext,
|
|
20
|
+
type DiagnosisEvidenceLimitationBase,
|
|
21
|
+
type DiagnosisEvidenceTone,
|
|
22
|
+
type DiagnosisFilteredLogs,
|
|
23
|
+
type DiagnosisPodContainerRef,
|
|
24
|
+
type DiagnosisPodLogEntry,
|
|
25
|
+
type DiagnosisResourceContext,
|
|
26
|
+
type DiagnosisResourceRef,
|
|
27
|
+
type DiagnosisStartupBlocker,
|
|
28
|
+
} from "./diagnoseEvidenceTypes";
|
|
29
|
+
import type { RootCauseEvidence } from "../../api/diagnose";
|
|
30
|
+
import { investigationResourceEvidenceSummary } from "./investigationResourceEvidenceModel";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The projection deliberately consumes only the small, structural portion of
|
|
34
|
+
* Turn that it needs. TimelineItem is private to parts.tsx today; Turn[] is
|
|
35
|
+
* structurally assignable to this type without coupling evidence extraction to
|
|
36
|
+
* the transcript renderer.
|
|
37
|
+
*/
|
|
38
|
+
export interface InvestigationEvidenceTurn {
|
|
39
|
+
timeline: readonly InvestigationEvidenceTimelineItem[];
|
|
40
|
+
question?: string;
|
|
41
|
+
apply?: boolean;
|
|
42
|
+
verify?: boolean;
|
|
43
|
+
status?: "running" | "done" | "error";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The resource the investigation was opened for. */
|
|
47
|
+
export interface InvestigationEvidenceTarget {
|
|
48
|
+
kind: string;
|
|
49
|
+
/** Kubernetes API group; empty means core. */
|
|
50
|
+
group: string;
|
|
51
|
+
namespace?: string;
|
|
52
|
+
name: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type InvestigationEvidencePhase =
|
|
56
|
+
"initial" | "followup" | "verification" | "apply";
|
|
57
|
+
|
|
58
|
+
export type InvestigationEvidenceTimelineItem =
|
|
59
|
+
| { kind: "thinking"; text: string }
|
|
60
|
+
| {
|
|
61
|
+
kind: "tool";
|
|
62
|
+
id: string;
|
|
63
|
+
tool: string;
|
|
64
|
+
status: string;
|
|
65
|
+
summary?: string;
|
|
66
|
+
result?: string;
|
|
67
|
+
evidenceRef?: string;
|
|
68
|
+
radarEvidence?: boolean;
|
|
69
|
+
truncated?: boolean;
|
|
70
|
+
isError?: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type InvestigationEvidenceTier =
|
|
74
|
+
"key" | "supporting" | "context" | "checked";
|
|
75
|
+
|
|
76
|
+
export type InvestigationEvidenceRelevance =
|
|
77
|
+
"target" | "producer-related" | "broader";
|
|
78
|
+
|
|
79
|
+
export type InvestigationEvidenceKind =
|
|
80
|
+
| "issue"
|
|
81
|
+
| "startup"
|
|
82
|
+
| "crash"
|
|
83
|
+
| "resource"
|
|
84
|
+
| "logs"
|
|
85
|
+
| "events"
|
|
86
|
+
| "changes"
|
|
87
|
+
| "dns"
|
|
88
|
+
| "network"
|
|
89
|
+
| "relationships"
|
|
90
|
+
| "topology"
|
|
91
|
+
| "inventory"
|
|
92
|
+
| "receipt";
|
|
93
|
+
|
|
94
|
+
type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
|
|
95
|
+
|
|
96
|
+
export interface InvestigationEvidenceSource {
|
|
97
|
+
/** DOM-safe stable identity derived from turn index + the agent step ID. */
|
|
98
|
+
id: string;
|
|
99
|
+
turnIndex: number;
|
|
100
|
+
timelineIndex: number;
|
|
101
|
+
stepId: string;
|
|
102
|
+
tool: string;
|
|
103
|
+
/** The exact agent-emitted tool input shown in Activity. */
|
|
104
|
+
args?: string;
|
|
105
|
+
/** Stable flattened transcript order. */
|
|
106
|
+
order: number;
|
|
107
|
+
/** Which chronological phase of the run produced this source. */
|
|
108
|
+
phase: InvestigationEvidencePhase;
|
|
109
|
+
/** True only when the agent transport explicitly marked the tool result successful. */
|
|
110
|
+
confirmedSuccess: boolean;
|
|
111
|
+
/** Server-issued, turn-scoped identity for this exact retained result. */
|
|
112
|
+
evidenceRef?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The highest-priority evidence group produced by this call. Evidence panes
|
|
115
|
+
* use it for one unique Activity → Evidence anchor even when a bundle fans
|
|
116
|
+
* out into several cards.
|
|
117
|
+
*/
|
|
118
|
+
primaryGroupId?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface InvestigationResourceContext extends DiagnosisResourceContext {
|
|
122
|
+
issueSummary?: {
|
|
123
|
+
count: number;
|
|
124
|
+
highestSeverity?: string;
|
|
125
|
+
topReason?: string;
|
|
126
|
+
bySource?: Record<string, number>;
|
|
127
|
+
};
|
|
128
|
+
auditSummary?: {
|
|
129
|
+
count: number;
|
|
130
|
+
highestSeverity?: string;
|
|
131
|
+
topFinding?: string;
|
|
132
|
+
};
|
|
133
|
+
policySummary?: unknown;
|
|
134
|
+
podSummary?: unknown;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface InvestigationGitOpsDiagnosis {
|
|
138
|
+
tool: "argocd" | "flux";
|
|
139
|
+
sync?: string;
|
|
140
|
+
health?: string;
|
|
141
|
+
operationPhase?: string;
|
|
142
|
+
suspended?: boolean;
|
|
143
|
+
ready?: string;
|
|
144
|
+
appliedRevision?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface InvestigationKubernetesResource {
|
|
148
|
+
apiVersion: string;
|
|
149
|
+
kind: string;
|
|
150
|
+
metadata: {
|
|
151
|
+
name: string;
|
|
152
|
+
namespace?: string;
|
|
153
|
+
[key: string]: unknown;
|
|
154
|
+
};
|
|
155
|
+
spec?: unknown;
|
|
156
|
+
status?: unknown;
|
|
157
|
+
summaryContext?: unknown;
|
|
158
|
+
[key: string]: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Current `list_resources` row shape (`ai/context.ResourceSummary`). */
|
|
162
|
+
export interface InvestigationResourceSummary {
|
|
163
|
+
kind: string;
|
|
164
|
+
name: string;
|
|
165
|
+
namespace?: string;
|
|
166
|
+
status?: string;
|
|
167
|
+
ready?: string;
|
|
168
|
+
issue?: string;
|
|
169
|
+
age?: string;
|
|
170
|
+
terminating?: boolean;
|
|
171
|
+
restarts?: number;
|
|
172
|
+
lastTerminatedReason?: string;
|
|
173
|
+
lastRestartedAge?: string;
|
|
174
|
+
summaryContext?: {
|
|
175
|
+
health?: string;
|
|
176
|
+
issueCount?: number;
|
|
177
|
+
managedBy?: {
|
|
178
|
+
kind: string;
|
|
179
|
+
source: string;
|
|
180
|
+
name: string;
|
|
181
|
+
namespace?: string;
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
[key: string]: unknown;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface InvestigationEventEvidence {
|
|
188
|
+
reason: string;
|
|
189
|
+
message: string;
|
|
190
|
+
type: string;
|
|
191
|
+
count: number;
|
|
192
|
+
lastTimestamp: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface InvestigationTopologyNode {
|
|
196
|
+
id: string;
|
|
197
|
+
kind: string;
|
|
198
|
+
name: string;
|
|
199
|
+
status?: string;
|
|
200
|
+
data?: Record<string, unknown> | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface InvestigationTopologyEdge {
|
|
204
|
+
id?: string;
|
|
205
|
+
source: string;
|
|
206
|
+
target: string;
|
|
207
|
+
type: string;
|
|
208
|
+
label?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface InvestigationNetworkRoute {
|
|
212
|
+
route: string;
|
|
213
|
+
target?: string;
|
|
214
|
+
outcome: string;
|
|
215
|
+
failedLayer?: string;
|
|
216
|
+
confidence?: string;
|
|
217
|
+
evidence?: string;
|
|
218
|
+
benign?: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface InvestigationNetworkEvidence {
|
|
222
|
+
subject: DiagnosisResourceRef;
|
|
223
|
+
verdict: "healthy" | "degraded" | "broken" | "unknown";
|
|
224
|
+
reason?: string;
|
|
225
|
+
diagnosis?: {
|
|
226
|
+
class?: string;
|
|
227
|
+
severity?: string;
|
|
228
|
+
summary: string;
|
|
229
|
+
route?: string;
|
|
230
|
+
nextAction?: string;
|
|
231
|
+
};
|
|
232
|
+
summary: {
|
|
233
|
+
tested: number;
|
|
234
|
+
passed: number;
|
|
235
|
+
failed: number;
|
|
236
|
+
derived?: number;
|
|
237
|
+
skipped: number;
|
|
238
|
+
headline: string;
|
|
239
|
+
};
|
|
240
|
+
routes: InvestigationNetworkRoute[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type InvestigationEvidenceData =
|
|
244
|
+
| {
|
|
245
|
+
type: "issue";
|
|
246
|
+
issue: Issue;
|
|
247
|
+
/**
|
|
248
|
+
* A broad `issues` query can return failures from other resources. Keep
|
|
249
|
+
* those factual observations, but do not let agent selection alone imply
|
|
250
|
+
* that they explain the resource under investigation.
|
|
251
|
+
*/
|
|
252
|
+
relevance: "target" | "producer-related" | "broader";
|
|
253
|
+
}
|
|
254
|
+
| {
|
|
255
|
+
type: "startup";
|
|
256
|
+
blocker: DiagnosisStartupBlocker;
|
|
257
|
+
/** Exact blocker object when the diagnosis producer established it. */
|
|
258
|
+
subject?: DiagnosisResourceRef;
|
|
259
|
+
}
|
|
260
|
+
| {
|
|
261
|
+
type: "crash";
|
|
262
|
+
crash: DiagnosisCrashCause;
|
|
263
|
+
/** Namespace of the producing check's subject; pods live there. */
|
|
264
|
+
namespace?: string;
|
|
265
|
+
}
|
|
266
|
+
| {
|
|
267
|
+
type: "resource";
|
|
268
|
+
resource: InvestigationKubernetesResource;
|
|
269
|
+
resourceContext?: InvestigationResourceContext;
|
|
270
|
+
warnings: string[];
|
|
271
|
+
gitOpsDiagnosis?: InvestigationGitOpsDiagnosis;
|
|
272
|
+
}
|
|
273
|
+
| {
|
|
274
|
+
type: "logs";
|
|
275
|
+
pod: string;
|
|
276
|
+
container: string;
|
|
277
|
+
/** Namespace the producing call actually read; absent when unstated. */
|
|
278
|
+
namespace?: string;
|
|
279
|
+
previous: boolean;
|
|
280
|
+
logs?: DiagnosisFilteredLogs;
|
|
281
|
+
warnings: string[];
|
|
282
|
+
error?: string;
|
|
283
|
+
}
|
|
284
|
+
| {
|
|
285
|
+
type: "events";
|
|
286
|
+
events: InvestigationEventEvidence[];
|
|
287
|
+
scope: string;
|
|
288
|
+
}
|
|
289
|
+
| {
|
|
290
|
+
type: "changes";
|
|
291
|
+
changes: IssueRecentChange[];
|
|
292
|
+
scope: string;
|
|
293
|
+
changeContext?: DiagnosisChangeContext;
|
|
294
|
+
}
|
|
295
|
+
| { type: "dns"; dns: DiagnosisDNSContext }
|
|
296
|
+
| { type: "network"; network: InvestigationNetworkEvidence }
|
|
297
|
+
| {
|
|
298
|
+
type: "relationships";
|
|
299
|
+
root: DiagnosisResourceRef;
|
|
300
|
+
nodes: InvestigationTopologyNode[];
|
|
301
|
+
edges: InvestigationTopologyEdge[];
|
|
302
|
+
truncated: boolean;
|
|
303
|
+
}
|
|
304
|
+
| {
|
|
305
|
+
type: "topology";
|
|
306
|
+
stats: { nodes: number; edges: number };
|
|
307
|
+
namespaces: Array<{ namespace: string; chains: string[] }>;
|
|
308
|
+
problems: string[];
|
|
309
|
+
warnings: string[];
|
|
310
|
+
}
|
|
311
|
+
| {
|
|
312
|
+
type: "inventory";
|
|
313
|
+
resources: InvestigationResourceSummary[];
|
|
314
|
+
scope: string;
|
|
315
|
+
}
|
|
316
|
+
| {
|
|
317
|
+
type: "receipt";
|
|
318
|
+
checked: "issues" | "events" | "changes" | "inventory" | "logs";
|
|
319
|
+
scope: string;
|
|
320
|
+
message: string;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export interface InvestigationEvidenceObservation {
|
|
324
|
+
source: InvestigationEvidenceSource;
|
|
325
|
+
revision: number;
|
|
326
|
+
/** This exact observation predates a later successful verification of its proof scope. */
|
|
327
|
+
historical: boolean;
|
|
328
|
+
/** Whether this semantic item differs from its immediately previous observation. */
|
|
329
|
+
changedFromPrevious: boolean;
|
|
330
|
+
/**
|
|
331
|
+
* How this producer-backed observation relates to the resource being
|
|
332
|
+
* investigated. Broader observations remain useful context, but agent
|
|
333
|
+
* selection alone must never promote them as support for this target.
|
|
334
|
+
*/
|
|
335
|
+
relevance: InvestigationEvidenceRelevance;
|
|
336
|
+
tier: InvestigationEvidenceTier;
|
|
337
|
+
tone: DiagnosisEvidenceTone;
|
|
338
|
+
title: string;
|
|
339
|
+
summary?: string;
|
|
340
|
+
data: InvestigationEvidenceData;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface InvestigationEvidenceGroup {
|
|
344
|
+
/** Stable DOM-safe identity for this semantic evidence item. */
|
|
345
|
+
id: string;
|
|
346
|
+
/** Raw deterministic identity used to merge repeated observations. */
|
|
347
|
+
identity: string;
|
|
348
|
+
kind: InvestigationEvidenceKind;
|
|
349
|
+
/** Latest observation predates the most recent completed verification turn. */
|
|
350
|
+
historical: boolean;
|
|
351
|
+
firstOrder: number;
|
|
352
|
+
observations: InvestigationEvidenceObservation[];
|
|
353
|
+
/** Strongest-provenance observation, newest when provenance is equal. */
|
|
354
|
+
latest: InvestigationEvidenceObservation;
|
|
355
|
+
/** Newest observation regardless of proof strength; used for chronology. */
|
|
356
|
+
chronologicalLatest: InvestigationEvidenceObservation;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface InvestigationEvidenceLimitation extends DiagnosisEvidenceLimitationBase {
|
|
360
|
+
/** A qualified history result, not a failed collection. */
|
|
361
|
+
presentation?: "history";
|
|
362
|
+
firstOrder: number;
|
|
363
|
+
sources: InvestigationEvidenceSource[];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface InvestigationEvidenceCoverage {
|
|
367
|
+
/** Completed calls to a tool with a typed evidence adapter. */
|
|
368
|
+
attempted: number;
|
|
369
|
+
/** Adapted calls that contributed at least one evidence group. */
|
|
370
|
+
projected: number;
|
|
371
|
+
/** Adapted calls with a producer-declared or transport limitation. */
|
|
372
|
+
limited: number;
|
|
373
|
+
/** Calls that produced a strict, successful zero-result receipt. */
|
|
374
|
+
checked: number;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface InvestigationEvidenceProjection {
|
|
378
|
+
groups: InvestigationEvidenceGroup[];
|
|
379
|
+
limitations: InvestigationEvidenceLimitation[];
|
|
380
|
+
sources: InvestigationEvidenceSource[];
|
|
381
|
+
/** Every retained tool item carrying a server-issued ref, eligible or not. */
|
|
382
|
+
evidenceRefSources: InvestigationEvidenceSource[];
|
|
383
|
+
/** Complete confirmed-success sources eligible for server-authored links. */
|
|
384
|
+
citableSources: InvestigationEvidenceSource[];
|
|
385
|
+
coverage: InvestigationEvidenceCoverage;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface InvestigationRootCauseEvidenceLink {
|
|
389
|
+
source: InvestigationEvidenceSource;
|
|
390
|
+
/** Canonical semantic group containing this source’s primary observation. */
|
|
391
|
+
originalGroupId?: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface InvestigationRootCauseEvidenceResolution {
|
|
395
|
+
status: "linked" | "missing" | "invalid";
|
|
396
|
+
links: InvestigationRootCauseEvidenceLink[];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function exactDiagnosisResourceRef(ref: {
|
|
400
|
+
kind?: unknown;
|
|
401
|
+
group?: unknown;
|
|
402
|
+
namespace?: unknown;
|
|
403
|
+
name?: unknown;
|
|
404
|
+
}): DiagnosisResourceRef | undefined {
|
|
405
|
+
const { kind, name } = ref;
|
|
406
|
+
if (
|
|
407
|
+
!nonEmptyString(kind) ||
|
|
408
|
+
kind.trim() !== kind ||
|
|
409
|
+
!nonEmptyString(name) ||
|
|
410
|
+
name.trim() !== name ||
|
|
411
|
+
(ref.group !== undefined &&
|
|
412
|
+
(typeof ref.group !== "string" || ref.group.trim() !== ref.group)) ||
|
|
413
|
+
(ref.namespace !== undefined &&
|
|
414
|
+
(typeof ref.namespace !== "string" ||
|
|
415
|
+
ref.namespace.trim() !== ref.namespace))
|
|
416
|
+
) {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const group = ref.group || undefined;
|
|
420
|
+
const namespace = ref.namespace || undefined;
|
|
421
|
+
const knownKinds = CORE_RESOURCES.filter(
|
|
422
|
+
(resource) =>
|
|
423
|
+
resource.kind.toLowerCase() === kind.toLowerCase() &&
|
|
424
|
+
(group === undefined || resource.group === group),
|
|
425
|
+
);
|
|
426
|
+
const knownScopes = new Set(
|
|
427
|
+
knownKinds.map((resource) => resource.namespaced),
|
|
428
|
+
);
|
|
429
|
+
// This is deliberately only a negative guard. Unknown kinds/groups may be
|
|
430
|
+
// cluster-scoped CRDs, so suppress the link only when Radar's existing
|
|
431
|
+
// resource metadata identifies the kind's scope without ambiguity.
|
|
432
|
+
if (knownScopes.size === 1) {
|
|
433
|
+
if (knownScopes.has(true) && !namespace) return undefined;
|
|
434
|
+
if (knownScopes.has(false) && namespace) return undefined;
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
kind,
|
|
438
|
+
name,
|
|
439
|
+
...(group ? { group } : {}),
|
|
440
|
+
...(namespace ? { namespace } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The Kubernetes resource an evidence item is about, when the producer payload
|
|
446
|
+
* states one unambiguously. Pod-shaped evidence resolves only through the
|
|
447
|
+
* namespace its producing check actually read; the investigation target's
|
|
448
|
+
* namespace is deliberately never borrowed.
|
|
449
|
+
*/
|
|
450
|
+
export function investigationEvidenceSubjectRef(
|
|
451
|
+
data: InvestigationEvidenceData,
|
|
452
|
+
): DiagnosisResourceRef | undefined {
|
|
453
|
+
const ref = ((): DiagnosisResourceRef | undefined => {
|
|
454
|
+
switch (data.type) {
|
|
455
|
+
case "issue":
|
|
456
|
+
return {
|
|
457
|
+
kind: data.issue.kind,
|
|
458
|
+
group: data.issue.group,
|
|
459
|
+
namespace: data.issue.namespace,
|
|
460
|
+
name: data.issue.name,
|
|
461
|
+
};
|
|
462
|
+
case "startup":
|
|
463
|
+
return data.subject;
|
|
464
|
+
case "resource": {
|
|
465
|
+
const apiVersion = data.resource.apiVersion;
|
|
466
|
+
const group = apiVersionToGroup(apiVersion);
|
|
467
|
+
return {
|
|
468
|
+
kind: data.resource.kind,
|
|
469
|
+
group: group || undefined,
|
|
470
|
+
namespace: data.resource.metadata.namespace,
|
|
471
|
+
name: data.resource.metadata.name,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
case "logs":
|
|
475
|
+
if (!data.namespace) return undefined;
|
|
476
|
+
return { kind: "Pod", namespace: data.namespace, name: data.pod };
|
|
477
|
+
case "crash":
|
|
478
|
+
if (data.crash.pods.length !== 1 || !data.namespace) return undefined;
|
|
479
|
+
return {
|
|
480
|
+
kind: "Pod",
|
|
481
|
+
namespace: data.namespace,
|
|
482
|
+
name: data.crash.pods[0],
|
|
483
|
+
};
|
|
484
|
+
case "network":
|
|
485
|
+
return data.network.subject;
|
|
486
|
+
case "relationships":
|
|
487
|
+
return data.root;
|
|
488
|
+
default:
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
})();
|
|
492
|
+
return ref ? exactDiagnosisResourceRef(ref) : undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function domToken(value: string): string {
|
|
496
|
+
// Underscore is reserved as the escape delimiter, so raw input can never
|
|
497
|
+
// impersonate an encoded code point ("/" vs. the literal "_x2f_"). The
|
|
498
|
+
// empty sentinel is safe for the same reason: a literal "_empty_" is escaped.
|
|
499
|
+
if (!value) return "_empty_";
|
|
500
|
+
return Array.from(value, (character) =>
|
|
501
|
+
/[A-Za-z0-9-]/.test(character)
|
|
502
|
+
? character
|
|
503
|
+
: `_x${character.codePointAt(0)!.toString(16)}_`,
|
|
504
|
+
).join("");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function investigationEvidenceSourceId(
|
|
508
|
+
turnIndex: number,
|
|
509
|
+
stepId: string,
|
|
510
|
+
): string {
|
|
511
|
+
return `turn-${turnIndex}-step-${domToken(stepId)}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function investigationActivitySourceDomId(sourceId: string): string {
|
|
515
|
+
return `investigation-activity-${sourceId}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function investigationEvidenceSourceDomId(sourceId: string): string {
|
|
519
|
+
return `investigation-evidence-${sourceId}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const investigationEvidenceRefRe = /^ev_[a-z2-7]{26,128}_[a-z2-7]{26,128}$/;
|
|
523
|
+
|
|
524
|
+
export function resolveInvestigationRootCauseEvidence(
|
|
525
|
+
projection: InvestigationEvidenceProjection,
|
|
526
|
+
evidence: RootCauseEvidence | undefined,
|
|
527
|
+
assessmentTurnIndex: number,
|
|
528
|
+
): InvestigationRootCauseEvidenceResolution {
|
|
529
|
+
if (!evidence || evidence.status === "missing") {
|
|
530
|
+
return { status: "missing", links: [] };
|
|
531
|
+
}
|
|
532
|
+
if (evidence.status !== "linked") {
|
|
533
|
+
return { status: "invalid", links: [] };
|
|
534
|
+
}
|
|
535
|
+
const refs = evidence.refs;
|
|
536
|
+
if (
|
|
537
|
+
!refs ||
|
|
538
|
+
refs.length < 1 ||
|
|
539
|
+
refs.length > 3 ||
|
|
540
|
+
new Set(refs).size !== refs.length ||
|
|
541
|
+
refs.some((ref) => !investigationEvidenceRefRe.test(ref)) ||
|
|
542
|
+
refs.some((ref) => ref.split("_")[1] !== refs[0].split("_")[1])
|
|
543
|
+
) {
|
|
544
|
+
return { status: "invalid", links: [] };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const byRef = new Map<string, InvestigationEvidenceSource[]>();
|
|
548
|
+
for (const source of projection.evidenceRefSources) {
|
|
549
|
+
if (source.turnIndex !== assessmentTurnIndex) continue;
|
|
550
|
+
if (!source.evidenceRef) continue;
|
|
551
|
+
const matches = byRef.get(source.evidenceRef) ?? [];
|
|
552
|
+
matches.push(source);
|
|
553
|
+
byRef.set(source.evidenceRef, matches);
|
|
554
|
+
}
|
|
555
|
+
const citableSourceIds = new Set(
|
|
556
|
+
projection.citableSources
|
|
557
|
+
.filter((source) => source.turnIndex === assessmentTurnIndex)
|
|
558
|
+
.map((source) => source.id),
|
|
559
|
+
);
|
|
560
|
+
const links: InvestigationRootCauseEvidenceLink[] = [];
|
|
561
|
+
for (const ref of refs) {
|
|
562
|
+
const matches = byRef.get(ref);
|
|
563
|
+
// Match the server's fail-closed binding: every current-turn occurrence
|
|
564
|
+
// counts before success/completeness eligibility is considered.
|
|
565
|
+
if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) {
|
|
566
|
+
return { status: "invalid", links: [] };
|
|
567
|
+
}
|
|
568
|
+
const source = matches[0];
|
|
569
|
+
const originalGroup = source.primaryGroupId
|
|
570
|
+
? projection.groups.find((group) => group.id === source.primaryGroupId)
|
|
571
|
+
: undefined;
|
|
572
|
+
links.push({
|
|
573
|
+
source,
|
|
574
|
+
originalGroupId: originalGroup?.observations.some(
|
|
575
|
+
(observation) => observation.source.id === source.id,
|
|
576
|
+
)
|
|
577
|
+
? originalGroup.id
|
|
578
|
+
: undefined,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return { status: "linked", links };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function investigationEvidenceStepIdsByTurn(
|
|
585
|
+
projection: InvestigationEvidenceProjection,
|
|
586
|
+
visibleGroupIds?: ReadonlySet<string>,
|
|
587
|
+
): Map<number, Set<string>> {
|
|
588
|
+
const byTurn = new Map<number, Set<string>>();
|
|
589
|
+
const linkedSourceIds = new Set<string>();
|
|
590
|
+
for (const group of projection.groups) {
|
|
591
|
+
if (visibleGroupIds && !visibleGroupIds.has(group.id)) continue;
|
|
592
|
+
for (const observation of group.observations) {
|
|
593
|
+
if (visibleGroupIds && observation.source.primaryGroupId !== group.id)
|
|
594
|
+
continue;
|
|
595
|
+
linkedSourceIds.add(observation.source.id);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (const limitation of projection.limitations) {
|
|
599
|
+
for (const source of limitation.sources) linkedSourceIds.add(source.id);
|
|
600
|
+
}
|
|
601
|
+
const navigableSources = new Map(
|
|
602
|
+
[...projection.sources, ...projection.citableSources].map((source) => [
|
|
603
|
+
source.id,
|
|
604
|
+
source,
|
|
605
|
+
]),
|
|
606
|
+
);
|
|
607
|
+
for (const source of navigableSources.values()) {
|
|
608
|
+
if (!linkedSourceIds.has(source.id)) continue;
|
|
609
|
+
const stepIds = byTurn.get(source.turnIndex) ?? new Set<string>();
|
|
610
|
+
stepIds.add(source.stepId);
|
|
611
|
+
byTurn.set(source.turnIndex, stepIds);
|
|
612
|
+
}
|
|
613
|
+
return byTurn;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function stableHash(value: string): string {
|
|
617
|
+
// FNV-1a keeps long resource/log identities out of DOM IDs. The raw identity
|
|
618
|
+
// remains the Map key, so a hash collision can never merge evidence.
|
|
619
|
+
return fnv1a32(value).toString(36);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
623
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
624
|
+
? (value as Record<string, unknown>)
|
|
625
|
+
: undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function nonEmptyString(value: unknown): value is string {
|
|
629
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
633
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
634
|
+
? value
|
|
635
|
+
: undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function parseJSON(value: string): unknown {
|
|
639
|
+
try {
|
|
640
|
+
return JSON.parse(value);
|
|
641
|
+
} catch {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function kubernetesResource(
|
|
647
|
+
value: unknown,
|
|
648
|
+
): InvestigationKubernetesResource | undefined {
|
|
649
|
+
const resource = record(value);
|
|
650
|
+
const metadata = record(resource?.metadata);
|
|
651
|
+
// Core Secrets intentionally use Radar's current safe detail contract rather
|
|
652
|
+
// than a Kubernetes object: identity + type + key names, with no values. Make
|
|
653
|
+
// that producer shape canonical for the projection instead of rejecting the
|
|
654
|
+
// exact evidence the agent saw.
|
|
655
|
+
if (
|
|
656
|
+
resource?.kind === "Secret" &&
|
|
657
|
+
!metadata &&
|
|
658
|
+
nonEmptyString(resource.name) &&
|
|
659
|
+
(resource.namespace === undefined ||
|
|
660
|
+
typeof resource.namespace === "string") &&
|
|
661
|
+
(resource.type === undefined || typeof resource.type === "string") &&
|
|
662
|
+
Array.isArray(resource.keys) &&
|
|
663
|
+
resource.keys.every((key) => typeof key === "string")
|
|
664
|
+
) {
|
|
665
|
+
return {
|
|
666
|
+
...resource,
|
|
667
|
+
apiVersion: "v1",
|
|
668
|
+
metadata: {
|
|
669
|
+
name: resource.name,
|
|
670
|
+
namespace: resource.namespace as string | undefined,
|
|
671
|
+
...(record(resource.labels) ? { labels: resource.labels } : {}),
|
|
672
|
+
...(record(resource.annotations)
|
|
673
|
+
? { annotations: resource.annotations }
|
|
674
|
+
: {}),
|
|
675
|
+
},
|
|
676
|
+
} as InvestigationKubernetesResource;
|
|
677
|
+
}
|
|
678
|
+
if (
|
|
679
|
+
!resource ||
|
|
680
|
+
!nonEmptyString(resource.apiVersion) ||
|
|
681
|
+
!nonEmptyString(resource.kind) ||
|
|
682
|
+
!metadata ||
|
|
683
|
+
!nonEmptyString(metadata.name)
|
|
684
|
+
) {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
return resource as unknown as InvestigationKubernetesResource;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function gitOpsDiagnosis(
|
|
691
|
+
value: unknown,
|
|
692
|
+
): InvestigationGitOpsDiagnosis | undefined {
|
|
693
|
+
const candidate = record(value);
|
|
694
|
+
if (
|
|
695
|
+
!candidate ||
|
|
696
|
+
(candidate.tool !== "argocd" && candidate.tool !== "flux")
|
|
697
|
+
) {
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
for (const field of [
|
|
701
|
+
"sync",
|
|
702
|
+
"health",
|
|
703
|
+
"operationPhase",
|
|
704
|
+
"ready",
|
|
705
|
+
"appliedRevision",
|
|
706
|
+
] as const) {
|
|
707
|
+
if (candidate[field] !== undefined && typeof candidate[field] !== "string")
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
if (
|
|
711
|
+
candidate.suspended !== undefined &&
|
|
712
|
+
typeof candidate.suspended !== "boolean"
|
|
713
|
+
)
|
|
714
|
+
return undefined;
|
|
715
|
+
return candidate as unknown as InvestigationGitOpsDiagnosis;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function resourceSummary(
|
|
719
|
+
value: unknown,
|
|
720
|
+
): InvestigationResourceSummary | undefined {
|
|
721
|
+
const candidate = record(value);
|
|
722
|
+
if (
|
|
723
|
+
!candidate ||
|
|
724
|
+
!nonEmptyString(candidate.kind) ||
|
|
725
|
+
!nonEmptyString(candidate.name)
|
|
726
|
+
) {
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
if (
|
|
730
|
+
candidate.namespace !== undefined &&
|
|
731
|
+
typeof candidate.namespace !== "string"
|
|
732
|
+
) {
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
for (const field of ["status", "ready", "issue", "age"] as const) {
|
|
736
|
+
if (
|
|
737
|
+
candidate[field] !== undefined &&
|
|
738
|
+
typeof candidate[field] !== "string"
|
|
739
|
+
) {
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (
|
|
744
|
+
(candidate.terminating !== undefined &&
|
|
745
|
+
typeof candidate.terminating !== "boolean") ||
|
|
746
|
+
(candidate.restarts !== undefined && typeof candidate.restarts !== "number")
|
|
747
|
+
) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
const summaryContext = record(candidate.summaryContext);
|
|
751
|
+
if (
|
|
752
|
+
candidate.summaryContext !== undefined &&
|
|
753
|
+
(!summaryContext ||
|
|
754
|
+
(summaryContext.health !== undefined &&
|
|
755
|
+
typeof summaryContext.health !== "string") ||
|
|
756
|
+
(summaryContext.issueCount !== undefined &&
|
|
757
|
+
typeof summaryContext.issueCount !== "number"))
|
|
758
|
+
) {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
return candidate as unknown as InvestigationResourceSummary;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function issue(value: unknown): Issue | undefined {
|
|
765
|
+
const candidate = record(value);
|
|
766
|
+
if (
|
|
767
|
+
!candidate ||
|
|
768
|
+
!nonEmptyString(candidate.id) ||
|
|
769
|
+
(candidate.severity !== "critical" && candidate.severity !== "warning") ||
|
|
770
|
+
!nonEmptyString(candidate.kind) ||
|
|
771
|
+
!nonEmptyString(candidate.name) ||
|
|
772
|
+
!nonEmptyString(candidate.reason)
|
|
773
|
+
) {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
return candidate as unknown as Issue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function recentChange(value: unknown): IssueRecentChange | undefined {
|
|
780
|
+
const candidate = record(value);
|
|
781
|
+
if (
|
|
782
|
+
!candidate ||
|
|
783
|
+
!nonEmptyString(candidate.kind) ||
|
|
784
|
+
(candidate.apiVersion !== undefined &&
|
|
785
|
+
!nonEmptyString(candidate.apiVersion)) ||
|
|
786
|
+
!nonEmptyString(candidate.name) ||
|
|
787
|
+
!nonEmptyString(candidate.changeType) ||
|
|
788
|
+
!nonEmptyString(candidate.timestamp)
|
|
789
|
+
) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
return candidate as unknown as IssueRecentChange;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function diagnosisChangeContext(
|
|
796
|
+
value: unknown,
|
|
797
|
+
): DiagnosisChangeContext | undefined {
|
|
798
|
+
const candidate = record(value);
|
|
799
|
+
if (!candidate || typeof candidate.changed !== "boolean") return undefined;
|
|
800
|
+
for (const field of ["what", "when", "evidence"] as const) {
|
|
801
|
+
if (candidate[field] !== undefined && typeof candidate[field] !== "string")
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
changed: candidate.changed,
|
|
806
|
+
...(typeof candidate.what === "string"
|
|
807
|
+
? {
|
|
808
|
+
what:
|
|
809
|
+
candidate.what === "pod_template"
|
|
810
|
+
? "The workload's Pod template changed"
|
|
811
|
+
: candidate.what,
|
|
812
|
+
}
|
|
813
|
+
: {}),
|
|
814
|
+
...(typeof candidate.when === "string" ? { when: candidate.when } : {}),
|
|
815
|
+
...(typeof candidate.evidence === "string"
|
|
816
|
+
? { evidence: candidate.evidence }
|
|
817
|
+
: {}),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function event(value: unknown): InvestigationEventEvidence | undefined {
|
|
822
|
+
const candidate = record(value);
|
|
823
|
+
if (
|
|
824
|
+
!candidate ||
|
|
825
|
+
!nonEmptyString(candidate.reason) ||
|
|
826
|
+
!nonEmptyString(candidate.message) ||
|
|
827
|
+
!nonEmptyString(candidate.type) ||
|
|
828
|
+
typeof candidate.count !== "number" ||
|
|
829
|
+
!nonEmptyString(candidate.lastTimestamp)
|
|
830
|
+
) {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
return candidate as unknown as InvestigationEventEvidence;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function filteredLogs(value: unknown): DiagnosisFilteredLogs | undefined {
|
|
837
|
+
const candidate = record(value);
|
|
838
|
+
if (
|
|
839
|
+
!candidate ||
|
|
840
|
+
(!Array.isArray(candidate.lines) && candidate.lines !== null) ||
|
|
841
|
+
(Array.isArray(candidate.lines) &&
|
|
842
|
+
!candidate.lines.every((line) => typeof line === "string")) ||
|
|
843
|
+
typeof candidate.totalLines !== "number" ||
|
|
844
|
+
typeof candidate.matchedLines !== "number" ||
|
|
845
|
+
typeof candidate.fallback !== "boolean"
|
|
846
|
+
) {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
...(candidate as unknown as DiagnosisFilteredLogs),
|
|
851
|
+
lines: Array.isArray(candidate.lines)
|
|
852
|
+
? candidate.lines.map((line) => stripAnsi(line))
|
|
853
|
+
: candidate.lines,
|
|
854
|
+
} as DiagnosisFilteredLogs;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function resourceRef(value: unknown): DiagnosisResourceRef | undefined {
|
|
858
|
+
const candidate = record(value);
|
|
859
|
+
if (
|
|
860
|
+
!candidate ||
|
|
861
|
+
!nonEmptyString(candidate.kind) ||
|
|
862
|
+
!nonEmptyString(candidate.name)
|
|
863
|
+
) {
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
return candidate as unknown as DiagnosisResourceRef;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function networkRoute(value: unknown): InvestigationNetworkRoute | undefined {
|
|
870
|
+
const candidate = record(value);
|
|
871
|
+
if (
|
|
872
|
+
!candidate ||
|
|
873
|
+
!nonEmptyString(candidate.route) ||
|
|
874
|
+
!nonEmptyString(candidate.outcome)
|
|
875
|
+
) {
|
|
876
|
+
return undefined;
|
|
877
|
+
}
|
|
878
|
+
for (const field of [
|
|
879
|
+
"target",
|
|
880
|
+
"failedLayer",
|
|
881
|
+
"confidence",
|
|
882
|
+
"evidence",
|
|
883
|
+
] as const) {
|
|
884
|
+
if (candidate[field] !== undefined && typeof candidate[field] !== "string")
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
if (candidate.benign !== undefined && typeof candidate.benign !== "boolean")
|
|
888
|
+
return undefined;
|
|
889
|
+
return candidate as unknown as InvestigationNetworkRoute;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function networkEvidence(
|
|
893
|
+
value: Record<string, unknown>,
|
|
894
|
+
): InvestigationNetworkEvidence | undefined {
|
|
895
|
+
const subject = resourceRef(value.subject);
|
|
896
|
+
const summary = record(value.summary);
|
|
897
|
+
const verdict = value.verdict;
|
|
898
|
+
const routesRaw = value.routes === undefined ? [] : value.routes;
|
|
899
|
+
if (
|
|
900
|
+
!subject ||
|
|
901
|
+
(verdict !== "healthy" &&
|
|
902
|
+
verdict !== "degraded" &&
|
|
903
|
+
verdict !== "broken" &&
|
|
904
|
+
verdict !== "unknown") ||
|
|
905
|
+
!summary ||
|
|
906
|
+
typeof summary.tested !== "number" ||
|
|
907
|
+
typeof summary.passed !== "number" ||
|
|
908
|
+
typeof summary.failed !== "number" ||
|
|
909
|
+
typeof summary.skipped !== "number" ||
|
|
910
|
+
!nonEmptyString(summary.headline) ||
|
|
911
|
+
(summary.derived !== undefined && typeof summary.derived !== "number") ||
|
|
912
|
+
!Array.isArray(routesRaw)
|
|
913
|
+
) {
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
const routes = routesRaw
|
|
917
|
+
.map(networkRoute)
|
|
918
|
+
.filter((route): route is InvestigationNetworkRoute => Boolean(route));
|
|
919
|
+
if (routes.length !== routesRaw.length) return undefined;
|
|
920
|
+
const diagnosis = record(value.diagnosis);
|
|
921
|
+
if (
|
|
922
|
+
diagnosis &&
|
|
923
|
+
(!nonEmptyString(diagnosis.summary) ||
|
|
924
|
+
["class", "severity", "route", "nextAction"].some(
|
|
925
|
+
(field) =>
|
|
926
|
+
diagnosis[field] !== undefined &&
|
|
927
|
+
typeof diagnosis[field] !== "string",
|
|
928
|
+
))
|
|
929
|
+
) {
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
if (value.reason !== undefined && typeof value.reason !== "string")
|
|
933
|
+
return undefined;
|
|
934
|
+
return {
|
|
935
|
+
subject,
|
|
936
|
+
verdict,
|
|
937
|
+
reason: value.reason as string | undefined,
|
|
938
|
+
diagnosis: diagnosis as
|
|
939
|
+
InvestigationNetworkEvidence["diagnosis"] | undefined,
|
|
940
|
+
summary: summary as unknown as InvestigationNetworkEvidence["summary"],
|
|
941
|
+
routes,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function topologyNode(value: unknown): InvestigationTopologyNode | undefined {
|
|
946
|
+
const candidate = record(value);
|
|
947
|
+
if (
|
|
948
|
+
!candidate ||
|
|
949
|
+
!nonEmptyString(candidate.id) ||
|
|
950
|
+
!nonEmptyString(candidate.kind) ||
|
|
951
|
+
!nonEmptyString(candidate.name)
|
|
952
|
+
) {
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
return candidate as unknown as InvestigationTopologyNode;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function topologyEdge(value: unknown): InvestigationTopologyEdge | undefined {
|
|
959
|
+
const candidate = record(value);
|
|
960
|
+
if (
|
|
961
|
+
!candidate ||
|
|
962
|
+
!nonEmptyString(candidate.source) ||
|
|
963
|
+
!nonEmptyString(candidate.target) ||
|
|
964
|
+
!nonEmptyString(candidate.type)
|
|
965
|
+
) {
|
|
966
|
+
return undefined;
|
|
967
|
+
}
|
|
968
|
+
return candidate as unknown as InvestigationTopologyEdge;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
type TopologyPartiality = {
|
|
972
|
+
warnings: string[];
|
|
973
|
+
largeCluster: boolean;
|
|
974
|
+
hiddenKinds: string[];
|
|
975
|
+
requiresNamespaceFilter: boolean;
|
|
976
|
+
crdDiscoveryStatus?: NonNullable<Topology["crdDiscoveryStatus"]>;
|
|
977
|
+
estimatedNodes?: number;
|
|
978
|
+
summaryMode: boolean;
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Both get_topology wire shapes carry the same completeness metadata. Keep the
|
|
983
|
+
* adapter strict: silently dropping a malformed flag would make a partial graph
|
|
984
|
+
* look complete in Evidence.
|
|
985
|
+
*/
|
|
986
|
+
function topologyPartiality(
|
|
987
|
+
value: Record<string, unknown>,
|
|
988
|
+
): TopologyPartiality | undefined {
|
|
989
|
+
const warnings =
|
|
990
|
+
value.warnings === undefined ? [] : stringArray(value.warnings);
|
|
991
|
+
const hiddenKinds =
|
|
992
|
+
value.hiddenKinds === undefined ? [] : stringArray(value.hiddenKinds);
|
|
993
|
+
const discovery = value.crdDiscoveryStatus;
|
|
994
|
+
const estimatedNodes = value.estimatedNodes;
|
|
995
|
+
if (
|
|
996
|
+
!warnings ||
|
|
997
|
+
!hiddenKinds ||
|
|
998
|
+
(value.largeCluster !== undefined &&
|
|
999
|
+
typeof value.largeCluster !== "boolean") ||
|
|
1000
|
+
(value.requiresNamespaceFilter !== undefined &&
|
|
1001
|
+
typeof value.requiresNamespaceFilter !== "boolean") ||
|
|
1002
|
+
(value.summaryMode !== undefined &&
|
|
1003
|
+
typeof value.summaryMode !== "boolean") ||
|
|
1004
|
+
(discovery !== undefined &&
|
|
1005
|
+
discovery !== "idle" &&
|
|
1006
|
+
discovery !== "discovering" &&
|
|
1007
|
+
discovery !== "ready") ||
|
|
1008
|
+
(estimatedNodes !== undefined &&
|
|
1009
|
+
(typeof estimatedNodes !== "number" ||
|
|
1010
|
+
!Number.isSafeInteger(estimatedNodes) ||
|
|
1011
|
+
estimatedNodes < 0))
|
|
1012
|
+
) {
|
|
1013
|
+
return undefined;
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
warnings,
|
|
1017
|
+
largeCluster: value.largeCluster === true,
|
|
1018
|
+
hiddenKinds,
|
|
1019
|
+
requiresNamespaceFilter: value.requiresNamespaceFilter === true,
|
|
1020
|
+
crdDiscoveryStatus: discovery as
|
|
1021
|
+
NonNullable<Topology["crdDiscoveryStatus"]> | undefined,
|
|
1022
|
+
estimatedNodes,
|
|
1023
|
+
summaryMode: value.summaryMode === true,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function addTopologyLimitations(
|
|
1028
|
+
builder: ProjectionBuilder,
|
|
1029
|
+
source: InvestigationEvidenceSource,
|
|
1030
|
+
partiality: TopologyPartiality,
|
|
1031
|
+
): void {
|
|
1032
|
+
for (const warning of partiality.warnings) {
|
|
1033
|
+
const normalized = warning.toLowerCase();
|
|
1034
|
+
builder.limit(
|
|
1035
|
+
source,
|
|
1036
|
+
"Topology coverage",
|
|
1037
|
+
warning,
|
|
1038
|
+
normalized.includes("large graph") || normalized.includes("too large")
|
|
1039
|
+
? "truncated"
|
|
1040
|
+
: "unknown",
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const scaleDetails: string[] = [];
|
|
1045
|
+
const estimate = partiality.estimatedNodes
|
|
1046
|
+
? ` (about ${partiality.estimatedNodes} estimated nodes)`
|
|
1047
|
+
: "";
|
|
1048
|
+
if (partiality.requiresNamespaceFilter) {
|
|
1049
|
+
scaleDetails.push(
|
|
1050
|
+
`The all-namespace topology was not built because the cluster is too large${estimate}; run a namespace-scoped topology search to collect a smaller graph.`,
|
|
1051
|
+
);
|
|
1052
|
+
} else if (partiality.largeCluster) {
|
|
1053
|
+
scaleDetails.push(
|
|
1054
|
+
`Large-cluster optimizations were active${estimate}; high-cardinality detail may be grouped.`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
if (partiality.hiddenKinds.length > 0) {
|
|
1058
|
+
scaleDetails.push(
|
|
1059
|
+
`Resource kinds omitted by the large-cluster optimization: ${partiality.hiddenKinds.join(", ")}.`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
if (partiality.summaryMode) {
|
|
1063
|
+
scaleDetails.push(
|
|
1064
|
+
"Summary mode collapsed individual Pods into workload or Service counts.",
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (scaleDetails.length > 0) {
|
|
1068
|
+
builder.limit(
|
|
1069
|
+
source,
|
|
1070
|
+
"Topology scale",
|
|
1071
|
+
scaleDetails.join(" "),
|
|
1072
|
+
"truncated",
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (
|
|
1077
|
+
partiality.crdDiscoveryStatus === "idle" ||
|
|
1078
|
+
partiality.crdDiscoveryStatus === "discovering"
|
|
1079
|
+
) {
|
|
1080
|
+
builder.limit(
|
|
1081
|
+
source,
|
|
1082
|
+
"Custom Resource topology",
|
|
1083
|
+
partiality.crdDiscoveryStatus === "idle"
|
|
1084
|
+
? "Custom Resource discovery had not started when this topology was captured; Custom Resource nodes and relationships may be missing."
|
|
1085
|
+
: "Custom Resource discovery was still in progress when this topology was captured; Custom Resource nodes and relationships may be missing.",
|
|
1086
|
+
"unknown",
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function scopeFromArgs(source: InvestigationEvidenceSource): string {
|
|
1092
|
+
if (!source.args) return "requested scope";
|
|
1093
|
+
const args = record(parseJSON(source.args));
|
|
1094
|
+
if (!args) return "requested scope";
|
|
1095
|
+
const kind = nonEmptyString(args.kind) ? displayKind(args.kind) : undefined;
|
|
1096
|
+
const namespace = nonEmptyString(args.namespace) ? args.namespace : undefined;
|
|
1097
|
+
const name = nonEmptyString(args.name) ? args.name : undefined;
|
|
1098
|
+
if (kind && name) {
|
|
1099
|
+
return kind + " " + (namespace ? namespace + "/" : "") + name;
|
|
1100
|
+
}
|
|
1101
|
+
if (kind && namespace) return kind + " resources in " + namespace;
|
|
1102
|
+
if (kind) return kind + " resources";
|
|
1103
|
+
if (namespace && name) return namespace + "/" + name;
|
|
1104
|
+
if (namespace) return "namespace " + namespace;
|
|
1105
|
+
if (name) return name;
|
|
1106
|
+
return "requested scope";
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
|
|
1110
|
+
diagnose: "Workload diagnosis",
|
|
1111
|
+
issues: "Issue scan",
|
|
1112
|
+
get_resource: "Resource details",
|
|
1113
|
+
list_resources: "Resource inventory",
|
|
1114
|
+
get_events: "Kubernetes events",
|
|
1115
|
+
get_pod_logs: "Container logs",
|
|
1116
|
+
get_changes: "Recent changes",
|
|
1117
|
+
get_neighborhood: "Relationships",
|
|
1118
|
+
get_topology: "Topology",
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
function investigationResultLabel(source: InvestigationEvidenceSource): string {
|
|
1122
|
+
return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function resourceMatchesTarget(
|
|
1126
|
+
target: InvestigationEvidenceTarget,
|
|
1127
|
+
resource: {
|
|
1128
|
+
kind: string;
|
|
1129
|
+
group?: string;
|
|
1130
|
+
namespace?: string;
|
|
1131
|
+
name: string;
|
|
1132
|
+
},
|
|
1133
|
+
): boolean {
|
|
1134
|
+
return (
|
|
1135
|
+
resource.kind.toLowerCase() === target.kind.toLowerCase() &&
|
|
1136
|
+
(resource.group ?? "").toLowerCase() === target.group.toLowerCase() &&
|
|
1137
|
+
(resource.namespace ?? "") === (target.namespace ?? "") &&
|
|
1138
|
+
resource.name === target.name
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function relevanceForResource(
|
|
1143
|
+
builder: ProjectionBuilder,
|
|
1144
|
+
resource: {
|
|
1145
|
+
kind: string;
|
|
1146
|
+
group?: string;
|
|
1147
|
+
namespace?: string;
|
|
1148
|
+
name: string;
|
|
1149
|
+
},
|
|
1150
|
+
): InvestigationEvidenceRelevance {
|
|
1151
|
+
return resourceMatchesTarget(builder.target, resource) ? "target" : "broader";
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function sourceArgsRelevance(
|
|
1155
|
+
builder: ProjectionBuilder,
|
|
1156
|
+
source: InvestigationEvidenceSource,
|
|
1157
|
+
impliedKind?: string,
|
|
1158
|
+
): InvestigationEvidenceRelevance {
|
|
1159
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
1160
|
+
const kind = nonEmptyString(args?.kind) ? args.kind : impliedKind;
|
|
1161
|
+
if (!kind || !nonEmptyString(args?.name)) return "broader";
|
|
1162
|
+
return relevanceForResource(builder, {
|
|
1163
|
+
kind,
|
|
1164
|
+
// get_events/get_changes/issues cannot express an API group today. An
|
|
1165
|
+
// omitted group is therefore unspecified, not proof that the caller meant
|
|
1166
|
+
// the core API group. Use the known investigation target for that missing
|
|
1167
|
+
// dimension; if a producer does provide a group, exact matching still
|
|
1168
|
+
// applies (including an explicitly empty core group).
|
|
1169
|
+
group: typeof args?.group === "string" ? args.group : builder.target.group,
|
|
1170
|
+
namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
|
|
1171
|
+
name: args.name,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function evidenceTierForRelevance(
|
|
1176
|
+
intended: InvestigationEvidenceTier,
|
|
1177
|
+
relevance: InvestigationEvidenceRelevance,
|
|
1178
|
+
): InvestigationEvidenceTier {
|
|
1179
|
+
return relevance === "broader" ? "context" : intended;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const DIAGNOSABLE_WORKLOAD_KINDS = new Set([
|
|
1183
|
+
"pod",
|
|
1184
|
+
"deployment",
|
|
1185
|
+
"statefulset",
|
|
1186
|
+
"daemonset",
|
|
1187
|
+
"rollout",
|
|
1188
|
+
]);
|
|
1189
|
+
|
|
1190
|
+
function isDiagnosableWorkloadKind(kind: string): boolean {
|
|
1191
|
+
return DIAGNOSABLE_WORKLOAD_KINDS.has(kind.toLowerCase());
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function previousFromArgs(source: InvestigationEvidenceSource): boolean {
|
|
1195
|
+
return (
|
|
1196
|
+
record(source.args ? parseJSON(source.args) : undefined)?.previous === true
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
class ProjectionBuilder {
|
|
1201
|
+
readonly groups: InvestigationEvidenceGroup[] = [];
|
|
1202
|
+
readonly sources: InvestigationEvidenceSource[] = [];
|
|
1203
|
+
readonly limitations: InvestigationEvidenceLimitation[] = [];
|
|
1204
|
+
readonly projectedSources = new Set<string>();
|
|
1205
|
+
readonly checkedSources = new Set<string>();
|
|
1206
|
+
readonly limitedSources = new Set<string>();
|
|
1207
|
+
readonly semanticCoverageBySource = new Map<
|
|
1208
|
+
string,
|
|
1209
|
+
Set<InvestigationSemanticDomain>
|
|
1210
|
+
>();
|
|
1211
|
+
|
|
1212
|
+
private readonly groupByIdentity = new Map<
|
|
1213
|
+
string,
|
|
1214
|
+
InvestigationEvidenceGroup
|
|
1215
|
+
>();
|
|
1216
|
+
private readonly limitationByIdentity = new Map<
|
|
1217
|
+
string,
|
|
1218
|
+
InvestigationEvidenceLimitation
|
|
1219
|
+
>();
|
|
1220
|
+
|
|
1221
|
+
constructor(readonly target: InvestigationEvidenceTarget) {}
|
|
1222
|
+
|
|
1223
|
+
addSource(source: InvestigationEvidenceSource): void {
|
|
1224
|
+
this.sources.push(source);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
coverSemantic(
|
|
1228
|
+
source: InvestigationEvidenceSource,
|
|
1229
|
+
domain: InvestigationSemanticDomain,
|
|
1230
|
+
): void {
|
|
1231
|
+
const covered = this.semanticCoverageBySource.get(source.id);
|
|
1232
|
+
if (covered) {
|
|
1233
|
+
covered.add(domain);
|
|
1234
|
+
} else {
|
|
1235
|
+
this.semanticCoverageBySource.set(source.id, new Set([domain]));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
observe(
|
|
1240
|
+
identity: string,
|
|
1241
|
+
kind: InvestigationEvidenceKind,
|
|
1242
|
+
source: InvestigationEvidenceSource,
|
|
1243
|
+
observation: Omit<
|
|
1244
|
+
InvestigationEvidenceObservation,
|
|
1245
|
+
"source" | "revision" | "historical" | "changedFromPrevious" | "relevance"
|
|
1246
|
+
> & { relevance: InvestigationEvidenceRelevance },
|
|
1247
|
+
): void {
|
|
1248
|
+
// Logs and synthesized startup/crash evidence do not carry a stable object
|
|
1249
|
+
// UID (and logs do not carry namespace in the producer row). Keep different
|
|
1250
|
+
// proof scopes separate so a same-named sibling can never inherit stronger
|
|
1251
|
+
// target provenance merely by colliding on its display identity.
|
|
1252
|
+
const partitionByRelevance =
|
|
1253
|
+
kind === "logs" ||
|
|
1254
|
+
kind === "startup" ||
|
|
1255
|
+
kind === "crash" ||
|
|
1256
|
+
(kind === "receipt" && identity.startsWith("previous-log-absence:"));
|
|
1257
|
+
const mapKey = `${kind}\u0000${identity}${
|
|
1258
|
+
partitionByRelevance
|
|
1259
|
+
? `\u0000${observation.relevance}\u0000${scopeFromArgs(source)}`
|
|
1260
|
+
: ""
|
|
1261
|
+
}`;
|
|
1262
|
+
let group = this.groupByIdentity.get(mapKey);
|
|
1263
|
+
const previousObservation = group?.observations.at(-1);
|
|
1264
|
+
const changedFromPrevious = previousObservation
|
|
1265
|
+
? evidenceSemanticSnapshot(previousObservation) !==
|
|
1266
|
+
evidenceSemanticSnapshot(observation)
|
|
1267
|
+
: false;
|
|
1268
|
+
const next: InvestigationEvidenceObservation = {
|
|
1269
|
+
...observation,
|
|
1270
|
+
source,
|
|
1271
|
+
revision: group ? group.observations.length + 1 : 1,
|
|
1272
|
+
historical: false,
|
|
1273
|
+
changedFromPrevious,
|
|
1274
|
+
};
|
|
1275
|
+
if (!group) {
|
|
1276
|
+
group = {
|
|
1277
|
+
id: `evidence-${kind}-${stableHash(mapKey)}`,
|
|
1278
|
+
identity,
|
|
1279
|
+
kind,
|
|
1280
|
+
historical: false,
|
|
1281
|
+
firstOrder: source.order,
|
|
1282
|
+
observations: [],
|
|
1283
|
+
latest: next,
|
|
1284
|
+
chronologicalLatest: next,
|
|
1285
|
+
};
|
|
1286
|
+
this.groupByIdentity.set(mapKey, group);
|
|
1287
|
+
this.groups.push(group);
|
|
1288
|
+
}
|
|
1289
|
+
group.observations.push(next);
|
|
1290
|
+
group.chronologicalLatest = next;
|
|
1291
|
+
const relevanceRank: Record<InvestigationEvidenceRelevance, number> = {
|
|
1292
|
+
target: 0,
|
|
1293
|
+
"producer-related": 1,
|
|
1294
|
+
broader: 2,
|
|
1295
|
+
};
|
|
1296
|
+
// A broad inventory/read can re-observe an item whose relationship to the
|
|
1297
|
+
// target was already established by a scoped producer. Keep the broad read
|
|
1298
|
+
// in revision history, but never let weaker provenance replace the card's
|
|
1299
|
+
// authoritative observation. Equal provenance still advances normally.
|
|
1300
|
+
if (
|
|
1301
|
+
relevanceRank[next.relevance] <= relevanceRank[group.latest.relevance]
|
|
1302
|
+
) {
|
|
1303
|
+
group.latest = next;
|
|
1304
|
+
}
|
|
1305
|
+
this.projectedSources.add(source.id);
|
|
1306
|
+
if (next.tier === "checked") this.checkedSources.add(source.id);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
limit(
|
|
1310
|
+
source: InvestigationEvidenceSource,
|
|
1311
|
+
label: string,
|
|
1312
|
+
message: string | undefined,
|
|
1313
|
+
kind: DiagnosisEvidenceLimitationBase["kind"],
|
|
1314
|
+
presentation?: InvestigationEvidenceLimitation["presentation"],
|
|
1315
|
+
): void {
|
|
1316
|
+
if (!message?.trim()) return;
|
|
1317
|
+
const normalized = message.trim();
|
|
1318
|
+
const key = `${kind}\u0000${presentation ?? ""}\u0000${label}\u0000${normalized}`;
|
|
1319
|
+
const existing = this.limitationByIdentity.get(key);
|
|
1320
|
+
if (existing) {
|
|
1321
|
+
if (!existing.sources.some((item) => item.id === source.id)) {
|
|
1322
|
+
existing.sources.push(source);
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
const limitation: InvestigationEvidenceLimitation = {
|
|
1326
|
+
source: label,
|
|
1327
|
+
message: normalized,
|
|
1328
|
+
kind,
|
|
1329
|
+
...(presentation ? { presentation } : {}),
|
|
1330
|
+
firstOrder: source.order,
|
|
1331
|
+
sources: [source],
|
|
1332
|
+
};
|
|
1333
|
+
this.limitationByIdentity.set(key, limitation);
|
|
1334
|
+
this.limitations.push(limitation);
|
|
1335
|
+
}
|
|
1336
|
+
this.limitedSources.add(source.id);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
export function evidenceSemanticSnapshot(
|
|
1341
|
+
observation: Pick<
|
|
1342
|
+
InvestigationEvidenceObservation,
|
|
1343
|
+
"tone" | "title" | "summary" | "data"
|
|
1344
|
+
>,
|
|
1345
|
+
): string {
|
|
1346
|
+
if (observation.data.type === "resource") {
|
|
1347
|
+
// Tool-specific context can change the summary/tone without changing the
|
|
1348
|
+
// object. Keep that context in history, not in the resource-change signal.
|
|
1349
|
+
return JSON.stringify({
|
|
1350
|
+
type: "resource",
|
|
1351
|
+
resource: observation.data.resource,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
const data =
|
|
1355
|
+
observation.data.type === "issue"
|
|
1356
|
+
? {
|
|
1357
|
+
type: observation.data.type,
|
|
1358
|
+
// These are the finding and details shown in the evidence card.
|
|
1359
|
+
// Collection timestamps and detector bookkeeping do not change it.
|
|
1360
|
+
issue: {
|
|
1361
|
+
kind: observation.data.issue.kind,
|
|
1362
|
+
group: observation.data.issue.group,
|
|
1363
|
+
namespace: observation.data.issue.namespace,
|
|
1364
|
+
name: observation.data.issue.name,
|
|
1365
|
+
reason: observation.data.issue.reason,
|
|
1366
|
+
severity: observation.data.issue.severity,
|
|
1367
|
+
cause: observation.data.issue.cause,
|
|
1368
|
+
message: observation.data.issue.message,
|
|
1369
|
+
},
|
|
1370
|
+
}
|
|
1371
|
+
: observation.data;
|
|
1372
|
+
return JSON.stringify({
|
|
1373
|
+
tone: observation.tone,
|
|
1374
|
+
title: observation.title,
|
|
1375
|
+
summary: observation.summary,
|
|
1376
|
+
data,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function contextFrom(value: unknown): InvestigationResourceContext | undefined {
|
|
1381
|
+
const candidate = record(value);
|
|
1382
|
+
if (!candidate || !nonEmptyString(candidate.tier)) return undefined;
|
|
1383
|
+
return candidate as unknown as InvestigationResourceContext;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function addNarrowHint(
|
|
1387
|
+
builder: ProjectionBuilder,
|
|
1388
|
+
source: InvestigationEvidenceSource,
|
|
1389
|
+
value: Record<string, unknown>,
|
|
1390
|
+
): void {
|
|
1391
|
+
if (nonEmptyString(value.narrowHint)) {
|
|
1392
|
+
const label = investigationResultLabel(source);
|
|
1393
|
+
builder.limit(
|
|
1394
|
+
source,
|
|
1395
|
+
label,
|
|
1396
|
+
label +
|
|
1397
|
+
" was narrowed to keep this investigation bounded. Additional matching evidence may exist.",
|
|
1398
|
+
"truncated",
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function addResourceContextLimitations(
|
|
1404
|
+
builder: ProjectionBuilder,
|
|
1405
|
+
source: InvestigationEvidenceSource,
|
|
1406
|
+
context: InvestigationResourceContext | undefined,
|
|
1407
|
+
): void {
|
|
1408
|
+
if (!context) return;
|
|
1409
|
+
for (const value of Array.isArray(context.omitted) ? context.omitted : []) {
|
|
1410
|
+
const omitted = record(value);
|
|
1411
|
+
if (
|
|
1412
|
+
!omitted ||
|
|
1413
|
+
!nonEmptyString(omitted.field) ||
|
|
1414
|
+
!nonEmptyString(omitted.reason)
|
|
1415
|
+
)
|
|
1416
|
+
continue;
|
|
1417
|
+
builder.limit(
|
|
1418
|
+
source,
|
|
1419
|
+
omitted.field,
|
|
1420
|
+
`Resource context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
|
|
1421
|
+
omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
if (context.referencedBy?.truncated) {
|
|
1425
|
+
const shown = context.referencedBy.items?.length ?? 0;
|
|
1426
|
+
builder.limit(
|
|
1427
|
+
source,
|
|
1428
|
+
"Relationships",
|
|
1429
|
+
`Referenced-by relationships were truncated (${shown} of ${context.referencedBy.total} returned).`,
|
|
1430
|
+
"truncated",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
if (context.appReferences?.staleSecretEnvTruncated) {
|
|
1434
|
+
builder.limit(
|
|
1435
|
+
source,
|
|
1436
|
+
"Application references",
|
|
1437
|
+
"Additional stale Secret environment reference groups were omitted.",
|
|
1438
|
+
"truncated",
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function addIssueLimitations(
|
|
1444
|
+
builder: ProjectionBuilder,
|
|
1445
|
+
source: InvestigationEvidenceSource,
|
|
1446
|
+
value: Issue,
|
|
1447
|
+
): void {
|
|
1448
|
+
if (value.members_truncated) {
|
|
1449
|
+
builder.limit(
|
|
1450
|
+
source,
|
|
1451
|
+
`Radar Issue ${value.id}`,
|
|
1452
|
+
"The affected-resource member list was truncated.",
|
|
1453
|
+
"truncated",
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function resourceObservationSummary(
|
|
1459
|
+
resource: InvestigationKubernetesResource,
|
|
1460
|
+
context: InvestigationResourceContext | undefined,
|
|
1461
|
+
warnings: string[],
|
|
1462
|
+
gitOps?: InvestigationGitOpsDiagnosis,
|
|
1463
|
+
detailedIssueShown = false,
|
|
1464
|
+
): string | undefined {
|
|
1465
|
+
if (!detailedIssueShown && context?.issueSummary?.topReason) {
|
|
1466
|
+
return context.issueSummary.topReason;
|
|
1467
|
+
}
|
|
1468
|
+
if (gitOps?.health) return `Health ${gitOps.health}`;
|
|
1469
|
+
if (gitOps?.ready) return `Ready ${gitOps.ready}`;
|
|
1470
|
+
if (gitOps?.sync) return `Sync ${gitOps.sync}`;
|
|
1471
|
+
if (gitOps?.suspended) return "Reconciliation suspended";
|
|
1472
|
+
const replicas = context?.workloadSummary?.replicas;
|
|
1473
|
+
if (replicas?.desired !== undefined) {
|
|
1474
|
+
const desired = replicas.desired;
|
|
1475
|
+
const ready = replicas.ready ?? 0;
|
|
1476
|
+
return `${ready}/${desired} replicas ready`;
|
|
1477
|
+
}
|
|
1478
|
+
if (context?.statusSummary?.phase) return context.statusSummary.phase;
|
|
1479
|
+
if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
|
|
1480
|
+
return (
|
|
1481
|
+
investigationResourceEvidenceSummary(resource) ||
|
|
1482
|
+
warnings[0] ||
|
|
1483
|
+
resource.metadata.namespace
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function addResourceObservation(
|
|
1488
|
+
builder: ProjectionBuilder,
|
|
1489
|
+
source: InvestigationEvidenceSource,
|
|
1490
|
+
resource: InvestigationKubernetesResource,
|
|
1491
|
+
context: InvestigationResourceContext | undefined,
|
|
1492
|
+
warnings: string[],
|
|
1493
|
+
gitOpsDiagnosis: InvestigationGitOpsDiagnosis | undefined,
|
|
1494
|
+
hasDetailedCriticalIssue: boolean,
|
|
1495
|
+
relevance: InvestigationEvidenceRelevance,
|
|
1496
|
+
): void {
|
|
1497
|
+
const issueSummary = context?.issueSummary;
|
|
1498
|
+
const severity = issueSummary?.highestSeverity ?? "";
|
|
1499
|
+
const critical = severity.toLowerCase() === "critical";
|
|
1500
|
+
const hasLiveIssue = (issueSummary?.count ?? 0) > 0;
|
|
1501
|
+
const replicas = context?.workloadSummary?.replicas;
|
|
1502
|
+
const desired = replicas?.desired;
|
|
1503
|
+
const ready = replicas ? (replicas.ready ?? 0) : undefined;
|
|
1504
|
+
const available = replicas ? (replicas.available ?? 0) : undefined;
|
|
1505
|
+
const replicaShortfall =
|
|
1506
|
+
desired !== undefined &&
|
|
1507
|
+
desired > 0 &&
|
|
1508
|
+
((ready !== undefined && ready < desired) ||
|
|
1509
|
+
(available !== undefined && available < desired) ||
|
|
1510
|
+
(replicas?.unavailable ?? 0) > 0);
|
|
1511
|
+
const adverseCondition = (context?.statusSummary?.conditions ?? []).some(
|
|
1512
|
+
(condition) => defaultConditionTone(condition) === "fail",
|
|
1513
|
+
);
|
|
1514
|
+
const gitOpsAdverse = Boolean(
|
|
1515
|
+
gitOpsDiagnosis &&
|
|
1516
|
+
(gitOpsDiagnosis.health?.toLowerCase() === "degraded" ||
|
|
1517
|
+
gitOpsDiagnosis.health?.toLowerCase() === "missing" ||
|
|
1518
|
+
gitOpsDiagnosis.sync?.toLowerCase() === "outofsync" ||
|
|
1519
|
+
gitOpsDiagnosis.ready?.toLowerCase().startsWith("false") ||
|
|
1520
|
+
["failed", "error"].includes(
|
|
1521
|
+
gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
|
|
1522
|
+
)),
|
|
1523
|
+
);
|
|
1524
|
+
const hasAdverseState = replicaShortfall || adverseCondition || gitOpsAdverse;
|
|
1525
|
+
const intendedTier: InvestigationEvidenceTier =
|
|
1526
|
+
critical && !hasDetailedCriticalIssue
|
|
1527
|
+
? "key"
|
|
1528
|
+
: hasLiveIssue
|
|
1529
|
+
? "supporting"
|
|
1530
|
+
: hasAdverseState
|
|
1531
|
+
? "supporting"
|
|
1532
|
+
: "context";
|
|
1533
|
+
const tier = evidenceTierForRelevance(intendedTier, relevance);
|
|
1534
|
+
const tone = hasLiveIssue
|
|
1535
|
+
? diagnosisSeverityTone(severity)
|
|
1536
|
+
: hasAdverseState
|
|
1537
|
+
? "warning"
|
|
1538
|
+
: warnings.length > 0
|
|
1539
|
+
? "warning"
|
|
1540
|
+
: "neutral";
|
|
1541
|
+
const namespace = resource.metadata.namespace;
|
|
1542
|
+
const identity = `${resource.apiVersion}:${resource.kind}:${namespace ?? ""}:${resource.metadata.name}`;
|
|
1543
|
+
builder.observe(identity, "resource", source, {
|
|
1544
|
+
tier,
|
|
1545
|
+
relevance,
|
|
1546
|
+
tone,
|
|
1547
|
+
title: `${resource.kind} ${namespace ? `${namespace}/` : ""}${resource.metadata.name}`,
|
|
1548
|
+
summary: resourceObservationSummary(
|
|
1549
|
+
resource,
|
|
1550
|
+
context,
|
|
1551
|
+
warnings,
|
|
1552
|
+
gitOpsDiagnosis,
|
|
1553
|
+
hasDetailedCriticalIssue,
|
|
1554
|
+
),
|
|
1555
|
+
data: {
|
|
1556
|
+
type: "resource",
|
|
1557
|
+
resource,
|
|
1558
|
+
resourceContext: context,
|
|
1559
|
+
warnings,
|
|
1560
|
+
gitOpsDiagnosis,
|
|
1561
|
+
},
|
|
1562
|
+
});
|
|
1563
|
+
addResourceContextLimitations(builder, source, context);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function addIssueObservation(
|
|
1567
|
+
builder: ProjectionBuilder,
|
|
1568
|
+
source: InvestigationEvidenceSource,
|
|
1569
|
+
value: Issue,
|
|
1570
|
+
producerRelevance: InvestigationEvidenceRelevance = "broader",
|
|
1571
|
+
): void {
|
|
1572
|
+
const matchesTarget = resourceMatchesTarget(builder.target, {
|
|
1573
|
+
kind: value.kind,
|
|
1574
|
+
group: value.group ?? "",
|
|
1575
|
+
namespace: value.namespace,
|
|
1576
|
+
name: value.name,
|
|
1577
|
+
});
|
|
1578
|
+
const relevance = matchesTarget ? "target" : producerRelevance;
|
|
1579
|
+
builder.observe(`issue:${value.id}`, "issue", source, {
|
|
1580
|
+
tier:
|
|
1581
|
+
relevance === "broader"
|
|
1582
|
+
? "context"
|
|
1583
|
+
: value.severity === "critical"
|
|
1584
|
+
? "key"
|
|
1585
|
+
: "supporting",
|
|
1586
|
+
tone: diagnosisSeverityTone(value.severity),
|
|
1587
|
+
relevance,
|
|
1588
|
+
title: value.reason,
|
|
1589
|
+
summary: value.cause || value.message,
|
|
1590
|
+
data: { type: "issue", issue: value, relevance },
|
|
1591
|
+
});
|
|
1592
|
+
addIssueLimitations(builder, source, value);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function startupBlocker(value: unknown): DiagnosisStartupBlocker | undefined {
|
|
1596
|
+
const candidate = record(value);
|
|
1597
|
+
if (
|
|
1598
|
+
!candidate ||
|
|
1599
|
+
!nonEmptyString(candidate.kind) ||
|
|
1600
|
+
!nonEmptyString(candidate.name) ||
|
|
1601
|
+
!nonEmptyString(candidate.reason) ||
|
|
1602
|
+
!nonEmptyString(candidate.severity) ||
|
|
1603
|
+
!nonEmptyString(candidate.message)
|
|
1604
|
+
) {
|
|
1605
|
+
return undefined;
|
|
1606
|
+
}
|
|
1607
|
+
return candidate as unknown as DiagnosisStartupBlocker;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function crashCause(value: unknown): DiagnosisCrashCause | undefined {
|
|
1611
|
+
const candidate = record(value);
|
|
1612
|
+
if (
|
|
1613
|
+
!candidate ||
|
|
1614
|
+
!Array.isArray(candidate.pods) ||
|
|
1615
|
+
!candidate.pods.every((pod) => typeof pod === "string") ||
|
|
1616
|
+
!nonEmptyString(candidate.container) ||
|
|
1617
|
+
!nonEmptyString(candidate.state) ||
|
|
1618
|
+
typeof candidate.exitCode !== "number" ||
|
|
1619
|
+
!nonEmptyString(candidate.logLine) ||
|
|
1620
|
+
!nonEmptyString(candidate.logSource) ||
|
|
1621
|
+
!nonEmptyString(candidate.logLineSelection)
|
|
1622
|
+
) {
|
|
1623
|
+
return undefined;
|
|
1624
|
+
}
|
|
1625
|
+
return {
|
|
1626
|
+
...(candidate as unknown as DiagnosisCrashCause),
|
|
1627
|
+
logLine: stripAnsi(candidate.logLine as string),
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function addEvents(
|
|
1632
|
+
builder: ProjectionBuilder,
|
|
1633
|
+
source: InvestigationEvidenceSource,
|
|
1634
|
+
values: InvestigationEventEvidence[],
|
|
1635
|
+
identity: string,
|
|
1636
|
+
complete = true,
|
|
1637
|
+
emptyIsAuthoritative = false,
|
|
1638
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
1639
|
+
): void {
|
|
1640
|
+
const scope = scopeFromArgs(source);
|
|
1641
|
+
if (values.length === 0) {
|
|
1642
|
+
if (!source.confirmedSuccess || !complete) return;
|
|
1643
|
+
if (!emptyIsAuthoritative) {
|
|
1644
|
+
builder.limit(
|
|
1645
|
+
source,
|
|
1646
|
+
"Events",
|
|
1647
|
+
"No events were returned. This result does not establish that no events occurred.",
|
|
1648
|
+
"unknown",
|
|
1649
|
+
);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
builder.observe(identity, "receipt", source, {
|
|
1653
|
+
tier: evidenceTierForRelevance("checked", relevance),
|
|
1654
|
+
relevance,
|
|
1655
|
+
tone: "neutral",
|
|
1656
|
+
title: "No matching warning events",
|
|
1657
|
+
summary: scope,
|
|
1658
|
+
data: {
|
|
1659
|
+
type: "receipt",
|
|
1660
|
+
checked: "events",
|
|
1661
|
+
scope,
|
|
1662
|
+
message: "The warning-event query completed and returned no groups.",
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
builder.observe(identity, "events", source, {
|
|
1668
|
+
tier: evidenceTierForRelevance(
|
|
1669
|
+
values.some((item) => item.type.toLowerCase() === "warning")
|
|
1670
|
+
? "supporting"
|
|
1671
|
+
: "context",
|
|
1672
|
+
relevance,
|
|
1673
|
+
),
|
|
1674
|
+
relevance,
|
|
1675
|
+
tone: values.some((item) => item.type.toLowerCase() === "warning")
|
|
1676
|
+
? "warning"
|
|
1677
|
+
: "info",
|
|
1678
|
+
title: "Kubernetes events",
|
|
1679
|
+
summary: `${values.length} event group${values.length === 1 ? "" : "s"} · ${scope}`,
|
|
1680
|
+
data: { type: "events", events: values, scope },
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function addChanges(
|
|
1685
|
+
builder: ProjectionBuilder,
|
|
1686
|
+
source: InvestigationEvidenceSource,
|
|
1687
|
+
values: IssueRecentChange[],
|
|
1688
|
+
identity: string,
|
|
1689
|
+
changeContext?: DiagnosisChangeContext,
|
|
1690
|
+
complete = true,
|
|
1691
|
+
emptyIsAuthoritative = false,
|
|
1692
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
1693
|
+
): void {
|
|
1694
|
+
const scope = scopeFromArgs(source);
|
|
1695
|
+
if (values.length === 0 && !changeContext?.changed) {
|
|
1696
|
+
if (!source.confirmedSuccess || !complete) return;
|
|
1697
|
+
if (!emptyIsAuthoritative) {
|
|
1698
|
+
builder.limit(
|
|
1699
|
+
source,
|
|
1700
|
+
"Recent changes",
|
|
1701
|
+
`No changes were returned for ${scope}. This result does not establish a complete change history.`,
|
|
1702
|
+
"unknown",
|
|
1703
|
+
"history",
|
|
1704
|
+
);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
builder.observe(identity, "receipt", source, {
|
|
1708
|
+
tier: evidenceTierForRelevance("checked", relevance),
|
|
1709
|
+
relevance,
|
|
1710
|
+
tone: "neutral",
|
|
1711
|
+
title: "No tracked recent changes",
|
|
1712
|
+
summary: scope,
|
|
1713
|
+
data: {
|
|
1714
|
+
type: "receipt",
|
|
1715
|
+
checked: "changes",
|
|
1716
|
+
scope,
|
|
1717
|
+
message: "The requested change window returned no tracked changes.",
|
|
1718
|
+
},
|
|
1719
|
+
});
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
builder.observe(identity, "changes", source, {
|
|
1723
|
+
// A producer-correlated change supports the diagnosis. A merely recent
|
|
1724
|
+
// edit is chronology/context and must not imply causality by proximity.
|
|
1725
|
+
tier: evidenceTierForRelevance(
|
|
1726
|
+
changeContext?.changed ? "supporting" : "context",
|
|
1727
|
+
relevance,
|
|
1728
|
+
),
|
|
1729
|
+
relevance,
|
|
1730
|
+
tone: "info",
|
|
1731
|
+
title: "Recent changes",
|
|
1732
|
+
summary: changeContext?.changed
|
|
1733
|
+
? changeContext.what === "The workload's Pod template changed" &&
|
|
1734
|
+
changeContext.when
|
|
1735
|
+
? `Pod template changed; newest ReplicaSet created ${changeContext.when} ago`
|
|
1736
|
+
: `${changeContext.what || "A workload change was observed"}${changeContext.when ? ` · ${changeContext.when} ago` : ""}`
|
|
1737
|
+
: `${values.length} change${values.length === 1 ? "" : "s"} · ${scope}`,
|
|
1738
|
+
data: {
|
|
1739
|
+
type: "changes",
|
|
1740
|
+
changes: values,
|
|
1741
|
+
scope,
|
|
1742
|
+
changeContext,
|
|
1743
|
+
},
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function addLogs(
|
|
1748
|
+
builder: ProjectionBuilder,
|
|
1749
|
+
source: InvestigationEvidenceSource,
|
|
1750
|
+
value: DiagnosisPodLogEntry,
|
|
1751
|
+
previous: boolean,
|
|
1752
|
+
warnings: string[] = [],
|
|
1753
|
+
relevance: InvestigationEvidenceRelevance = "broader",
|
|
1754
|
+
namespace?: string,
|
|
1755
|
+
): void {
|
|
1756
|
+
const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
|
|
1757
|
+
const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
|
|
1758
|
+
const normalizedError = value.error ? stripAnsi(value.error) : undefined;
|
|
1759
|
+
if (lines.length === 0) {
|
|
1760
|
+
builder.limit(
|
|
1761
|
+
source,
|
|
1762
|
+
`${value.pod} / ${value.container}`,
|
|
1763
|
+
value.error ||
|
|
1764
|
+
"No log lines were available. This does not mean the container is healthy.",
|
|
1765
|
+
value.error ? "error" : "unknown",
|
|
1766
|
+
);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
// A producer-filtered excerpt is a candidate, not proof that its contents are
|
|
1770
|
+
// adverse. Query strings and routine request logs can contain words such as
|
|
1771
|
+
// "warning" or "critical" and still be successful traffic. Promote only an
|
|
1772
|
+
// explicit failure/error signature; keep benign excerpts available in Context.
|
|
1773
|
+
const diagnosticSignal = [...lines, ...normalizedWarnings].some((line) =>
|
|
1774
|
+
/(?:\b(?:error|exception|failed|failure|fatal|panic|crash|denied|refused|timeout|timed out|unhealthy|oomkill|back-?off)\b|\s5\d\d(?:\s|$))/i.test(
|
|
1775
|
+
line,
|
|
1776
|
+
),
|
|
1777
|
+
);
|
|
1778
|
+
const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
|
|
1779
|
+
const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
|
|
1780
|
+
builder.observe(identity, "logs", source, {
|
|
1781
|
+
// FilterLogs' raw-tail fallback is useful provenance, but the producer did
|
|
1782
|
+
// not select it as diagnostic signal. Keep it in Context; only filtered
|
|
1783
|
+
// excerpts are Supporting evidence.
|
|
1784
|
+
tier: evidenceTierForRelevance(
|
|
1785
|
+
selectedEvidence ? "supporting" : "context",
|
|
1786
|
+
relevance,
|
|
1787
|
+
),
|
|
1788
|
+
relevance,
|
|
1789
|
+
tone: selectedEvidence || normalizedError ? "warning" : "neutral",
|
|
1790
|
+
title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
|
|
1791
|
+
summary:
|
|
1792
|
+
lines.length > 0
|
|
1793
|
+
? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
|
|
1794
|
+
: "No log lines available",
|
|
1795
|
+
data: {
|
|
1796
|
+
type: "logs",
|
|
1797
|
+
pod: value.pod,
|
|
1798
|
+
container: value.container,
|
|
1799
|
+
namespace,
|
|
1800
|
+
previous,
|
|
1801
|
+
logs: value.logs ? { ...value.logs, lines } : undefined,
|
|
1802
|
+
warnings: normalizedWarnings,
|
|
1803
|
+
error: normalizedError,
|
|
1804
|
+
},
|
|
1805
|
+
});
|
|
1806
|
+
if (normalizedError) {
|
|
1807
|
+
builder.limit(
|
|
1808
|
+
source,
|
|
1809
|
+
`${value.pod} / ${value.container}`,
|
|
1810
|
+
normalizedError,
|
|
1811
|
+
"error",
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function podContainerRef(value: unknown): DiagnosisPodContainerRef | undefined {
|
|
1817
|
+
const candidate = record(value);
|
|
1818
|
+
if (
|
|
1819
|
+
!candidate ||
|
|
1820
|
+
!nonEmptyString(candidate.pod) ||
|
|
1821
|
+
!nonEmptyString(candidate.container)
|
|
1822
|
+
) {
|
|
1823
|
+
return undefined;
|
|
1824
|
+
}
|
|
1825
|
+
return candidate as unknown as DiagnosisPodContainerRef;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function parseLogEntry(value: unknown): DiagnosisPodLogEntry | undefined {
|
|
1829
|
+
const candidate = record(value);
|
|
1830
|
+
if (
|
|
1831
|
+
!candidate ||
|
|
1832
|
+
!nonEmptyString(candidate.pod) ||
|
|
1833
|
+
!nonEmptyString(candidate.container)
|
|
1834
|
+
) {
|
|
1835
|
+
return undefined;
|
|
1836
|
+
}
|
|
1837
|
+
if (candidate.logs !== undefined && !filteredLogs(candidate.logs))
|
|
1838
|
+
return undefined;
|
|
1839
|
+
if (candidate.error !== undefined && typeof candidate.error !== "string")
|
|
1840
|
+
return undefined;
|
|
1841
|
+
return candidate as unknown as DiagnosisPodLogEntry;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function nonNegativeInteger(value: unknown): value is number {
|
|
1845
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function diagnoseCrashQueryCoversTarget(
|
|
1849
|
+
source: InvestigationEvidenceSource,
|
|
1850
|
+
): boolean {
|
|
1851
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
1852
|
+
if (!args) return false;
|
|
1853
|
+
|
|
1854
|
+
// Crash retirement is target-wide. It therefore requires the producer's
|
|
1855
|
+
// normal all-container, full-time-window read. A caller-selected container,
|
|
1856
|
+
// since window, or shorter-than-default tail can validate that slice only;
|
|
1857
|
+
// it cannot clear a smoking gun from a stream it never revisited.
|
|
1858
|
+
if (nonEmptyString(args.container) || nonEmptyString(args.since))
|
|
1859
|
+
return false;
|
|
1860
|
+
if (args.tail_lines === undefined) return true;
|
|
1861
|
+
return (
|
|
1862
|
+
nonNegativeInteger(args.tail_lines) &&
|
|
1863
|
+
(args.tail_lines === 0 || args.tail_lines >= 100)
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* A missing crash candidate is meaningful only when diagnose actually read the
|
|
1869
|
+
* complete log surface that its crash classifier consumes. Keep this tied to
|
|
1870
|
+
* the current producer contract: a partial pod sample, a capped response, or a
|
|
1871
|
+
* failed stream is absence of evidence and must not clear earlier crash proof.
|
|
1872
|
+
*/
|
|
1873
|
+
function diagnoseCrashCoverageComplete(
|
|
1874
|
+
value: Record<string, unknown>,
|
|
1875
|
+
): boolean {
|
|
1876
|
+
if (
|
|
1877
|
+
!nonNegativeInteger(value.pods) ||
|
|
1878
|
+
value.pods === 0 ||
|
|
1879
|
+
value.logsError !== undefined ||
|
|
1880
|
+
(value.crashCauseTruncated !== undefined &&
|
|
1881
|
+
typeof value.crashCauseTruncated !== "boolean") ||
|
|
1882
|
+
value.crashCauseTruncated === true
|
|
1883
|
+
) {
|
|
1884
|
+
return false;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const coverage = record(value.logCoverage);
|
|
1888
|
+
if (
|
|
1889
|
+
!coverage ||
|
|
1890
|
+
!nonNegativeInteger(coverage.resolvedPods) ||
|
|
1891
|
+
!nonNegativeInteger(coverage.selectedPods) ||
|
|
1892
|
+
!nonNegativeInteger(coverage.shownLines) ||
|
|
1893
|
+
!nonNegativeInteger(coverage.totalLines) ||
|
|
1894
|
+
!nonNegativeInteger(coverage.shownPods) ||
|
|
1895
|
+
!nonNegativeInteger(coverage.totalPods) ||
|
|
1896
|
+
coverage.resolvedPods !== value.pods ||
|
|
1897
|
+
coverage.selectedPods !== coverage.resolvedPods ||
|
|
1898
|
+
(coverage.selectionTruncated !== undefined &&
|
|
1899
|
+
typeof coverage.selectionTruncated !== "boolean") ||
|
|
1900
|
+
coverage.selectionTruncated === true ||
|
|
1901
|
+
(coverage.contentTruncated !== undefined &&
|
|
1902
|
+
typeof coverage.contentTruncated !== "boolean") ||
|
|
1903
|
+
coverage.contentTruncated === true ||
|
|
1904
|
+
coverage.shownLines !== coverage.totalLines ||
|
|
1905
|
+
coverage.shownPods !== coverage.totalPods
|
|
1906
|
+
) {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const currentRaw = value.logsCurrent;
|
|
1911
|
+
const previousRaw = value.logsPrevious;
|
|
1912
|
+
if (
|
|
1913
|
+
!Array.isArray(currentRaw) ||
|
|
1914
|
+
currentRaw.length === 0 ||
|
|
1915
|
+
!Array.isArray(previousRaw)
|
|
1916
|
+
) {
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const current = currentRaw.map(parseLogEntry);
|
|
1921
|
+
const previous = previousRaw.map(parseLogEntry);
|
|
1922
|
+
if (
|
|
1923
|
+
current.some(
|
|
1924
|
+
(entry) => !entry || entry.error !== undefined || !entry.logs,
|
|
1925
|
+
) ||
|
|
1926
|
+
previous.some((entry) => !entry || !entry.logs)
|
|
1927
|
+
) {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const currentEntries = current as DiagnosisPodLogEntry[];
|
|
1932
|
+
const previousEntries = previous as DiagnosisPodLogEntry[];
|
|
1933
|
+
const streamKey = (entry: DiagnosisPodContainerRef) =>
|
|
1934
|
+
`${entry.pod}\u0000${entry.container}`;
|
|
1935
|
+
const currentKeys = new Set(currentEntries.map(streamKey));
|
|
1936
|
+
const previousKeys = new Set(previousEntries.map(streamKey));
|
|
1937
|
+
if (
|
|
1938
|
+
currentKeys.size !== current.length ||
|
|
1939
|
+
previousKeys.size !== previous.length ||
|
|
1940
|
+
currentKeys.size !== previousKeys.size ||
|
|
1941
|
+
[...currentKeys].some((key) => !previousKeys.has(key))
|
|
1942
|
+
) {
|
|
1943
|
+
return false;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const absencesRaw = value.expectedPreviousLogAbsences;
|
|
1947
|
+
if (absencesRaw !== undefined && !Array.isArray(absencesRaw)) return false;
|
|
1948
|
+
const absences = Array.isArray(absencesRaw)
|
|
1949
|
+
? absencesRaw.map(podContainerRef)
|
|
1950
|
+
: [];
|
|
1951
|
+
if (absences.some((entry) => !entry)) return false;
|
|
1952
|
+
const absenceEntries = absences as DiagnosisPodContainerRef[];
|
|
1953
|
+
const absenceKeys = new Set(absenceEntries.map(streamKey));
|
|
1954
|
+
if ([...absenceKeys].some((key) => !previousKeys.has(key))) return false;
|
|
1955
|
+
|
|
1956
|
+
return previousEntries.every((entry) => {
|
|
1957
|
+
if (entry.error === undefined) return true;
|
|
1958
|
+
return nonEmptyString(entry.error) && absenceKeys.has(streamKey(entry));
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function invalidPayload(
|
|
1963
|
+
builder: ProjectionBuilder,
|
|
1964
|
+
source: InvestigationEvidenceSource,
|
|
1965
|
+
section = investigationResultLabel(source),
|
|
1966
|
+
): void {
|
|
1967
|
+
builder.limit(
|
|
1968
|
+
source,
|
|
1969
|
+
section,
|
|
1970
|
+
"Radar couldn't summarize this investigation step. Review it in Activity.",
|
|
1971
|
+
"unknown",
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function adaptDiagnose(
|
|
1976
|
+
builder: ProjectionBuilder,
|
|
1977
|
+
source: InvestigationEvidenceSource,
|
|
1978
|
+
payload: unknown,
|
|
1979
|
+
): void {
|
|
1980
|
+
const value = record(payload);
|
|
1981
|
+
if (value) {
|
|
1982
|
+
const network = networkEvidence(value);
|
|
1983
|
+
if (network) {
|
|
1984
|
+
const relevance = relevanceForResource(builder, network.subject);
|
|
1985
|
+
const adverse =
|
|
1986
|
+
network.verdict === "broken" || network.verdict === "degraded";
|
|
1987
|
+
builder.observe(
|
|
1988
|
+
`network:${network.subject.group ?? ""}:${network.subject.kind}:${network.subject.namespace ?? ""}:${network.subject.name}`,
|
|
1989
|
+
"network",
|
|
1990
|
+
source,
|
|
1991
|
+
{
|
|
1992
|
+
tier: evidenceTierForRelevance(
|
|
1993
|
+
adverse ? "supporting" : "context",
|
|
1994
|
+
relevance,
|
|
1995
|
+
),
|
|
1996
|
+
relevance,
|
|
1997
|
+
tone:
|
|
1998
|
+
network.verdict === "broken"
|
|
1999
|
+
? "error"
|
|
2000
|
+
: network.verdict === "degraded"
|
|
2001
|
+
? "warning"
|
|
2002
|
+
: network.verdict === "healthy"
|
|
2003
|
+
? "info"
|
|
2004
|
+
: "neutral",
|
|
2005
|
+
title: `${network.subject.kind} path · ${network.subject.namespace ? `${network.subject.namespace}/` : ""}${network.subject.name}`,
|
|
2006
|
+
summary:
|
|
2007
|
+
network.diagnosis?.summary ||
|
|
2008
|
+
network.reason ||
|
|
2009
|
+
network.summary.headline,
|
|
2010
|
+
data: { type: "network", network },
|
|
2011
|
+
},
|
|
2012
|
+
);
|
|
2013
|
+
if (network.summary.skipped > 0) {
|
|
2014
|
+
builder.limit(
|
|
2015
|
+
source,
|
|
2016
|
+
"Network path coverage",
|
|
2017
|
+
`${network.summary.skipped} intended route${network.summary.skipped === 1 ? " was" : "s were"} not tested. ${network.summary.headline}`,
|
|
2018
|
+
"unknown",
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
const resource = kubernetesResource(value?.resource);
|
|
2025
|
+
if (!value || !resource) {
|
|
2026
|
+
invalidPayload(builder, source);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const bundleRelevance = relevanceForResource(builder, {
|
|
2030
|
+
kind: resource.kind,
|
|
2031
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
2032
|
+
namespace: resource.metadata.namespace,
|
|
2033
|
+
name: resource.metadata.name,
|
|
2034
|
+
});
|
|
2035
|
+
const relatedRelevance: InvestigationEvidenceRelevance =
|
|
2036
|
+
bundleRelevance === "target" ? "producer-related" : "broader";
|
|
2037
|
+
const bundledRowRelevance = (
|
|
2038
|
+
kind: string,
|
|
2039
|
+
name: string,
|
|
2040
|
+
): InvestigationEvidenceRelevance => {
|
|
2041
|
+
if (bundleRelevance !== "target") return "broader";
|
|
2042
|
+
const sameAsRoot =
|
|
2043
|
+
kind.toLowerCase() === resource.kind.toLowerCase() &&
|
|
2044
|
+
name === resource.metadata.name;
|
|
2045
|
+
const group = sameAsRoot
|
|
2046
|
+
? apiVersionToGroup(resource.apiVersion)
|
|
2047
|
+
: kind.toLowerCase() === "pod"
|
|
2048
|
+
? ""
|
|
2049
|
+
: undefined;
|
|
2050
|
+
return resourceMatchesTarget(builder.target, {
|
|
2051
|
+
kind,
|
|
2052
|
+
group,
|
|
2053
|
+
namespace: resource.metadata.namespace,
|
|
2054
|
+
name,
|
|
2055
|
+
})
|
|
2056
|
+
? "target"
|
|
2057
|
+
: "producer-related";
|
|
2058
|
+
};
|
|
2059
|
+
addNarrowHint(builder, source, value);
|
|
2060
|
+
|
|
2061
|
+
const relatedRaw = value.relatedIssues;
|
|
2062
|
+
const related = Array.isArray(relatedRaw)
|
|
2063
|
+
? relatedRaw.map(issue).filter((item): item is Issue => Boolean(item))
|
|
2064
|
+
: [];
|
|
2065
|
+
const relatedValid =
|
|
2066
|
+
relatedRaw === undefined ||
|
|
2067
|
+
(Array.isArray(relatedRaw) && related.length === relatedRaw.length);
|
|
2068
|
+
if (!relatedValid) {
|
|
2069
|
+
invalidPayload(builder, source, "Classified issues");
|
|
2070
|
+
}
|
|
2071
|
+
for (const item of related) {
|
|
2072
|
+
// `diagnose` is itself scoped to one resource and declares these rows as
|
|
2073
|
+
// related evidence. That producer contract is stronger than a broad
|
|
2074
|
+
// `issues` query, even when the related row is a child Pod.
|
|
2075
|
+
addIssueObservation(builder, source, item, relatedRelevance);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const context = contextFrom(value.resourceContext);
|
|
2079
|
+
if (value.resourceContext !== undefined && !context) {
|
|
2080
|
+
invalidPayload(builder, source, "Resource context");
|
|
2081
|
+
}
|
|
2082
|
+
const gitOps =
|
|
2083
|
+
value.gitopsDiagnosis === undefined
|
|
2084
|
+
? undefined
|
|
2085
|
+
: gitOpsDiagnosis(value.gitopsDiagnosis);
|
|
2086
|
+
if (value.gitopsDiagnosis !== undefined && !gitOps) {
|
|
2087
|
+
invalidPayload(builder, source, "GitOps status");
|
|
2088
|
+
}
|
|
2089
|
+
if (
|
|
2090
|
+
source.confirmedSuccess &&
|
|
2091
|
+
related.length === 0 &&
|
|
2092
|
+
relatedValid &&
|
|
2093
|
+
isDiagnosableWorkloadKind(resource.kind)
|
|
2094
|
+
) {
|
|
2095
|
+
const scope = scopeFromArgs(source);
|
|
2096
|
+
builder.observe(`issues:diagnose:${scope}`, "receipt", source, {
|
|
2097
|
+
tier: evidenceTierForRelevance("checked", bundleRelevance),
|
|
2098
|
+
relevance: bundleRelevance,
|
|
2099
|
+
tone: "neutral",
|
|
2100
|
+
title: "No classified workload issues",
|
|
2101
|
+
summary: scope,
|
|
2102
|
+
data: {
|
|
2103
|
+
type: "receipt",
|
|
2104
|
+
checked: "issues",
|
|
2105
|
+
scope,
|
|
2106
|
+
message:
|
|
2107
|
+
"Radar's workload diagnosis completed without a classified live issue for this resource.",
|
|
2108
|
+
},
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
const warnings = stringArray(value.warnings) ?? [];
|
|
2112
|
+
addResourceObservation(
|
|
2113
|
+
builder,
|
|
2114
|
+
source,
|
|
2115
|
+
resource,
|
|
2116
|
+
context,
|
|
2117
|
+
warnings,
|
|
2118
|
+
gitOps,
|
|
2119
|
+
related.some((item) => item.severity === "critical"),
|
|
2120
|
+
bundleRelevance,
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2123
|
+
const blockersRaw = value.startupBlockers;
|
|
2124
|
+
let blockersValid = blockersRaw === undefined || Array.isArray(blockersRaw);
|
|
2125
|
+
if (Array.isArray(blockersRaw)) {
|
|
2126
|
+
for (const raw of blockersRaw) {
|
|
2127
|
+
const blocker = startupBlocker(raw);
|
|
2128
|
+
if (!blocker) {
|
|
2129
|
+
blockersValid = false;
|
|
2130
|
+
invalidPayload(builder, source, "Startup evidence");
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
builder.observe(
|
|
2134
|
+
`startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
|
|
2135
|
+
"startup",
|
|
2136
|
+
source,
|
|
2137
|
+
{
|
|
2138
|
+
tier: evidenceTierForRelevance(
|
|
2139
|
+
"key",
|
|
2140
|
+
bundledRowRelevance(blocker.kind, blocker.name),
|
|
2141
|
+
),
|
|
2142
|
+
relevance: bundledRowRelevance(blocker.kind, blocker.name),
|
|
2143
|
+
tone: diagnosisSeverityTone(blocker.severity),
|
|
2144
|
+
title: blocker.reason,
|
|
2145
|
+
summary: blocker.message,
|
|
2146
|
+
data: {
|
|
2147
|
+
type: "startup",
|
|
2148
|
+
blocker,
|
|
2149
|
+
subject: (() => {
|
|
2150
|
+
const namespace = resource.metadata.namespace;
|
|
2151
|
+
if (!namespace) return undefined;
|
|
2152
|
+
const rootGroup = apiVersionToGroup(resource.apiVersion);
|
|
2153
|
+
const group =
|
|
2154
|
+
blocker.kind === resource.kind
|
|
2155
|
+
? rootGroup
|
|
2156
|
+
: blocker.kind === "Pod"
|
|
2157
|
+
? ""
|
|
2158
|
+
: blocker.kind === "ReplicaSet"
|
|
2159
|
+
? "apps"
|
|
2160
|
+
: undefined;
|
|
2161
|
+
if (group === undefined) return undefined;
|
|
2162
|
+
return {
|
|
2163
|
+
kind: blocker.kind,
|
|
2164
|
+
...(group ? { group } : {}),
|
|
2165
|
+
namespace,
|
|
2166
|
+
name: blocker.name,
|
|
2167
|
+
};
|
|
2168
|
+
})(),
|
|
2169
|
+
},
|
|
2170
|
+
},
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
} else if (blockersRaw !== undefined) {
|
|
2174
|
+
invalidPayload(builder, source, "Startup evidence");
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
const crashesRaw = value.crashCause;
|
|
2178
|
+
let crashesValid =
|
|
2179
|
+
(crashesRaw === undefined || Array.isArray(crashesRaw)) &&
|
|
2180
|
+
(value.crashCauseTruncated === undefined ||
|
|
2181
|
+
typeof value.crashCauseTruncated === "boolean");
|
|
2182
|
+
if (Array.isArray(crashesRaw)) {
|
|
2183
|
+
for (const raw of crashesRaw) {
|
|
2184
|
+
const crash = crashCause(raw);
|
|
2185
|
+
if (!crash) {
|
|
2186
|
+
crashesValid = false;
|
|
2187
|
+
invalidPayload(builder, source, "Crash evidence");
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
const pods = [...crash.pods].sort().join(",");
|
|
2191
|
+
const crashRelevance =
|
|
2192
|
+
crash.pods.length === 1
|
|
2193
|
+
? bundledRowRelevance("Pod", crash.pods[0])
|
|
2194
|
+
: relatedRelevance;
|
|
2195
|
+
builder.observe(
|
|
2196
|
+
`crash:${pods}:${crash.container}:${crash.state}:${crash.reason ?? ""}`,
|
|
2197
|
+
"crash",
|
|
2198
|
+
source,
|
|
2199
|
+
{
|
|
2200
|
+
tier: evidenceTierForRelevance("key", crashRelevance),
|
|
2201
|
+
relevance: crashRelevance,
|
|
2202
|
+
tone: "error",
|
|
2203
|
+
title: `${crash.container} ${crash.reason || crash.state}`,
|
|
2204
|
+
summary: crash.logLine,
|
|
2205
|
+
data: {
|
|
2206
|
+
type: "crash",
|
|
2207
|
+
crash,
|
|
2208
|
+
namespace: resource.metadata.namespace,
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
} else if (crashesRaw !== undefined) {
|
|
2214
|
+
invalidPayload(builder, source, "Crash evidence");
|
|
2215
|
+
}
|
|
2216
|
+
if (value.crashCauseTruncated === true) {
|
|
2217
|
+
builder.limit(
|
|
2218
|
+
source,
|
|
2219
|
+
"Crash evidence",
|
|
2220
|
+
"Additional crash-cause candidates were omitted.",
|
|
2221
|
+
"truncated",
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if (
|
|
2226
|
+
source.confirmedSuccess &&
|
|
2227
|
+
bundleRelevance === "target" &&
|
|
2228
|
+
isDiagnosableWorkloadKind(resource.kind)
|
|
2229
|
+
) {
|
|
2230
|
+
if (relatedValid) builder.coverSemantic(source, "issue");
|
|
2231
|
+
if (blockersValid) builder.coverSemantic(source, "startup");
|
|
2232
|
+
if (
|
|
2233
|
+
crashesValid &&
|
|
2234
|
+
diagnoseCrashQueryCoversTarget(source) &&
|
|
2235
|
+
diagnoseCrashCoverageComplete(value)
|
|
2236
|
+
) {
|
|
2237
|
+
builder.coverSemantic(source, "crash");
|
|
2238
|
+
}
|
|
2239
|
+
// DNSContext is emitted only for positive symptoms/configuration. The
|
|
2240
|
+
// producer has no explicit negative DNS coverage receipt, so its absence
|
|
2241
|
+
// cannot safely retire earlier DNS evidence.
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const expectedAbsencesRaw = value.expectedPreviousLogAbsences;
|
|
2245
|
+
const expectedAbsences = Array.isArray(expectedAbsencesRaw)
|
|
2246
|
+
? expectedAbsencesRaw
|
|
2247
|
+
.map(podContainerRef)
|
|
2248
|
+
.filter((item): item is DiagnosisPodContainerRef => Boolean(item))
|
|
2249
|
+
: [];
|
|
2250
|
+
if (
|
|
2251
|
+
expectedAbsencesRaw !== undefined &&
|
|
2252
|
+
(!Array.isArray(expectedAbsencesRaw) ||
|
|
2253
|
+
expectedAbsences.length !== expectedAbsencesRaw.length)
|
|
2254
|
+
) {
|
|
2255
|
+
invalidPayload(builder, source, "Previous-log status");
|
|
2256
|
+
}
|
|
2257
|
+
const expectedAbsenceKeys = new Set(
|
|
2258
|
+
expectedAbsences.map((item) => `${item.pod}\u0000${item.container}`),
|
|
2259
|
+
);
|
|
2260
|
+
if (source.confirmedSuccess) {
|
|
2261
|
+
for (const item of expectedAbsences) {
|
|
2262
|
+
const logRelevance = bundledRowRelevance("Pod", item.pod);
|
|
2263
|
+
builder.observe(
|
|
2264
|
+
`previous-log-absence:${item.pod}:${item.container}`,
|
|
2265
|
+
"receipt",
|
|
2266
|
+
source,
|
|
2267
|
+
{
|
|
2268
|
+
tier: evidenceTierForRelevance("checked", logRelevance),
|
|
2269
|
+
relevance: logRelevance,
|
|
2270
|
+
tone: "neutral",
|
|
2271
|
+
title: "No previous container instance expected",
|
|
2272
|
+
summary: `${item.pod} / ${item.container}`,
|
|
2273
|
+
data: {
|
|
2274
|
+
type: "receipt",
|
|
2275
|
+
checked: "logs",
|
|
2276
|
+
scope: `${item.pod} / ${item.container}`,
|
|
2277
|
+
message:
|
|
2278
|
+
"Captured container status shows zero restarts and no prior termination, so a previous log stream should not exist.",
|
|
2279
|
+
},
|
|
2280
|
+
},
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
for (const [field, previous] of [
|
|
2286
|
+
["logsCurrent", false],
|
|
2287
|
+
["logsPrevious", true],
|
|
2288
|
+
] as const) {
|
|
2289
|
+
const raw = value[field];
|
|
2290
|
+
if (Array.isArray(raw)) {
|
|
2291
|
+
for (const item of raw) {
|
|
2292
|
+
const entry = parseLogEntry(item);
|
|
2293
|
+
if (
|
|
2294
|
+
entry &&
|
|
2295
|
+
previous &&
|
|
2296
|
+
expectedAbsenceKeys.has(`${entry.pod}\u0000${entry.container}`) &&
|
|
2297
|
+
(entry.logs?.lines?.length ?? 0) === 0
|
|
2298
|
+
) {
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
if (entry) {
|
|
2302
|
+
addLogs(
|
|
2303
|
+
builder,
|
|
2304
|
+
source,
|
|
2305
|
+
entry,
|
|
2306
|
+
previous,
|
|
2307
|
+
[],
|
|
2308
|
+
bundledRowRelevance("Pod", entry.pod),
|
|
2309
|
+
resource.metadata.namespace,
|
|
2310
|
+
);
|
|
2311
|
+
} else
|
|
2312
|
+
invalidPayload(
|
|
2313
|
+
builder,
|
|
2314
|
+
source,
|
|
2315
|
+
previous ? "Previous logs" : "Current logs",
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
if (raw.length === 0 && field === "logsCurrent") {
|
|
2319
|
+
builder.limit(
|
|
2320
|
+
source,
|
|
2321
|
+
"Current logs",
|
|
2322
|
+
"No container logs were available, so Radar could not evaluate them.",
|
|
2323
|
+
"unknown",
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
} else if (raw !== undefined) {
|
|
2327
|
+
invalidPayload(
|
|
2328
|
+
builder,
|
|
2329
|
+
source,
|
|
2330
|
+
previous ? "Previous logs" : "Current logs",
|
|
2331
|
+
);
|
|
2332
|
+
} else if (
|
|
2333
|
+
field === "logsCurrent" &&
|
|
2334
|
+
typeof value.pods === "number" &&
|
|
2335
|
+
value.pods > 0 &&
|
|
2336
|
+
!nonEmptyString(value.logsError)
|
|
2337
|
+
) {
|
|
2338
|
+
// Empty slices are omitted by the Go producer. With resolved pods this
|
|
2339
|
+
// means the read yielded no stream rows, not that logs proved anything.
|
|
2340
|
+
builder.limit(
|
|
2341
|
+
source,
|
|
2342
|
+
"Current logs",
|
|
2343
|
+
"No container logs were available, so Radar could not evaluate them.",
|
|
2344
|
+
"unknown",
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (nonEmptyString(value.logsError)) {
|
|
2349
|
+
builder.limit(source, "Logs", value.logsError, "error");
|
|
2350
|
+
}
|
|
2351
|
+
const logCoverage = record(value.logCoverage);
|
|
2352
|
+
if (logCoverage?.selectionTruncated === true) {
|
|
2353
|
+
const selected =
|
|
2354
|
+
typeof logCoverage.selectedPods === "number"
|
|
2355
|
+
? logCoverage.selectedPods
|
|
2356
|
+
: "some";
|
|
2357
|
+
const resolved =
|
|
2358
|
+
typeof logCoverage.resolvedPods === "number"
|
|
2359
|
+
? logCoverage.resolvedPods
|
|
2360
|
+
: "the resolved";
|
|
2361
|
+
builder.limit(
|
|
2362
|
+
source,
|
|
2363
|
+
"Log pod coverage",
|
|
2364
|
+
`Log collection selected ${selected} of ${resolved} pods.`,
|
|
2365
|
+
"truncated",
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
if (logCoverage?.contentTruncated === true) {
|
|
2369
|
+
const shown =
|
|
2370
|
+
typeof logCoverage.shownLines === "number"
|
|
2371
|
+
? logCoverage.shownLines
|
|
2372
|
+
: "a subset of";
|
|
2373
|
+
const total =
|
|
2374
|
+
typeof logCoverage.totalLines === "number"
|
|
2375
|
+
? logCoverage.totalLines
|
|
2376
|
+
: "the returned";
|
|
2377
|
+
builder.limit(
|
|
2378
|
+
source,
|
|
2379
|
+
"Log excerpt coverage",
|
|
2380
|
+
`The response includes ${shown} of ${total} selected log lines after the size limit; these are excerpts, not the container's full log history.`,
|
|
2381
|
+
"truncated",
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
const eventsRaw = value.events;
|
|
2386
|
+
if (Array.isArray(eventsRaw)) {
|
|
2387
|
+
const events = eventsRaw
|
|
2388
|
+
.map(event)
|
|
2389
|
+
.filter((item): item is InvestigationEventEvidence => Boolean(item));
|
|
2390
|
+
if (events.length === eventsRaw.length) {
|
|
2391
|
+
addEvents(
|
|
2392
|
+
builder,
|
|
2393
|
+
source,
|
|
2394
|
+
events,
|
|
2395
|
+
`events:diagnose:${scopeFromArgs(source)}`,
|
|
2396
|
+
typeof value.eventsTotalGroups !== "number" ||
|
|
2397
|
+
value.eventsTotalGroups <= events.length,
|
|
2398
|
+
true,
|
|
2399
|
+
relatedRelevance,
|
|
2400
|
+
);
|
|
2401
|
+
} else {
|
|
2402
|
+
invalidPayload(builder, source, "Events");
|
|
2403
|
+
}
|
|
2404
|
+
} else if (
|
|
2405
|
+
eventsRaw === undefined &&
|
|
2406
|
+
value.gitopsDiagnosis === undefined &&
|
|
2407
|
+
!nonEmptyString(value.eventsError)
|
|
2408
|
+
) {
|
|
2409
|
+
addEvents(
|
|
2410
|
+
builder,
|
|
2411
|
+
source,
|
|
2412
|
+
[],
|
|
2413
|
+
`events:diagnose:${scopeFromArgs(source)}`,
|
|
2414
|
+
true,
|
|
2415
|
+
true,
|
|
2416
|
+
relatedRelevance,
|
|
2417
|
+
);
|
|
2418
|
+
} else if (eventsRaw !== undefined) {
|
|
2419
|
+
invalidPayload(builder, source, "Events");
|
|
2420
|
+
}
|
|
2421
|
+
if (nonEmptyString(value.eventsError)) {
|
|
2422
|
+
builder.limit(source, "Events", value.eventsError, "error");
|
|
2423
|
+
}
|
|
2424
|
+
if (
|
|
2425
|
+
typeof value.eventsTotalGroups === "number" &&
|
|
2426
|
+
Array.isArray(eventsRaw) &&
|
|
2427
|
+
value.eventsTotalGroups > eventsRaw.length
|
|
2428
|
+
) {
|
|
2429
|
+
builder.limit(
|
|
2430
|
+
source,
|
|
2431
|
+
"Events",
|
|
2432
|
+
`Radar received ${eventsRaw.length} of ${value.eventsTotalGroups} event groups.`,
|
|
2433
|
+
"truncated",
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const changesRaw = value.recentChanges;
|
|
2438
|
+
const changesCoverageLimitedRaw = value.recentChangesCoverageLimited;
|
|
2439
|
+
const changesCoverageLimitedValid =
|
|
2440
|
+
changesCoverageLimitedRaw === undefined ||
|
|
2441
|
+
typeof changesCoverageLimitedRaw === "boolean";
|
|
2442
|
+
const changesCoverageLimited = changesCoverageLimitedRaw === true;
|
|
2443
|
+
if (!changesCoverageLimitedValid) {
|
|
2444
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2445
|
+
}
|
|
2446
|
+
const changeContext =
|
|
2447
|
+
value.changeContext === undefined
|
|
2448
|
+
? undefined
|
|
2449
|
+
: diagnosisChangeContext(value.changeContext);
|
|
2450
|
+
if (value.changeContext !== undefined && !changeContext) {
|
|
2451
|
+
invalidPayload(builder, source, "Change correlation");
|
|
2452
|
+
}
|
|
2453
|
+
if (Array.isArray(changesRaw)) {
|
|
2454
|
+
const changes = changesRaw
|
|
2455
|
+
.map(recentChange)
|
|
2456
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
2457
|
+
if (changes.length === changesRaw.length) {
|
|
2458
|
+
addChanges(
|
|
2459
|
+
builder,
|
|
2460
|
+
source,
|
|
2461
|
+
changes,
|
|
2462
|
+
`changes:diagnose:${scopeFromArgs(source)}`,
|
|
2463
|
+
changeContext,
|
|
2464
|
+
value.recentChangesSaturated !== true &&
|
|
2465
|
+
!changesCoverageLimited &&
|
|
2466
|
+
changesCoverageLimitedValid,
|
|
2467
|
+
true,
|
|
2468
|
+
relatedRelevance,
|
|
2469
|
+
);
|
|
2470
|
+
} else {
|
|
2471
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2472
|
+
}
|
|
2473
|
+
} else if (
|
|
2474
|
+
changesRaw === undefined &&
|
|
2475
|
+
value.gitopsDiagnosis === undefined &&
|
|
2476
|
+
!nonEmptyString(value.recentChangesError)
|
|
2477
|
+
) {
|
|
2478
|
+
addChanges(
|
|
2479
|
+
builder,
|
|
2480
|
+
source,
|
|
2481
|
+
[],
|
|
2482
|
+
`changes:diagnose:${scopeFromArgs(source)}`,
|
|
2483
|
+
changeContext,
|
|
2484
|
+
value.recentChangesSaturated !== true &&
|
|
2485
|
+
!changesCoverageLimited &&
|
|
2486
|
+
changesCoverageLimitedValid,
|
|
2487
|
+
true,
|
|
2488
|
+
relatedRelevance,
|
|
2489
|
+
);
|
|
2490
|
+
} else if (changesRaw !== undefined) {
|
|
2491
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2492
|
+
}
|
|
2493
|
+
if (nonEmptyString(value.recentChangesError)) {
|
|
2494
|
+
builder.limit(source, "Recent changes", value.recentChangesError, "error");
|
|
2495
|
+
}
|
|
2496
|
+
if (value.recentChangesSaturated === true) {
|
|
2497
|
+
builder.limit(
|
|
2498
|
+
source,
|
|
2499
|
+
"Recent changes",
|
|
2500
|
+
"The recent-change result limit was reached; additional changes may exist in the requested window.",
|
|
2501
|
+
"truncated",
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
if (changesCoverageLimited) {
|
|
2505
|
+
builder.limit(
|
|
2506
|
+
source,
|
|
2507
|
+
"Recent changes",
|
|
2508
|
+
"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.",
|
|
2509
|
+
"unknown",
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const dns = record(value.dnsContext);
|
|
2514
|
+
if (dns) {
|
|
2515
|
+
const signals = stringArray(dns.signals) ?? [];
|
|
2516
|
+
const findings = Array.isArray(dns.coreDNSFindings)
|
|
2517
|
+
? dns.coreDNSFindings
|
|
2518
|
+
: [];
|
|
2519
|
+
if (signals.length > 0 || findings.length > 0) {
|
|
2520
|
+
const adverse =
|
|
2521
|
+
findings.length > 0 ||
|
|
2522
|
+
signals.some((signal) =>
|
|
2523
|
+
/\b(error|failed|failures?|timeouts?|nxdomain|servfail)\b/i.test(
|
|
2524
|
+
signal,
|
|
2525
|
+
),
|
|
2526
|
+
);
|
|
2527
|
+
builder.observe(`dns:${scopeFromArgs(source)}`, "dns", source, {
|
|
2528
|
+
tier: evidenceTierForRelevance(
|
|
2529
|
+
adverse ? "supporting" : "context",
|
|
2530
|
+
relatedRelevance,
|
|
2531
|
+
),
|
|
2532
|
+
relevance: relatedRelevance,
|
|
2533
|
+
tone: adverse ? "warning" : "info",
|
|
2534
|
+
title: adverse ? "DNS failure signals" : "DNS configuration",
|
|
2535
|
+
summary:
|
|
2536
|
+
signals[0] ||
|
|
2537
|
+
`${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
|
|
2538
|
+
data: { type: "dns", dns: dns as unknown as DiagnosisDNSContext },
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
} else if (value.dnsContext !== undefined) {
|
|
2542
|
+
invalidPayload(builder, source, "DNS context");
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function adaptIssues(
|
|
2547
|
+
builder: ProjectionBuilder,
|
|
2548
|
+
source: InvestigationEvidenceSource,
|
|
2549
|
+
payload: unknown,
|
|
2550
|
+
): void {
|
|
2551
|
+
const value = record(payload);
|
|
2552
|
+
const raw = value?.issues;
|
|
2553
|
+
if (
|
|
2554
|
+
!value ||
|
|
2555
|
+
!Array.isArray(raw) ||
|
|
2556
|
+
typeof value.total !== "number" ||
|
|
2557
|
+
typeof value.total_matched !== "number"
|
|
2558
|
+
) {
|
|
2559
|
+
invalidPayload(builder, source);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const issues = raw.map(issue).filter((item): item is Issue => Boolean(item));
|
|
2563
|
+
if (issues.length !== raw.length) {
|
|
2564
|
+
invalidPayload(builder, source);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
addNarrowHint(builder, source, value);
|
|
2568
|
+
if (
|
|
2569
|
+
value.total_matched > issues.length &&
|
|
2570
|
+
!nonEmptyString(value.narrowHint)
|
|
2571
|
+
) {
|
|
2572
|
+
builder.limit(
|
|
2573
|
+
source,
|
|
2574
|
+
"Issues",
|
|
2575
|
+
`The query returned ${issues.length} of ${value.total_matched} matching issues.`,
|
|
2576
|
+
"truncated",
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
if (typeof value.filter_errors === "number" && value.filter_errors > 0) {
|
|
2580
|
+
builder.limit(
|
|
2581
|
+
source,
|
|
2582
|
+
"Issues filter",
|
|
2583
|
+
nonEmptyString(value.filter_error_sample)
|
|
2584
|
+
? value.filter_error_sample
|
|
2585
|
+
: `${value.filter_errors} issue rows could not be evaluated by the filter.`,
|
|
2586
|
+
"error",
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
if (value.recent_changes_truncated === true) {
|
|
2590
|
+
builder.limit(
|
|
2591
|
+
source,
|
|
2592
|
+
"Issue-related changes",
|
|
2593
|
+
"The issue response omitted some recent changes.",
|
|
2594
|
+
"truncated",
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
if (value.correlation_truncated === true) {
|
|
2598
|
+
builder.limit(
|
|
2599
|
+
source,
|
|
2600
|
+
"Issue change correlation",
|
|
2601
|
+
"Change correlation was not evaluated for every returned issue.",
|
|
2602
|
+
"truncated",
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
const visibility = record(value.visibility);
|
|
2606
|
+
if (
|
|
2607
|
+
visibility &&
|
|
2608
|
+
nonEmptyString(visibility.state) &&
|
|
2609
|
+
visibility.state !== "ok"
|
|
2610
|
+
) {
|
|
2611
|
+
builder.limit(
|
|
2612
|
+
source,
|
|
2613
|
+
"Issue visibility",
|
|
2614
|
+
nonEmptyString(visibility.impact)
|
|
2615
|
+
? visibility.impact
|
|
2616
|
+
: `Radar reported ${visibility.state} visibility for this issue query.`,
|
|
2617
|
+
"unknown",
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
const issueChangesRaw = value.recent_changes;
|
|
2621
|
+
if (Array.isArray(issueChangesRaw)) {
|
|
2622
|
+
const changes = issueChangesRaw
|
|
2623
|
+
.map(recentChange)
|
|
2624
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
2625
|
+
if (changes.length === issueChangesRaw.length) {
|
|
2626
|
+
addChanges(
|
|
2627
|
+
builder,
|
|
2628
|
+
source,
|
|
2629
|
+
changes,
|
|
2630
|
+
`changes:issues:${source.args ?? scopeFromArgs(source)}`,
|
|
2631
|
+
undefined,
|
|
2632
|
+
value.recent_changes_truncated !== true,
|
|
2633
|
+
);
|
|
2634
|
+
} else {
|
|
2635
|
+
invalidPayload(builder, source, "Issue-related changes");
|
|
2636
|
+
}
|
|
2637
|
+
} else if (issueChangesRaw !== undefined) {
|
|
2638
|
+
invalidPayload(builder, source, "Issue-related changes");
|
|
2639
|
+
}
|
|
2640
|
+
const clusterDNS = record(record(value.cluster_context)?.dns);
|
|
2641
|
+
if (clusterDNS) {
|
|
2642
|
+
const signals = stringArray(clusterDNS.signals) ?? [];
|
|
2643
|
+
const findings = Array.isArray(clusterDNS.findings)
|
|
2644
|
+
? clusterDNS.findings
|
|
2645
|
+
: [];
|
|
2646
|
+
if (signals.length > 0 || findings.length > 0) {
|
|
2647
|
+
builder.observe(`dns:issues:${scopeFromArgs(source)}`, "dns", source, {
|
|
2648
|
+
tier: "context",
|
|
2649
|
+
relevance: "broader",
|
|
2650
|
+
tone: "warning",
|
|
2651
|
+
title: "Cluster DNS signals",
|
|
2652
|
+
summary:
|
|
2653
|
+
signals[0] ||
|
|
2654
|
+
`${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
|
|
2655
|
+
data: {
|
|
2656
|
+
type: "dns",
|
|
2657
|
+
dns: {
|
|
2658
|
+
signals,
|
|
2659
|
+
coreDNSFindings: findings as DiagnosisDNSContext["coreDNSFindings"],
|
|
2660
|
+
},
|
|
2661
|
+
},
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
if (issues.length === 0) {
|
|
2666
|
+
if (!source.confirmedSuccess || builder.limitedSources.has(source.id))
|
|
2667
|
+
return;
|
|
2668
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
2669
|
+
if (!nonEmptyString(args?.namespace)) {
|
|
2670
|
+
builder.limit(
|
|
2671
|
+
source,
|
|
2672
|
+
"Issues",
|
|
2673
|
+
"Radar found no matching issues, but the search covered only namespaces the current user can access.",
|
|
2674
|
+
"unknown",
|
|
2675
|
+
);
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
const scope = scopeFromArgs(source);
|
|
2679
|
+
const relevance = sourceArgsRelevance(builder, source);
|
|
2680
|
+
builder.observe(`issues:${source.args ?? scope}`, "receipt", source, {
|
|
2681
|
+
tier: evidenceTierForRelevance("checked", relevance),
|
|
2682
|
+
relevance,
|
|
2683
|
+
tone: "neutral",
|
|
2684
|
+
title: "No matching live issues",
|
|
2685
|
+
summary: scope,
|
|
2686
|
+
data: {
|
|
2687
|
+
type: "receipt",
|
|
2688
|
+
checked: "issues",
|
|
2689
|
+
scope,
|
|
2690
|
+
message:
|
|
2691
|
+
"Radar's live-issue query completed and returned no matching issues.",
|
|
2692
|
+
},
|
|
2693
|
+
});
|
|
2694
|
+
} else {
|
|
2695
|
+
for (const item of issues) addIssueObservation(builder, source, item);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function adaptGetResource(
|
|
2700
|
+
builder: ProjectionBuilder,
|
|
2701
|
+
source: InvestigationEvidenceSource,
|
|
2702
|
+
payload: unknown,
|
|
2703
|
+
): void {
|
|
2704
|
+
// Current producer modes are discriminated by the resource's own identity,
|
|
2705
|
+
// never by guessing an undocumented legacy wrapper:
|
|
2706
|
+
// 1. bare Kubernetes resource
|
|
2707
|
+
// 2. {resource, resourceContext, warnings}
|
|
2708
|
+
// 3. the same wrapper with requested extras.
|
|
2709
|
+
// Core Secrets use the producer's deliberately value-free detail shape,
|
|
2710
|
+
// normalized by kubernetesResource above.
|
|
2711
|
+
const bare = kubernetesResource(payload);
|
|
2712
|
+
const wrapper = record(payload);
|
|
2713
|
+
const resource = bare ?? kubernetesResource(wrapper?.resource);
|
|
2714
|
+
if (!resource) {
|
|
2715
|
+
invalidPayload(builder, source);
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
const value = bare ? undefined : wrapper;
|
|
2719
|
+
const context = value ? contextFrom(value.resourceContext) : undefined;
|
|
2720
|
+
if (value?.resourceContext !== undefined && !context) {
|
|
2721
|
+
invalidPayload(builder, source, "Resource context");
|
|
2722
|
+
}
|
|
2723
|
+
const warnings = stringArray(value?.warnings) ?? [];
|
|
2724
|
+
const relevance = relevanceForResource(builder, {
|
|
2725
|
+
kind: resource.kind,
|
|
2726
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
2727
|
+
namespace: resource.metadata.namespace,
|
|
2728
|
+
name: resource.metadata.name,
|
|
2729
|
+
});
|
|
2730
|
+
addResourceObservation(
|
|
2731
|
+
builder,
|
|
2732
|
+
source,
|
|
2733
|
+
resource,
|
|
2734
|
+
context,
|
|
2735
|
+
warnings,
|
|
2736
|
+
undefined,
|
|
2737
|
+
false,
|
|
2738
|
+
relevance,
|
|
2739
|
+
);
|
|
2740
|
+
if (!value) return;
|
|
2741
|
+
addNarrowHint(builder, source, value);
|
|
2742
|
+
|
|
2743
|
+
const errors: Array<[string, string]> = [
|
|
2744
|
+
["eventsError", "Events"],
|
|
2745
|
+
["recentChangesError", "Recent changes"],
|
|
2746
|
+
["metricsError", "Metrics"],
|
|
2747
|
+
["revisionsError", "Revisions"],
|
|
2748
|
+
["includeError", "Requested include"],
|
|
2749
|
+
];
|
|
2750
|
+
for (const [field, label] of errors) {
|
|
2751
|
+
if (nonEmptyString(value[field])) {
|
|
2752
|
+
builder.limit(source, label, value[field] as string, "error");
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
if (Array.isArray(value.events)) {
|
|
2757
|
+
const events = value.events
|
|
2758
|
+
.map(event)
|
|
2759
|
+
.filter((item): item is InvestigationEventEvidence => Boolean(item));
|
|
2760
|
+
if (events.length === value.events.length) {
|
|
2761
|
+
addEvents(
|
|
2762
|
+
builder,
|
|
2763
|
+
source,
|
|
2764
|
+
events,
|
|
2765
|
+
`events:get-resource:${scopeFromArgs(source)}`,
|
|
2766
|
+
(typeof value.eventsTotalGroups !== "number" ||
|
|
2767
|
+
value.eventsTotalGroups <= events.length) &&
|
|
2768
|
+
!nonEmptyString(value.eventsError),
|
|
2769
|
+
true,
|
|
2770
|
+
relevance,
|
|
2771
|
+
);
|
|
2772
|
+
if (
|
|
2773
|
+
typeof value.eventsTotalGroups === "number" &&
|
|
2774
|
+
value.eventsTotalGroups > events.length
|
|
2775
|
+
) {
|
|
2776
|
+
builder.limit(
|
|
2777
|
+
source,
|
|
2778
|
+
"Events",
|
|
2779
|
+
`Radar received ${events.length} of ${value.eventsTotalGroups} event groups.`,
|
|
2780
|
+
"truncated",
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
} else {
|
|
2784
|
+
invalidPayload(builder, source, "Events");
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
const recentChangesRaw = value.recentChanges;
|
|
2788
|
+
const recentChangesSaturatedRaw = value.recentChangesSaturated;
|
|
2789
|
+
const recentChangesCoverageLimitedRaw = value.recentChangesCoverageLimited;
|
|
2790
|
+
const hasRecentChangesResult =
|
|
2791
|
+
recentChangesRaw !== undefined ||
|
|
2792
|
+
recentChangesSaturatedRaw !== undefined ||
|
|
2793
|
+
recentChangesCoverageLimitedRaw !== undefined;
|
|
2794
|
+
const recentChangesMetadataValid =
|
|
2795
|
+
typeof recentChangesSaturatedRaw === "boolean" &&
|
|
2796
|
+
typeof recentChangesCoverageLimitedRaw === "boolean";
|
|
2797
|
+
if (hasRecentChangesResult && !recentChangesMetadataValid) {
|
|
2798
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2799
|
+
}
|
|
2800
|
+
if (Array.isArray(recentChangesRaw)) {
|
|
2801
|
+
const changes = recentChangesRaw
|
|
2802
|
+
.map(recentChange)
|
|
2803
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
2804
|
+
if (changes.length === recentChangesRaw.length) {
|
|
2805
|
+
addChanges(
|
|
2806
|
+
builder,
|
|
2807
|
+
source,
|
|
2808
|
+
changes,
|
|
2809
|
+
`changes:get-resource:${scopeFromArgs(source)}`,
|
|
2810
|
+
undefined,
|
|
2811
|
+
recentChangesMetadataValid &&
|
|
2812
|
+
recentChangesSaturatedRaw === false &&
|
|
2813
|
+
recentChangesCoverageLimitedRaw === false &&
|
|
2814
|
+
!nonEmptyString(value.recentChangesError),
|
|
2815
|
+
true,
|
|
2816
|
+
relevance,
|
|
2817
|
+
);
|
|
2818
|
+
} else {
|
|
2819
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2820
|
+
}
|
|
2821
|
+
} else if (
|
|
2822
|
+
recentChangesRaw === undefined &&
|
|
2823
|
+
recentChangesMetadataValid &&
|
|
2824
|
+
!nonEmptyString(value.recentChangesError)
|
|
2825
|
+
) {
|
|
2826
|
+
addChanges(
|
|
2827
|
+
builder,
|
|
2828
|
+
source,
|
|
2829
|
+
[],
|
|
2830
|
+
`changes:get-resource:${scopeFromArgs(source)}`,
|
|
2831
|
+
undefined,
|
|
2832
|
+
recentChangesSaturatedRaw === false &&
|
|
2833
|
+
recentChangesCoverageLimitedRaw === false,
|
|
2834
|
+
true,
|
|
2835
|
+
relevance,
|
|
2836
|
+
);
|
|
2837
|
+
} else if (recentChangesRaw !== undefined) {
|
|
2838
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2839
|
+
}
|
|
2840
|
+
if (recentChangesSaturatedRaw === true) {
|
|
2841
|
+
builder.limit(
|
|
2842
|
+
source,
|
|
2843
|
+
"Recent changes",
|
|
2844
|
+
"The recent-change result limit was reached; additional changes may exist in the requested window.",
|
|
2845
|
+
"truncated",
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
if (recentChangesCoverageLimitedRaw === true) {
|
|
2849
|
+
builder.limit(
|
|
2850
|
+
source,
|
|
2851
|
+
"Recent changes",
|
|
2852
|
+
`Change history for ${scopeFromArgs(source)} is incomplete.`,
|
|
2853
|
+
"unknown",
|
|
2854
|
+
"history",
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function adaptListResources(
|
|
2860
|
+
builder: ProjectionBuilder,
|
|
2861
|
+
source: InvestigationEvidenceSource,
|
|
2862
|
+
payload: unknown,
|
|
2863
|
+
): void {
|
|
2864
|
+
if (!Array.isArray(payload)) {
|
|
2865
|
+
invalidPayload(builder, source);
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const resources = payload
|
|
2869
|
+
.map(resourceSummary)
|
|
2870
|
+
.filter((item): item is InvestigationResourceSummary => Boolean(item));
|
|
2871
|
+
if (resources.length !== payload.length) {
|
|
2872
|
+
invalidPayload(builder, source);
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
const scope = scopeFromArgs(source);
|
|
2876
|
+
const args = record(parseJSON(source.args ?? ""));
|
|
2877
|
+
const kinds = [...new Set(resources.map((resource) => resource.kind))];
|
|
2878
|
+
const noun =
|
|
2879
|
+
kinds.length === 1
|
|
2880
|
+
? kindToPlural(kinds[0]) === kinds[0].toLowerCase()
|
|
2881
|
+
? displayKind(kinds[0])
|
|
2882
|
+
: englishPlural(displayKind(kinds[0]))
|
|
2883
|
+
: "Resources";
|
|
2884
|
+
const namespace = nonEmptyString(args?.namespace)
|
|
2885
|
+
? args.namespace
|
|
2886
|
+
: undefined;
|
|
2887
|
+
const title = namespace ? `${noun} in ${namespace}` : noun;
|
|
2888
|
+
if (resources.length === 0) {
|
|
2889
|
+
// list_resources intentionally returns [] for some RBAC-filtered reads;
|
|
2890
|
+
// even a successful transport outcome therefore cannot prove absence.
|
|
2891
|
+
builder.limit(
|
|
2892
|
+
source,
|
|
2893
|
+
"Resource inventory",
|
|
2894
|
+
`Radar found no matching resources for ${scope}, but access restrictions may have hidden some results.`,
|
|
2895
|
+
"unknown",
|
|
2896
|
+
);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
const hasAdverseResource = resources.some((resource) => {
|
|
2900
|
+
const health = resource.summaryContext?.health?.toLowerCase();
|
|
2901
|
+
return (
|
|
2902
|
+
health === "unhealthy" ||
|
|
2903
|
+
health === "degraded" ||
|
|
2904
|
+
(resource.summaryContext?.issueCount ?? 0) > 0
|
|
2905
|
+
);
|
|
2906
|
+
});
|
|
2907
|
+
builder.observe(`inventory:${source.args ?? scope}`, "inventory", source, {
|
|
2908
|
+
tier: "context",
|
|
2909
|
+
relevance: "broader",
|
|
2910
|
+
tone: hasAdverseResource ? "warning" : "neutral",
|
|
2911
|
+
title,
|
|
2912
|
+
summary: `${resources.length} returned${nonEmptyString(args?.group) ? ` · ${args.group}` : ""}`,
|
|
2913
|
+
data: { type: "inventory", resources, scope },
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
function adaptEvents(
|
|
2918
|
+
builder: ProjectionBuilder,
|
|
2919
|
+
source: InvestigationEvidenceSource,
|
|
2920
|
+
payload: unknown,
|
|
2921
|
+
): void {
|
|
2922
|
+
const value = record(payload);
|
|
2923
|
+
if (!value || !Array.isArray(value.events)) {
|
|
2924
|
+
invalidPayload(builder, source);
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
const events = value.events
|
|
2928
|
+
.map(event)
|
|
2929
|
+
.filter((item): item is InvestigationEventEvidence => Boolean(item));
|
|
2930
|
+
if (events.length !== value.events.length) {
|
|
2931
|
+
invalidPayload(builder, source);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
addNarrowHint(builder, source, value);
|
|
2935
|
+
addEvents(
|
|
2936
|
+
builder,
|
|
2937
|
+
source,
|
|
2938
|
+
events,
|
|
2939
|
+
`events:${source.args ?? scopeFromArgs(source)}`,
|
|
2940
|
+
!nonEmptyString(value.narrowHint),
|
|
2941
|
+
false,
|
|
2942
|
+
sourceArgsRelevance(builder, source),
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function adaptPodLogs(
|
|
2947
|
+
builder: ProjectionBuilder,
|
|
2948
|
+
source: InvestigationEvidenceSource,
|
|
2949
|
+
payload: unknown,
|
|
2950
|
+
): void {
|
|
2951
|
+
const value = record(payload);
|
|
2952
|
+
const logs = filteredLogs(payload) ?? filteredLogs(value);
|
|
2953
|
+
if (!logs) {
|
|
2954
|
+
invalidPayload(builder, source);
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
if (value) addNarrowHint(builder, source, value);
|
|
2958
|
+
const warnings = stringArray(value?.warnings) ?? [];
|
|
2959
|
+
const args = record(source.args ? parseJSON(source.args) : undefined);
|
|
2960
|
+
const pod = nonEmptyString(args?.name) ? args.name : "Pod";
|
|
2961
|
+
const container = nonEmptyString(args?.container)
|
|
2962
|
+
? args.container
|
|
2963
|
+
: "default container";
|
|
2964
|
+
addLogs(
|
|
2965
|
+
builder,
|
|
2966
|
+
source,
|
|
2967
|
+
{ pod, container, logs },
|
|
2968
|
+
previousFromArgs(source),
|
|
2969
|
+
warnings,
|
|
2970
|
+
sourceArgsRelevance(builder, source, "Pod"),
|
|
2971
|
+
nonEmptyString(args?.namespace) ? args.namespace : undefined,
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function adaptChanges(
|
|
2976
|
+
builder: ProjectionBuilder,
|
|
2977
|
+
source: InvestigationEvidenceSource,
|
|
2978
|
+
payload: unknown,
|
|
2979
|
+
): void {
|
|
2980
|
+
const value = record(payload);
|
|
2981
|
+
if (!value || !Array.isArray(value.changes)) {
|
|
2982
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
const changes = value.changes
|
|
2986
|
+
.map(recentChange)
|
|
2987
|
+
.filter((item): item is IssueRecentChange => Boolean(item));
|
|
2988
|
+
if (changes.length !== value.changes.length) {
|
|
2989
|
+
invalidPayload(builder, source, "Recent changes");
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
addNarrowHint(builder, source, value);
|
|
2993
|
+
let sourceErrors = 0;
|
|
2994
|
+
if (Array.isArray(value.sourcesErrored)) {
|
|
2995
|
+
for (const sourceError of value.sourcesErrored) {
|
|
2996
|
+
if (nonEmptyString(sourceError)) {
|
|
2997
|
+
sourceErrors += 1;
|
|
2998
|
+
builder.limit(source, "Recent changes", sourceError, "error");
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
} else if (value.sourcesErrored !== undefined) {
|
|
3002
|
+
invalidPayload(builder, source, "Recent changes source coverage");
|
|
3003
|
+
}
|
|
3004
|
+
addChanges(
|
|
3005
|
+
builder,
|
|
3006
|
+
source,
|
|
3007
|
+
changes,
|
|
3008
|
+
`changes:${source.args ?? scopeFromArgs(source)}`,
|
|
3009
|
+
undefined,
|
|
3010
|
+
!nonEmptyString(value.narrowHint) && sourceErrors === 0,
|
|
3011
|
+
false,
|
|
3012
|
+
sourceArgsRelevance(builder, source),
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
function adaptNeighborhood(
|
|
3017
|
+
builder: ProjectionBuilder,
|
|
3018
|
+
source: InvestigationEvidenceSource,
|
|
3019
|
+
payload: unknown,
|
|
3020
|
+
): void {
|
|
3021
|
+
const value = record(payload);
|
|
3022
|
+
const root = resourceRef(value?.root);
|
|
3023
|
+
const subgraph = record(value?.subgraph);
|
|
3024
|
+
if (
|
|
3025
|
+
!value ||
|
|
3026
|
+
!root ||
|
|
3027
|
+
!subgraph ||
|
|
3028
|
+
!Array.isArray(subgraph.nodes) ||
|
|
3029
|
+
!Array.isArray(subgraph.edges) ||
|
|
3030
|
+
typeof value.truncated !== "boolean"
|
|
3031
|
+
) {
|
|
3032
|
+
invalidPayload(builder, source);
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
const nodes = subgraph.nodes
|
|
3036
|
+
.map(topologyNode)
|
|
3037
|
+
.filter((item): item is InvestigationTopologyNode => Boolean(item));
|
|
3038
|
+
const edges = subgraph.edges
|
|
3039
|
+
.map(topologyEdge)
|
|
3040
|
+
.filter((item): item is InvestigationTopologyEdge => Boolean(item));
|
|
3041
|
+
if (
|
|
3042
|
+
nodes.length !== subgraph.nodes.length ||
|
|
3043
|
+
edges.length !== subgraph.edges.length
|
|
3044
|
+
) {
|
|
3045
|
+
invalidPayload(builder, source);
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
addNarrowHint(builder, source, value);
|
|
3049
|
+
if (value.truncated === true && !nonEmptyString(value.narrowHint)) {
|
|
3050
|
+
builder.limit(
|
|
3051
|
+
source,
|
|
3052
|
+
"Relationships",
|
|
3053
|
+
"The relationship view reached its resource limit and may be incomplete.",
|
|
3054
|
+
"truncated",
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
for (const raw of Array.isArray(value.omitted) ? value.omitted : []) {
|
|
3058
|
+
const omitted = record(raw);
|
|
3059
|
+
if (
|
|
3060
|
+
!omitted ||
|
|
3061
|
+
!nonEmptyString(omitted.field) ||
|
|
3062
|
+
!nonEmptyString(omitted.reason)
|
|
3063
|
+
)
|
|
3064
|
+
continue;
|
|
3065
|
+
builder.limit(
|
|
3066
|
+
source,
|
|
3067
|
+
omitted.field,
|
|
3068
|
+
`Relationship context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
|
|
3069
|
+
omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
builder.observe(
|
|
3073
|
+
`relationships:${root.group ?? ""}:${root.kind}:${root.namespace ?? ""}:${root.name}`,
|
|
3074
|
+
"relationships",
|
|
3075
|
+
source,
|
|
3076
|
+
{
|
|
3077
|
+
tier: "context",
|
|
3078
|
+
relevance: relevanceForResource(builder, {
|
|
3079
|
+
kind: root.kind,
|
|
3080
|
+
group: root.group ?? "",
|
|
3081
|
+
namespace: root.namespace,
|
|
3082
|
+
name: root.name,
|
|
3083
|
+
}),
|
|
3084
|
+
tone: "info",
|
|
3085
|
+
title: `Relationships around ${root.kind} ${root.name}`,
|
|
3086
|
+
summary: `${nodes.length} nodes · ${edges.length} relationships`,
|
|
3087
|
+
data: {
|
|
3088
|
+
type: "relationships",
|
|
3089
|
+
root,
|
|
3090
|
+
nodes,
|
|
3091
|
+
edges,
|
|
3092
|
+
truncated: value.truncated,
|
|
3093
|
+
},
|
|
3094
|
+
},
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
function adaptTopology(
|
|
3099
|
+
builder: ProjectionBuilder,
|
|
3100
|
+
source: InvestigationEvidenceSource,
|
|
3101
|
+
payload: unknown,
|
|
3102
|
+
): void {
|
|
3103
|
+
const value = record(payload);
|
|
3104
|
+
if (!value) {
|
|
3105
|
+
invalidPayload(builder, source);
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const partiality = topologyPartiality(value);
|
|
3109
|
+
if (!partiality) {
|
|
3110
|
+
invalidPayload(builder, source, "Topology coverage metadata");
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
const stats = record(value.stats);
|
|
3114
|
+
if (
|
|
3115
|
+
stats &&
|
|
3116
|
+
typeof stats.nodes === "number" &&
|
|
3117
|
+
typeof stats.edges === "number" &&
|
|
3118
|
+
Array.isArray(value.namespaces)
|
|
3119
|
+
) {
|
|
3120
|
+
const namespaces = value.namespaces.flatMap((raw) => {
|
|
3121
|
+
const namespace = record(raw);
|
|
3122
|
+
const chains = stringArray(namespace?.chains);
|
|
3123
|
+
return namespace && nonEmptyString(namespace.namespace) && chains
|
|
3124
|
+
? [{ namespace: namespace.namespace, chains }]
|
|
3125
|
+
: [];
|
|
3126
|
+
});
|
|
3127
|
+
if (namespaces.length !== value.namespaces.length) {
|
|
3128
|
+
invalidPayload(builder, source);
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
const problems =
|
|
3132
|
+
value.problems === undefined ? [] : stringArray(value.problems);
|
|
3133
|
+
if (!problems) {
|
|
3134
|
+
invalidPayload(builder, source, "Topology problems");
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
addTopologyLimitations(builder, source, partiality);
|
|
3138
|
+
builder.observe(
|
|
3139
|
+
`topology:${source.args ?? scopeFromArgs(source)}`,
|
|
3140
|
+
"topology",
|
|
3141
|
+
source,
|
|
3142
|
+
{
|
|
3143
|
+
tier: "context",
|
|
3144
|
+
relevance: "broader",
|
|
3145
|
+
tone: problems.length > 0 ? "warning" : "info",
|
|
3146
|
+
title: "Resource topology",
|
|
3147
|
+
summary: `${stats.nodes} nodes · ${stats.edges} relationships`,
|
|
3148
|
+
data: {
|
|
3149
|
+
type: "topology",
|
|
3150
|
+
stats: { nodes: stats.nodes, edges: stats.edges },
|
|
3151
|
+
namespaces,
|
|
3152
|
+
problems,
|
|
3153
|
+
warnings: partiality.warnings,
|
|
3154
|
+
},
|
|
3155
|
+
},
|
|
3156
|
+
);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
|
|
3161
|
+
invalidPayload(builder, source);
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
const nodes = value.nodes
|
|
3165
|
+
.map(topologyNode)
|
|
3166
|
+
.filter((item): item is InvestigationTopologyNode => Boolean(item));
|
|
3167
|
+
const edges = value.edges
|
|
3168
|
+
.map(topologyEdge)
|
|
3169
|
+
.filter((item): item is InvestigationTopologyEdge => Boolean(item));
|
|
3170
|
+
if (
|
|
3171
|
+
nodes.length !== value.nodes.length ||
|
|
3172
|
+
edges.length !== value.edges.length
|
|
3173
|
+
) {
|
|
3174
|
+
invalidPayload(builder, source);
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const problems = nodes
|
|
3178
|
+
.filter((node) => node.status === "unhealthy" || node.status === "degraded")
|
|
3179
|
+
.map((node) => `${node.kind} ${node.name}: ${node.status}`);
|
|
3180
|
+
addTopologyLimitations(builder, source, partiality);
|
|
3181
|
+
builder.observe(
|
|
3182
|
+
`topology:${source.args ?? scopeFromArgs(source)}`,
|
|
3183
|
+
"topology",
|
|
3184
|
+
source,
|
|
3185
|
+
{
|
|
3186
|
+
tier: "context",
|
|
3187
|
+
relevance: "broader",
|
|
3188
|
+
tone: problems.length > 0 ? "warning" : "info",
|
|
3189
|
+
title: "Resource topology",
|
|
3190
|
+
summary: `${nodes.length} nodes · ${edges.length} relationships`,
|
|
3191
|
+
data: {
|
|
3192
|
+
type: "topology",
|
|
3193
|
+
stats: { nodes: nodes.length, edges: edges.length },
|
|
3194
|
+
namespaces: [],
|
|
3195
|
+
problems,
|
|
3196
|
+
warnings: partiality.warnings,
|
|
3197
|
+
},
|
|
3198
|
+
},
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
const ADAPTERS: Record<
|
|
3203
|
+
string,
|
|
3204
|
+
(
|
|
3205
|
+
builder: ProjectionBuilder,
|
|
3206
|
+
source: InvestigationEvidenceSource,
|
|
3207
|
+
payload: unknown,
|
|
3208
|
+
) => void
|
|
3209
|
+
> = {
|
|
3210
|
+
diagnose: adaptDiagnose,
|
|
3211
|
+
issues: adaptIssues,
|
|
3212
|
+
get_resource: adaptGetResource,
|
|
3213
|
+
list_resources: adaptListResources,
|
|
3214
|
+
get_events: adaptEvents,
|
|
3215
|
+
get_pod_logs: adaptPodLogs,
|
|
3216
|
+
get_changes: adaptChanges,
|
|
3217
|
+
get_neighborhood: adaptNeighborhood,
|
|
3218
|
+
get_topology: adaptTopology,
|
|
3219
|
+
};
|
|
3220
|
+
|
|
3221
|
+
export function projectInvestigationEvidence(
|
|
3222
|
+
turns: readonly InvestigationEvidenceTurn[],
|
|
3223
|
+
target: InvestigationEvidenceTarget,
|
|
3224
|
+
): InvestigationEvidenceProjection {
|
|
3225
|
+
const builder = new ProjectionBuilder(target);
|
|
3226
|
+
const evidenceRefSources: InvestigationEvidenceSource[] = [];
|
|
3227
|
+
const citableSources: InvestigationEvidenceSource[] = [];
|
|
3228
|
+
let order = 0;
|
|
3229
|
+
for (const [turnIndex, turn] of turns.entries()) {
|
|
3230
|
+
for (const [timelineIndex, item] of turn.timeline.entries()) {
|
|
3231
|
+
const itemOrder = order;
|
|
3232
|
+
order += 1;
|
|
3233
|
+
if (item.kind !== "tool") continue;
|
|
3234
|
+
// Full-local agents may load user MCP servers whose bare tool names collide
|
|
3235
|
+
// with Radar's. Only results matched by the server to the active private
|
|
3236
|
+
// transport ledger may enter the surface labelled "Radar evidence".
|
|
3237
|
+
if (item.radarEvidence !== true) continue;
|
|
3238
|
+
const source: InvestigationEvidenceSource = {
|
|
3239
|
+
id: investigationEvidenceSourceId(turnIndex, item.id),
|
|
3240
|
+
turnIndex,
|
|
3241
|
+
timelineIndex,
|
|
3242
|
+
stepId: item.id,
|
|
3243
|
+
tool: item.tool,
|
|
3244
|
+
args: item.summary,
|
|
3245
|
+
order: itemOrder,
|
|
3246
|
+
phase: turn.verify
|
|
3247
|
+
? "verification"
|
|
3248
|
+
: turn.apply
|
|
3249
|
+
? "apply"
|
|
3250
|
+
: turn.question
|
|
3251
|
+
? "followup"
|
|
3252
|
+
: "initial",
|
|
3253
|
+
confirmedSuccess: item.status === "done" && item.isError === false,
|
|
3254
|
+
evidenceRef: item.evidenceRef,
|
|
3255
|
+
};
|
|
3256
|
+
if (item.evidenceRef) evidenceRefSources.push(source);
|
|
3257
|
+
if (item.status !== "done") continue;
|
|
3258
|
+
if (
|
|
3259
|
+
item.evidenceRef &&
|
|
3260
|
+
investigationEvidenceRefRe.test(item.evidenceRef) &&
|
|
3261
|
+
item.isError === false &&
|
|
3262
|
+
!item.truncated &&
|
|
3263
|
+
nonEmptyString(item.result)
|
|
3264
|
+
) {
|
|
3265
|
+
citableSources.push(source);
|
|
3266
|
+
}
|
|
3267
|
+
const adapt = ADAPTERS[item.tool];
|
|
3268
|
+
if (!adapt) continue;
|
|
3269
|
+
builder.addSource(source);
|
|
3270
|
+
if (item.isError === true) {
|
|
3271
|
+
builder.limit(
|
|
3272
|
+
source,
|
|
3273
|
+
investigationResultLabel(source),
|
|
3274
|
+
item.result || "This investigation step failed.",
|
|
3275
|
+
"error",
|
|
3276
|
+
);
|
|
3277
|
+
continue;
|
|
3278
|
+
}
|
|
3279
|
+
if (item.truncated) {
|
|
3280
|
+
builder.limit(
|
|
3281
|
+
source,
|
|
3282
|
+
investigationResultLabel(source),
|
|
3283
|
+
"Only part of this investigation result was saved, so Radar could not summarize it here.",
|
|
3284
|
+
"truncated",
|
|
3285
|
+
);
|
|
3286
|
+
continue;
|
|
3287
|
+
}
|
|
3288
|
+
if (!nonEmptyString(item.result)) {
|
|
3289
|
+
builder.limit(
|
|
3290
|
+
source,
|
|
3291
|
+
investigationResultLabel(source),
|
|
3292
|
+
"This investigation step did not return details Radar could summarize.",
|
|
3293
|
+
"unknown",
|
|
3294
|
+
);
|
|
3295
|
+
continue;
|
|
3296
|
+
}
|
|
3297
|
+
const payload = parseJSON(item.result);
|
|
3298
|
+
if (payload === undefined) {
|
|
3299
|
+
invalidPayload(builder, source);
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
adapt(builder, source, payload);
|
|
3303
|
+
if (item.isError !== false) {
|
|
3304
|
+
builder.limit(
|
|
3305
|
+
source,
|
|
3306
|
+
investigationResultLabel(source),
|
|
3307
|
+
"Radar cannot confirm whether this investigation step completed successfully. Available evidence is shown, but an empty result cannot confirm that nothing was found.",
|
|
3308
|
+
"unknown",
|
|
3309
|
+
);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
const tierRank: Record<InvestigationEvidenceTier, number> = {
|
|
3315
|
+
key: 0,
|
|
3316
|
+
supporting: 1,
|
|
3317
|
+
context: 2,
|
|
3318
|
+
checked: 3,
|
|
3319
|
+
};
|
|
3320
|
+
const primaryBySource = new Map<
|
|
3321
|
+
string,
|
|
3322
|
+
{ groupId: string; rank: number; order: number }
|
|
3323
|
+
>();
|
|
3324
|
+
for (const group of builder.groups) {
|
|
3325
|
+
for (const observation of group.observations) {
|
|
3326
|
+
const candidate = {
|
|
3327
|
+
groupId: group.id,
|
|
3328
|
+
rank: tierRank[observation.tier],
|
|
3329
|
+
order: group.firstOrder,
|
|
3330
|
+
};
|
|
3331
|
+
const current = primaryBySource.get(observation.source.id);
|
|
3332
|
+
if (
|
|
3333
|
+
!current ||
|
|
3334
|
+
candidate.rank < current.rank ||
|
|
3335
|
+
(candidate.rank === current.rank && candidate.order < current.order)
|
|
3336
|
+
) {
|
|
3337
|
+
primaryBySource.set(observation.source.id, candidate);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
for (const source of builder.sources) {
|
|
3342
|
+
source.primaryGroupId = primaryBySource.get(source.id)?.groupId;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// The investigation target, rather than the producer tool, is the proof
|
|
3346
|
+
// boundary for semantic diagnosis domains. A later successful target
|
|
3347
|
+
// diagnosis can therefore retire an exact-target issue first observed by
|
|
3348
|
+
// `issues`, while a broad or sibling read still cannot clear it.
|
|
3349
|
+
const targetProofScope = [
|
|
3350
|
+
builder.target.group.toLowerCase(),
|
|
3351
|
+
builder.target.kind.toLowerCase(),
|
|
3352
|
+
builder.target.namespace ?? "",
|
|
3353
|
+
builder.target.name,
|
|
3354
|
+
].join("/");
|
|
3355
|
+
const semanticCoverageKey = (kind: InvestigationEvidenceKind) =>
|
|
3356
|
+
`semantic:${kind}:${targetProofScope}`;
|
|
3357
|
+
const collectionCoverageKey = (
|
|
3358
|
+
kind: InvestigationEvidenceKind,
|
|
3359
|
+
identity: string,
|
|
3360
|
+
) => `collection:${kind}:${identity}`;
|
|
3361
|
+
const previousLogCoverageKey = (
|
|
3362
|
+
source: InvestigationEvidenceSource,
|
|
3363
|
+
podContainer: string,
|
|
3364
|
+
) => `previous-log:${source.tool}:${scopeFromArgs(source)}:${podContainer}`;
|
|
3365
|
+
const retirementKey = (
|
|
3366
|
+
group: InvestigationEvidenceGroup,
|
|
3367
|
+
observation: InvestigationEvidenceObservation,
|
|
3368
|
+
): string | undefined => {
|
|
3369
|
+
switch (group.kind) {
|
|
3370
|
+
case "issue":
|
|
3371
|
+
case "startup":
|
|
3372
|
+
case "crash":
|
|
3373
|
+
case "dns":
|
|
3374
|
+
return semanticCoverageKey(group.kind);
|
|
3375
|
+
case "events":
|
|
3376
|
+
case "changes":
|
|
3377
|
+
return collectionCoverageKey(group.kind, group.identity);
|
|
3378
|
+
case "logs":
|
|
3379
|
+
return group.identity.startsWith("logs:previous:")
|
|
3380
|
+
? previousLogCoverageKey(
|
|
3381
|
+
observation.source,
|
|
3382
|
+
group.identity.slice("logs:previous:".length),
|
|
3383
|
+
)
|
|
3384
|
+
: undefined;
|
|
3385
|
+
case "network":
|
|
3386
|
+
return collectionCoverageKey(group.kind, group.identity);
|
|
3387
|
+
default:
|
|
3388
|
+
return undefined;
|
|
3389
|
+
}
|
|
3390
|
+
};
|
|
3391
|
+
|
|
3392
|
+
// Each completed verification contributes only the exact proof scopes its
|
|
3393
|
+
// successful producers covered. Keep every verification: supersession is
|
|
3394
|
+
// monotonic until a newer relevant observation reopens that semantic item.
|
|
3395
|
+
const verificationCoverage: Array<{
|
|
3396
|
+
turnIndex: number;
|
|
3397
|
+
keys: Set<string>;
|
|
3398
|
+
}> = [];
|
|
3399
|
+
turns.forEach((turn, turnIndex) => {
|
|
3400
|
+
if (!turn.verify || turn.status !== "done") return;
|
|
3401
|
+
const keys = new Set<string>();
|
|
3402
|
+
for (const group of builder.groups) {
|
|
3403
|
+
for (const observation of group.observations) {
|
|
3404
|
+
if (
|
|
3405
|
+
observation.source.turnIndex !== turnIndex ||
|
|
3406
|
+
!observation.source.confirmedSuccess ||
|
|
3407
|
+
observation.relevance === "broader"
|
|
3408
|
+
) {
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
3411
|
+
if (observation.data.type === "receipt") {
|
|
3412
|
+
switch (observation.data.checked) {
|
|
3413
|
+
case "issues":
|
|
3414
|
+
keys.add(semanticCoverageKey("issue"));
|
|
3415
|
+
break;
|
|
3416
|
+
case "events":
|
|
3417
|
+
keys.add(collectionCoverageKey("events", group.identity));
|
|
3418
|
+
break;
|
|
3419
|
+
case "changes":
|
|
3420
|
+
keys.add(collectionCoverageKey("changes", group.identity));
|
|
3421
|
+
break;
|
|
3422
|
+
case "logs":
|
|
3423
|
+
if (group.identity.startsWith("previous-log-absence:")) {
|
|
3424
|
+
keys.add(
|
|
3425
|
+
previousLogCoverageKey(
|
|
3426
|
+
observation.source,
|
|
3427
|
+
group.identity.slice("previous-log-absence:".length),
|
|
3428
|
+
),
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
break;
|
|
3432
|
+
case "inventory":
|
|
3433
|
+
break;
|
|
3434
|
+
}
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
if (
|
|
3438
|
+
observation.source.tool === "diagnose" &&
|
|
3439
|
+
observation.data.type === "resource"
|
|
3440
|
+
) {
|
|
3441
|
+
for (const kind of builder.semanticCoverageBySource.get(
|
|
3442
|
+
observation.source.id,
|
|
3443
|
+
) ?? []) {
|
|
3444
|
+
keys.add(semanticCoverageKey(kind));
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
if (
|
|
3448
|
+
observation.source.tool === "diagnose" &&
|
|
3449
|
+
group.kind === "network"
|
|
3450
|
+
) {
|
|
3451
|
+
keys.add(collectionCoverageKey("network", group.identity));
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
verificationCoverage.push({ turnIndex, keys });
|
|
3456
|
+
});
|
|
3457
|
+
|
|
3458
|
+
for (const group of builder.groups) {
|
|
3459
|
+
for (const observation of group.observations) {
|
|
3460
|
+
const key =
|
|
3461
|
+
observation.relevance !== "broader"
|
|
3462
|
+
? retirementKey(group, observation)
|
|
3463
|
+
: undefined;
|
|
3464
|
+
observation.historical = Boolean(
|
|
3465
|
+
key &&
|
|
3466
|
+
verificationCoverage.some(
|
|
3467
|
+
(verification) =>
|
|
3468
|
+
verification.turnIndex > observation.source.turnIndex &&
|
|
3469
|
+
verification.keys.has(key),
|
|
3470
|
+
),
|
|
3471
|
+
);
|
|
3472
|
+
}
|
|
3473
|
+
const latestRelevantObservation = [...group.observations]
|
|
3474
|
+
.reverse()
|
|
3475
|
+
.find((observation) => observation.relevance !== "broader");
|
|
3476
|
+
group.historical = latestRelevantObservation?.historical ?? false;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
return {
|
|
3480
|
+
groups: builder.groups,
|
|
3481
|
+
limitations: builder.limitations,
|
|
3482
|
+
sources: builder.sources,
|
|
3483
|
+
evidenceRefSources,
|
|
3484
|
+
citableSources,
|
|
3485
|
+
coverage: {
|
|
3486
|
+
attempted: builder.sources.length,
|
|
3487
|
+
projected: builder.projectedSources.size,
|
|
3488
|
+
limited: builder.limitedSources.size,
|
|
3489
|
+
checked: builder.checkedSources.size,
|
|
3490
|
+
},
|
|
3491
|
+
};
|
|
3492
|
+
}
|