@skyhook-io/k8s-ui 1.10.4 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +11 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +517 -0
- package/src/components/trace/ReachabilityView.tsx +1173 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1423 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +892 -0
- package/src/components/trace/reachInspector.ts +797 -0
- package/src/components/trace/reachMarks.test.ts +698 -0
- package/src/components/trace/reachMarks.ts +623 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +449 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +414 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +64 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type { Trace, ProbeResult, RouteResult } from './types'
|
|
2
|
+
import type { Mark } from './reachMarks'
|
|
3
|
+
import { originRouteEvidence, routeMark } from './reachMarks'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An Origin is a vantage point - WHERE a test ran from, and as WHOM. It is a
|
|
7
|
+
* first-class entity in the graph, never a Kubernetes resource, because two
|
|
8
|
+
* results about the same Service can mean completely different things depending
|
|
9
|
+
* on who sent the request and by what mechanism.
|
|
10
|
+
*
|
|
11
|
+
* Radar has three real origins today. `caller` and `external` are declared here
|
|
12
|
+
* and rendered as unavailable rather than omitted: an operator reading this tab
|
|
13
|
+
* needs to know that the strongest evidence - the real application caller, and a
|
|
14
|
+
* genuine request from outside the cluster - is absent, not merely unrun.
|
|
15
|
+
* Hiding them would make synthetic evidence look complete.
|
|
16
|
+
*/
|
|
17
|
+
export type OriginId = 'incluster' | 'radar-incluster' | 'apiserver' | 'local' | 'caller' | 'external'
|
|
18
|
+
|
|
19
|
+
/** Which lane an origin's traffic belongs to. */
|
|
20
|
+
export type Lane = 'dataplane' | 'control'
|
|
21
|
+
|
|
22
|
+
/** What the origin's identity means for the result's strength. */
|
|
23
|
+
export type OriginKind = 'synthetic' | 'radar' | 'real-client' | 'real-caller' | 'relayed'
|
|
24
|
+
|
|
25
|
+
export interface Origin {
|
|
26
|
+
id: OriginId
|
|
27
|
+
glyph: string
|
|
28
|
+
name: string
|
|
29
|
+
/** How the request is actually sent. */
|
|
30
|
+
mech: string
|
|
31
|
+
/** Namespace / ServiceAccount / mesh participation, as far as we know it. */
|
|
32
|
+
identity: string
|
|
33
|
+
kind: OriginKind
|
|
34
|
+
kindTag: string
|
|
35
|
+
lane: Lane
|
|
36
|
+
mark: Mark
|
|
37
|
+
/** Set when this origin cannot be used at all. Renders instead of an action. */
|
|
38
|
+
unavailable?: string
|
|
39
|
+
/** True for origins Radar does not implement (vs merely denied by RBAC). */
|
|
40
|
+
unsupported?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ORIGIN_KIND_TAG: Record<OriginKind, string> = {
|
|
44
|
+
synthetic: 'SYNTHETIC',
|
|
45
|
+
radar: 'RADAR ITSELF',
|
|
46
|
+
'real-client': 'REAL CLIENT',
|
|
47
|
+
'real-caller': 'REAL CALLER',
|
|
48
|
+
relayed: 'RELAYED · NOT A CALLER',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The laptop's own tag. It shares the 'real-client' KIND with the external
|
|
52
|
+
* origin, but the tags must differ: a genuine request from the public internet
|
|
53
|
+
* IS a real client; Radar dialling from the operator's machine behaves AS one
|
|
54
|
+
* - the sender is Radar, and saying "real client" upgraded it into a user. */
|
|
55
|
+
export const LAPTOP_TAG = 'AS A CLIENT'
|
|
56
|
+
|
|
57
|
+
/** Evidence strength, strongest first. Drives the "next strongest test" prompt. */
|
|
58
|
+
export const ORIGIN_STRENGTH: OriginId[] = ['caller', 'external', 'incluster', 'radar-incluster', 'local', 'apiserver']
|
|
59
|
+
|
|
60
|
+
/** The live work this page actually did: how many real dials were sent, at
|
|
61
|
+
* which layers, from how many vantages. The route counts alone undersold it -
|
|
62
|
+
* "1 got through" can be twenty-odd DNS/TLS/HTTP checks across hostnames,
|
|
63
|
+
* ports, pods and vantages, and that volume IS the trust signal. */
|
|
64
|
+
export function probeCheckStats(trace?: Trace): { checks: number; byLayer: Record<string, number>; vantages: number } {
|
|
65
|
+
const live = allProbes(trace).filter((p) => !p.skipped)
|
|
66
|
+
const byLayer: Record<string, number> = {}
|
|
67
|
+
for (const p of live) byLayer[p.layer] = (byLayer[p.layer] ?? 0) + 1
|
|
68
|
+
const vantages = new Set(live.map((p) => originOf(p, trace?.runVantage))).size
|
|
69
|
+
return { checks: live.length, byLayer, vantages }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function allProbes(trace?: Trace): ProbeResult[] {
|
|
73
|
+
if (!trace) return []
|
|
74
|
+
return [...(trace.upstreams ?? []), ...(trace.downstream ?? [])].flatMap((h) => h.probes ?? [])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Which origin produced a probe. The apiserver path wins over vantage: a proxy
|
|
78
|
+
* relay is a control-plane mechanism no matter where the process calling it runs. */
|
|
79
|
+
export function originOf(p: ProbeResult, runVantage?: string): OriginId {
|
|
80
|
+
if (p.path === 'apiserver') return 'apiserver'
|
|
81
|
+
if (p.vantage !== 'in-cluster') return 'local'
|
|
82
|
+
if (p.source === 'probe-job') return 'incluster'
|
|
83
|
+
if (p.source === 'radar') return 'radar-incluster'
|
|
84
|
+
// No source: an older producer. An in-cluster dataplane probe can only be
|
|
85
|
+
// Radar's own when Radar itself runs in the cluster; from a laptop the only
|
|
86
|
+
// way to get one is the throwaway Job. runVantage is what the producer knows
|
|
87
|
+
// about itself, so it settles the ambiguity without guessing.
|
|
88
|
+
return runVantage === 'in-cluster' ? 'radar-incluster' : 'incluster'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface OriginContext {
|
|
92
|
+
inClusterAllowed?: boolean
|
|
93
|
+
/** The scenario on screen. Marks are scored against THIS route when the
|
|
94
|
+
* producer sent per-vantage results, so the rail cannot disagree with the
|
|
95
|
+
* graph about what a vantage found. */
|
|
96
|
+
route?: RouteResult
|
|
97
|
+
inClusterRunning?: boolean
|
|
98
|
+
inClusterDeniedReason?: string
|
|
99
|
+
/** A run was ATTEMPTED and its probe never started (image pull, quota,
|
|
100
|
+
* webhook). "Not tested" would erase the attempt; execution failure is its
|
|
101
|
+
* own state, distinct from reachability. */
|
|
102
|
+
inClusterRunError?: string
|
|
103
|
+
stale?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Derives an origin's mark from the probes it actually produced.
|
|
108
|
+
*
|
|
109
|
+
* A live failure always wins over a success: when one origin's probes disagree
|
|
110
|
+
* among themselves, surfacing the failure is the safe direction. An origin whose
|
|
111
|
+
* every probe was SKIPPED did not fail - it never ran, which is `blocked`, and
|
|
112
|
+
* the skip reason explains why (a ClusterIP is not routable from a laptop).
|
|
113
|
+
*/
|
|
114
|
+
function markFor(probes: ProbeResult[], id: OriginId, ctx: OriginContext): Mark {
|
|
115
|
+
if (id === 'incluster' && ctx.inClusterRunning) return 'running'
|
|
116
|
+
// The SELECTED route's own result for this origin wins when the producer sent
|
|
117
|
+
// one. Without it the rail scored an origin across EVERY route at once, so a
|
|
118
|
+
// vantage that failed on one path read as failed while the graph beside it -
|
|
119
|
+
// now route-scoped - showed that same vantage succeeding on the selected one.
|
|
120
|
+
const ev = ctx.route ? originRouteEvidence(ctx.route, id) : undefined
|
|
121
|
+
if (ev?.kind === 'own') {
|
|
122
|
+
if (ctx.stale) return 'stale'
|
|
123
|
+
return routeMark(ev.result, {})
|
|
124
|
+
}
|
|
125
|
+
// The producer sent a breakdown and this origin is not in it. That means the
|
|
126
|
+
// ROUTE has no result from here - it does NOT mean this vantage sent nothing.
|
|
127
|
+
// A laptop that dialled a public Ingress and got an answer has real evidence
|
|
128
|
+
// on the entry hop; the route is built from the backend's probes and cannot
|
|
129
|
+
// see it. Saying "not tested" there contradicted the graph beside it, which
|
|
130
|
+
// was drawing that very dial. Fall through to what this origin actually did.
|
|
131
|
+
if (ev?.kind === 'none' && probes.filter((p) => !p.skipped).length === 0) {
|
|
132
|
+
if (id === 'incluster' && ctx.inClusterAllowed === false) return 'denied'
|
|
133
|
+
return 'untested'
|
|
134
|
+
}
|
|
135
|
+
if (probes.length === 0) {
|
|
136
|
+
if (id === 'incluster' && ctx.inClusterAllowed === false) return 'denied'
|
|
137
|
+
return 'untested'
|
|
138
|
+
}
|
|
139
|
+
const live = probes.filter((p) => !p.skipped)
|
|
140
|
+
// A demoted run is a DISPOSITION, not a coverage gap: the probe dialled and
|
|
141
|
+
// its answer was deliberately kept informational. 'blocked' ("never tried -
|
|
142
|
+
// something failed earlier") was false on both clauses.
|
|
143
|
+
if (live.length === 0 && probes.some((p) => p.skipped && p.skipClass === 'informational')) return 'inconclusive'
|
|
144
|
+
if (live.length === 0) return 'blocked'
|
|
145
|
+
if (ctx.stale) return 'stale'
|
|
146
|
+
if (live.some((p) => !p.ok || p.tone === 'unhealthy')) return 'failed'
|
|
147
|
+
// 'reached' = answered with a 3xx/4xx/app-5xx: evidence the port serves, not a
|
|
148
|
+
// clean pass - same rule as the route rollup, where reached maps to answered.
|
|
149
|
+
if (live.some((p) => p.tone === 'degraded' || p.tone === 'reached')) return 'answered'
|
|
150
|
+
return id === 'apiserver' ? 'proxied' : 'proved'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The reason an all-skipped origin never ran, taken from the probes themselves. */
|
|
154
|
+
function skipReason(probes: ProbeResult[]): string | undefined {
|
|
155
|
+
return probes.find((p) => p.skipped && p.reason)?.reason
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Why THIS origin's dials for a route were skipped, from the skip rows
|
|
160
|
+
* themselves. Each skip carries (vantage, path, source), so the reason is
|
|
161
|
+
* attributed by the exact identity that produced it - inferring from the run
|
|
162
|
+
* mode repeated the proxy's reason under the laptop, and lost it entirely when
|
|
163
|
+
* the run came from inside the cluster (which relays through the proxy too).
|
|
164
|
+
* A port-scoped skip only speaks for its own port; a shared (portless) skip
|
|
165
|
+
* speaks for every port. Another port's reason is never borrowed.
|
|
166
|
+
*/
|
|
167
|
+
export function originSkipReason(trace: Trace | undefined, originId: OriginId, route?: RouteResult): string | undefined {
|
|
168
|
+
const target = route?.target || route?.route || ''
|
|
169
|
+
const m = /:(\d+)$/.exec(target)
|
|
170
|
+
const port = m ? Number(m[1]) : undefined
|
|
171
|
+
const backend = m ? target.slice(0, target.lastIndexOf(':')).trim() : ''
|
|
172
|
+
const pick = (probes: ProbeResult[]): string | undefined => {
|
|
173
|
+
const skips = probes.filter((p) => p.skipped && p.reason && originOf(p, trace?.runVantage) === originId)
|
|
174
|
+
if (port !== undefined) {
|
|
175
|
+
return (skips.find((p) => p.port === port) ?? skips.find((p) => !p.port))?.reason
|
|
176
|
+
}
|
|
177
|
+
return skips[0]?.reason
|
|
178
|
+
}
|
|
179
|
+
// Two backends can share a port, so a route with a named backend takes its
|
|
180
|
+
// reason ONLY from that backend's own hops - borrowing a sibling's would be
|
|
181
|
+
// the exact misattribution this lookup exists to prevent. The flat pool
|
|
182
|
+
// remains only for routes that never name a backend.
|
|
183
|
+
if (backend) {
|
|
184
|
+
const own = [...(trace?.upstreams ?? []), ...(trace?.downstream ?? [])].filter((h) => h.resource.name === backend)
|
|
185
|
+
if (own.length > 0) {
|
|
186
|
+
for (const h of own) {
|
|
187
|
+
const r = pick(h.probes ?? [])
|
|
188
|
+
if (r) return r
|
|
189
|
+
}
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return pick(allProbes(trace))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Why this origin's run was kept informational. A demotion applies to the RUN,
|
|
198
|
+
* not to one backend, so this is origin-scoped rather than route-scoped: the
|
|
199
|
+
* route-scoped lookup asks which declared path a skip speaks for, which is the
|
|
200
|
+
* wrong question when the whole run was demoted.
|
|
201
|
+
*/
|
|
202
|
+
export function originInformationalReason(trace: Trace | undefined, originId: OriginId): string | undefined {
|
|
203
|
+
return allProbes(trace).find(
|
|
204
|
+
(p) => p.skipped && p.skipClass === 'informational' && !!p.reason && originOf(p, trace?.runVantage) === originId,
|
|
205
|
+
)?.reason
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Builds the origin rail for a trace.
|
|
210
|
+
*
|
|
211
|
+
* Order is by evidence strength, not by availability, so the gap between what
|
|
212
|
+
* was tested and what would be conclusive stays legible.
|
|
213
|
+
*/
|
|
214
|
+
export function buildOrigins(trace: Trace | undefined, ctx: OriginContext = {}): Origin[] {
|
|
215
|
+
const probes = allProbes(trace)
|
|
216
|
+
const byOrigin = new Map<OriginId, ProbeResult[]>()
|
|
217
|
+
for (const p of probes) {
|
|
218
|
+
const id = originOf(p, trace?.runVantage)
|
|
219
|
+
const arr = byOrigin.get(id) ?? []
|
|
220
|
+
arr.push(p)
|
|
221
|
+
byOrigin.set(id, arr)
|
|
222
|
+
}
|
|
223
|
+
const ns = trace?.subject.namespace ?? ''
|
|
224
|
+
|
|
225
|
+
const caller: Origin = {
|
|
226
|
+
id: 'caller',
|
|
227
|
+
glyph: '◈',
|
|
228
|
+
name: 'Real caller workload',
|
|
229
|
+
mech: 'one of your running Pods, as itself',
|
|
230
|
+
identity: 'the app’s own account and namespace',
|
|
231
|
+
kind: 'real-caller',
|
|
232
|
+
kindTag: ORIGIN_KIND_TAG['real-caller'],
|
|
233
|
+
lane: 'dataplane',
|
|
234
|
+
mark: 'blocked',
|
|
235
|
+
unsupported: true,
|
|
236
|
+
unavailable: 'Radar can’t send a request from one of your running Pods yet. Until it can, anything that depends on who is calling — mesh mTLS, authorization policy, network policy — stays untested.',
|
|
237
|
+
}
|
|
238
|
+
const external: Origin = {
|
|
239
|
+
id: 'external',
|
|
240
|
+
glyph: '◉',
|
|
241
|
+
name: 'Direct external client',
|
|
242
|
+
mech: 'a request from the public internet',
|
|
243
|
+
identity: 'no cluster identity · anonymous client',
|
|
244
|
+
kind: 'real-client',
|
|
245
|
+
kindTag: ORIGIN_KIND_TAG['real-client'],
|
|
246
|
+
lane: 'dataplane',
|
|
247
|
+
mark: 'blocked',
|
|
248
|
+
unsupported: true,
|
|
249
|
+
unavailable: 'Radar has nowhere to test from that is provably on the public internet. A test from your machine is reported as exactly that, never as “from outside”.',
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const inClusterProbes = byOrigin.get('incluster') ?? []
|
|
253
|
+
// A failed ATTEMPT is not "not tested": the operator clicked Run and the
|
|
254
|
+
// probe never started. Only when no live probe made it through - a partial
|
|
255
|
+
// run that produced results keeps its evidence-based mark.
|
|
256
|
+
const inClusterFailedToRun =
|
|
257
|
+
!!ctx.inClusterRunError && !ctx.inClusterRunning && inClusterProbes.filter((p) => !p.skipped).length === 0
|
|
258
|
+
const incluster: Origin = {
|
|
259
|
+
id: 'incluster',
|
|
260
|
+
glyph: '⚗',
|
|
261
|
+
name: 'In-cluster probe',
|
|
262
|
+
mech: 'a throwaway Pod Radar starts inside the cluster',
|
|
263
|
+
// The Job sets no serviceAccountName and disables token automount, so it runs
|
|
264
|
+
// as the target namespace's default SA with no credentials mounted - NOT as
|
|
265
|
+
// Radar. Sidecar injection is admission's call, not ours; the runner handles
|
|
266
|
+
// injected sidecars precisely because they do appear.
|
|
267
|
+
identity: `in ${ns || 'the namespace'} · the namespace’s default account, no token`,
|
|
268
|
+
kind: 'synthetic',
|
|
269
|
+
kindTag: ORIGIN_KIND_TAG.synthetic,
|
|
270
|
+
lane: 'dataplane',
|
|
271
|
+
mark: inClusterFailedToRun && ctx.inClusterAllowed !== false ? 'blocked' : markFor(inClusterProbes, 'incluster', ctx),
|
|
272
|
+
unavailable:
|
|
273
|
+
ctx.inClusterAllowed === false
|
|
274
|
+
? ctx.inClusterDeniedReason || 'Not permitted in this cluster.'
|
|
275
|
+
: inClusterFailedToRun
|
|
276
|
+
? ctx.inClusterRunError
|
|
277
|
+
: undefined,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const apiProbes = byOrigin.get('apiserver') ?? []
|
|
281
|
+
const apiserver: Origin = {
|
|
282
|
+
id: 'apiserver',
|
|
283
|
+
glyph: '⌸',
|
|
284
|
+
name: 'API-server proxy',
|
|
285
|
+
mech: 'Kubernetes relays the request for us',
|
|
286
|
+
identity: 'Radar’s own credentials — not a Pod',
|
|
287
|
+
kind: 'relayed',
|
|
288
|
+
kindTag: ORIGIN_KIND_TAG.relayed,
|
|
289
|
+
lane: 'control',
|
|
290
|
+
mark: markFor(apiProbes, 'apiserver', ctx),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const localProbes = byOrigin.get('local') ?? []
|
|
294
|
+
const localMark = markFor(localProbes, 'local', ctx)
|
|
295
|
+
// A probed run that produced NOTHING from the laptop, on a subject with no
|
|
296
|
+
// front door, is not "not tested yet" - there is nothing the laptop could
|
|
297
|
+
// dial. The vantage stays on the rail (the evidence ceiling must stay
|
|
298
|
+
// visible, exactly like the caller and external gaps) but it must say WHY it
|
|
299
|
+
// cannot be used - a bare "not tested" reads as a test someone forgot to run.
|
|
300
|
+
// Only claimed after a run: with no probes anywhere, "not tested yet" is the
|
|
301
|
+
// honest state for everyone.
|
|
302
|
+
const localCannotDial = probes.length > 0 && localProbes.length === 0 && localMark === 'untested' && (trace?.upstreams ?? []).length === 0
|
|
303
|
+
// "No Ingress, Gateway or LoadBalancer" is only true of a ClusterIP subject.
|
|
304
|
+
// A LoadBalancer/NodePort Service with no Ingress reaches this branch too -
|
|
305
|
+
// Radar just doesn't dial its external address yet, which is a claim about
|
|
306
|
+
// Radar, not about the Service.
|
|
307
|
+
const subjectType = (trace?.downstream ?? [])
|
|
308
|
+
.find((h) => h.resource.kind === 'Service' && h.resource.name === trace?.subject.name)
|
|
309
|
+
?.config?.serviceType?.trim()
|
|
310
|
+
.toUpperCase()
|
|
311
|
+
const externallyAddressable = subjectType === 'LOADBALANCER' || subjectType === 'NODEPORT' || subjectType === 'EXTERNALNAME'
|
|
312
|
+
const local: Origin = {
|
|
313
|
+
id: 'local',
|
|
314
|
+
glyph: '▣',
|
|
315
|
+
name: 'Radar on your machine',
|
|
316
|
+
mech: 'this machine, over your own network',
|
|
317
|
+
kind: 'real-client',
|
|
318
|
+
kindTag: LAPTOP_TAG,
|
|
319
|
+
// Not the cluster dataplane: from a laptop a request never traverses
|
|
320
|
+
// kube-proxy, NetworkPolicy or the mesh, so it cannot sit in that lane.
|
|
321
|
+
lane: 'control',
|
|
322
|
+
mark: localCannotDial ? 'blocked' : localMark,
|
|
323
|
+
identity: localIdentity(localProbes),
|
|
324
|
+
unavailable:
|
|
325
|
+
localMark === 'blocked'
|
|
326
|
+
? skipReason(localProbes)
|
|
327
|
+
: localCannotDial
|
|
328
|
+
? externallyAddressable
|
|
329
|
+
? 'Radar doesn’t dial this Service’s external address from your machine yet. From here it can only relay through the API-server proxy.'
|
|
330
|
+
: 'This Service has no entry point Radar can dial from your machine — no Ingress, Gateway address or LoadBalancer. From here Radar can only relay through the API-server proxy.'
|
|
331
|
+
: undefined,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const radarProbes = byOrigin.get('radar-incluster') ?? []
|
|
335
|
+
// Radar running AS a Pod is not the throwaway Job: it dials with Radar's own
|
|
336
|
+
// ServiceAccount from Radar's own namespace, and whatever sidecars Radar has.
|
|
337
|
+
// Collapsing the two credited Radar's direct dials to a Job never created.
|
|
338
|
+
const radarInCluster: Origin = {
|
|
339
|
+
id: 'radar-incluster',
|
|
340
|
+
glyph: '◈',
|
|
341
|
+
name: 'Radar, inside the cluster',
|
|
342
|
+
mech: 'Radar\u2019s own process, running as a Pod',
|
|
343
|
+
identity: 'Radar\u2019s own namespace and ServiceAccount',
|
|
344
|
+
kind: 'radar',
|
|
345
|
+
kindTag: ORIGIN_KIND_TAG.radar,
|
|
346
|
+
lane: 'dataplane',
|
|
347
|
+
mark: markFor(radarProbes, 'radar-incluster', ctx),
|
|
348
|
+
unsupported: trace?.runVantage !== 'in-cluster' && radarProbes.length === 0,
|
|
349
|
+
unavailable:
|
|
350
|
+
trace?.runVantage !== 'in-cluster' && radarProbes.length === 0
|
|
351
|
+
? 'Radar is not running in this cluster, so it cannot dial from inside on its own. The in-cluster test starts a throwaway Pod instead.'
|
|
352
|
+
: undefined,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const all: Record<OriginId, Origin> = { caller, external, incluster, 'radar-incluster': radarInCluster, local, apiserver }
|
|
356
|
+
// Radar not being a Pod is not an evidence GAP - the in-cluster Job covers the
|
|
357
|
+
// same ground. Listing it as "never tested" would pad the coverage caveat with
|
|
358
|
+
// a deployment detail, next to genuine holes like "as your application".
|
|
359
|
+
const radarIsAbsent = radarProbes.length === 0 && trace?.runVantage !== 'in-cluster'
|
|
360
|
+
return ORIGIN_STRENGTH.filter((id) => !(id === 'radar-incluster' && radarIsAbsent)).map((id) => all[id])
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* What we can honestly say about where the laptop sat on the network.
|
|
365
|
+
*
|
|
366
|
+
* This used to be a constant - "we can't tell where on the network you are" -
|
|
367
|
+
* for every trace forever. DNS already resolves the address, so when the name
|
|
368
|
+
* resolves to a globally routable one we DO know the request left for a public
|
|
369
|
+
* address, and saying nothing under-claims.
|
|
370
|
+
*
|
|
371
|
+
* It stops there deliberately. A public address is not proof the packet crossed
|
|
372
|
+
* the public internet: a VPN, hairpin NAT or split-horizon DNS all break that
|
|
373
|
+
* inference. So this describes the ADDRESS and never the journey, and it does
|
|
374
|
+
* not make the external vantage supported - that is a different claim.
|
|
375
|
+
*/
|
|
376
|
+
export function localIdentity(probes: ProbeResult[]): string {
|
|
377
|
+
const scopes = new Set(probes.filter((p) => !p.skipped && p.addressScope).map((p) => p.addressScope))
|
|
378
|
+
if (scopes.has('mixed')) {
|
|
379
|
+
return 'this name resolves to both public and private addresses — we can’t tell which one you reached'
|
|
380
|
+
}
|
|
381
|
+
if (scopes.has('public')) {
|
|
382
|
+
return 'you dialled a public address — though a VPN or split-horizon DNS could still have kept it internal'
|
|
383
|
+
}
|
|
384
|
+
if (scopes.has('private')) {
|
|
385
|
+
return 'you dialled a private address, so this did not come from the public internet'
|
|
386
|
+
}
|
|
387
|
+
return 'we can’t tell where on the network you are'
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The origin that should be selected when the view opens: the strongest one
|
|
391
|
+
* that actually produced evidence, else the strongest usable one. */
|
|
392
|
+
export function defaultOrigin(origins: Origin[], route?: RouteResult): OriginId {
|
|
393
|
+
// First paint must AGREE with the headline: the headline speaks from the
|
|
394
|
+
// rollup's deciding evidence, and default-selecting a different vantage put
|
|
395
|
+
// "not tested" directly under a sentence about a mechanism that vantage
|
|
396
|
+
// never used. Prefer the origin whose row decided the rollup.
|
|
397
|
+
const fromHeadline = headlineEvidenceOrigin(route)
|
|
398
|
+
if (fromHeadline && origins.some((o) => o.id === fromHeadline && !o.unsupported)) return fromHeadline
|
|
399
|
+
const evidence: Mark[] = ['proved', 'failed', 'answered', 'proxied', 'stale', 'running']
|
|
400
|
+
const withEvidence = origins.find((o) => evidence.includes(o.mark))
|
|
401
|
+
if (withEvidence) return withEvidence.id
|
|
402
|
+
const usable = origins.find((o) => !o.unsupported && !o.unavailable)
|
|
403
|
+
return usable?.id ?? 'apiserver'
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The origin whose evidence DECIDED the route rollup - what the headline
|
|
407
|
+
* speaks from. Undefined when nothing decided it (untested / no rows). */
|
|
408
|
+
export function headlineEvidenceOrigin(route?: RouteResult): OriginId | undefined {
|
|
409
|
+
if (!route || route.outcome === 'not-tested') return undefined
|
|
410
|
+
if (route.confidence === 'indirect') return 'apiserver'
|
|
411
|
+
const rows = route.byVantage ?? []
|
|
412
|
+
const row = rows.find((v) => v.confidence === 'real' && v.outcome === route.outcome) ?? rows.find((v) => v.outcome === route.outcome)
|
|
413
|
+
if (!row) return undefined
|
|
414
|
+
if (row.path === 'apiserver') return 'apiserver'
|
|
415
|
+
if (row.vantage !== 'in-cluster') return 'local'
|
|
416
|
+
return row.source === 'radar' ? 'radar-incluster' : 'incluster'
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const isGap = (o: Origin): boolean => o.mark === 'untested' || o.mark === 'denied' || (!!o.unsupported && o.mark === 'blocked')
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The strongest origin that is not yet proven, including ones Radar cannot use.
|
|
423
|
+
* This is the honest ceiling on the evidence - useful as a caveat, but NEVER as
|
|
424
|
+
* a call to action.
|
|
425
|
+
*/
|
|
426
|
+
export function strongestGap(origins: Origin[]): Origin | undefined {
|
|
427
|
+
return origins.find(isGap)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The strongest gap Radar can actually close.
|
|
432
|
+
*
|
|
433
|
+
* Kept separate from `strongestGap` deliberately. The real caller is almost
|
|
434
|
+
* always the strongest missing origin AND permanently unavailable, so driving
|
|
435
|
+
* the next-step prompt from `strongestGap` made every resource advertise a test
|
|
436
|
+
* that cannot be run - a dead end exactly where the operator needs a next move.
|
|
437
|
+
* The unreachable ceiling belongs in the caveats; the prompt belongs here.
|
|
438
|
+
*/
|
|
439
|
+
export function actionableGap(origins: Origin[]): Origin | undefined {
|
|
440
|
+
// ORIGIN_STRENGTH is an evidence ranking, and a gap is only worth proposing
|
|
441
|
+
// if it is STRONGER than what has already been proven. Without this the
|
|
442
|
+
// panel told you your laptop (control lane) was "the strongest evidence
|
|
443
|
+
// Radar can still collect" about in-cluster reachability, after the
|
|
444
|
+
// in-cluster probe had already succeeded.
|
|
445
|
+
const provenAt = origins.findIndex((o) => o.mark === 'proved')
|
|
446
|
+
const ceiling = provenAt === -1 ? origins.length : provenAt
|
|
447
|
+
return origins.slice(0, ceiling).find((o) => isGap(o) && !o.unsupported && o.mark !== 'denied')
|
|
448
|
+
}
|
|
449
|
+
|