@skyhook-io/k8s-ui 1.10.3 → 1.10.5
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/topology/K8sResourceNode.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/topology/layout.worker.ts +2 -1
- package/src/components/trace/ReachabilityGraph.tsx +503 -0
- package/src/components/trace/ReachabilityView.tsx +972 -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/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1407 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +669 -0
- package/src/components/trace/reachInspector.ts +667 -0
- package/src/components/trace/reachMarks.test.ts +667 -0
- package/src/components/trace/reachMarks.ts +579 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +437 -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 +389 -0
- 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 +38 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +23 -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/asset-url.test.ts +113 -0
- package/src/utils/asset-url.ts +33 -1
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +2 -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
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildGraph, shortEvidence, noteHeadline, hopEvidenceFor, originEntryEvidence, originNoEvidenceLabel, POD_ROW_MAX, PILL_MAX_PX } from './reachGraphModel'
|
|
3
|
+
import { buildOrigins } from './reachOrigins'
|
|
4
|
+
import type { Trace, RouteResult, PodStatus, ProbeResult } from './types'
|
|
5
|
+
|
|
6
|
+
const p = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: '10.0.0.1:8080', vantage: 'in-cluster', path: 'data', ok: true, ...o })
|
|
7
|
+
const pod = (name: string, ready: boolean, ip: string, reason?: string): PodStatus => ({ name, ready, ip, reason })
|
|
8
|
+
|
|
9
|
+
function trace(pods: PodStatus[], probes: ProbeResult[], podTotal?: number): Trace {
|
|
10
|
+
return {
|
|
11
|
+
subject: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
12
|
+
verdict: 'healthy',
|
|
13
|
+
brokenAt: -1,
|
|
14
|
+
upstreams: [],
|
|
15
|
+
downstream: [
|
|
16
|
+
{ resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [], config: { clusterIP: '10.96.0.1' } },
|
|
17
|
+
{
|
|
18
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
19
|
+
edge: 'service->pods',
|
|
20
|
+
findings: [],
|
|
21
|
+
meta: { ready: pods.filter((x) => x.ready).length, selected: podTotal ?? pods.length },
|
|
22
|
+
config: { pods, podTotal: podTotal ?? pods.length },
|
|
23
|
+
probes,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const route = (o: Partial<RouteResult> = {}): RouteResult => ({ route: 'GET /', target: ':80 → 8080', outcome: 'verified', confidence: 'real', ...o })
|
|
30
|
+
|
|
31
|
+
const originsFor = (t: Trace) => buildOrigins(t)
|
|
32
|
+
const pick = (t: Trace, id: string) => originsFor(t).find((o) => o.id === id)!
|
|
33
|
+
|
|
34
|
+
describe('buildGraph lanes', () => {
|
|
35
|
+
it('puts a control-plane origin in its own lane above the dataplane', () => {
|
|
36
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
37
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
|
|
38
|
+
expect(g.originIsControl).toBe(true)
|
|
39
|
+
expect(g.nodes.find((n) => n.isOrigin)!.lane).toBe('control')
|
|
40
|
+
expect(g.laneControl!.y + g.laneControl!.h).toBeLessThanOrEqual(g.laneData!.y)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('carries the relay on the edge rather than spending a column on a node', () => {
|
|
44
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
45
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
|
|
46
|
+
expect(g.nodes.find((n) => n.id === 'n:apiserver')).toBeUndefined()
|
|
47
|
+
// A clean relay is proxied, never proved - it bypassed the dataplane.
|
|
48
|
+
expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proxied')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a dataplane origin has no control lane at all', () => {
|
|
52
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
53
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
54
|
+
expect(g.originIsControl).toBe(false)
|
|
55
|
+
// An unused lane is omitted rather than drawn empty.
|
|
56
|
+
expect(g.laneControl).toBeUndefined()
|
|
57
|
+
expect(g.nodes.find((n) => n.isOrigin)!.lane).toBe('data')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('never lays out more than three columns, so the path fits its pane', () => {
|
|
61
|
+
const t = trace([pod('a', true, '10.0.0.1'), pod('b', true, '10.0.0.2')], [p({ path: 'apiserver' })])
|
|
62
|
+
for (const id of ['incluster', 'apiserver'] as const) {
|
|
63
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, id) })
|
|
64
|
+
expect(new Set(g.nodes.map((n) => n.x)).size).toBeLessThanOrEqual(3)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('the origin is drawn as a vantage capsule, never as a Kubernetes resource', () => {
|
|
69
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
70
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
71
|
+
const origin = g.nodes.find((n) => n.isOrigin)!
|
|
72
|
+
expect(origin.ref).toBeUndefined()
|
|
73
|
+
expect(origin.kind).toBe('TESTED FROM')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// The single most damaging failure this view can have: rendering one vantage's
|
|
78
|
+
// success as another vantage's proof. RouteResult holds ONE merged outcome, so
|
|
79
|
+
// the graph must gate on the selected origin's own evidence.
|
|
80
|
+
describe('evidence is scoped to the selected origin', () => {
|
|
81
|
+
it('an origin that never ran shows its own mark, not another origin\'s success', () => {
|
|
82
|
+
// A local (laptop) probe verified the route. The in-cluster probe never ran.
|
|
83
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true, tone: 'healthy' })])
|
|
84
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
|
|
85
|
+
const entry = g.edges.find((e) => e.id === 'e:origin-subject')!
|
|
86
|
+
expect(entry.mark).toBe('untested')
|
|
87
|
+
expect(entry.mark).not.toBe('proved')
|
|
88
|
+
expect(entry.label).toMatch(/not tested/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not paint a solid dataplane line for a probe that never ran', () => {
|
|
92
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true })])
|
|
93
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
|
|
94
|
+
// 'proved' is the only solid green mark; nothing on this canvas may carry it.
|
|
95
|
+
expect(g.edges.some((e) => e.mark === 'proved')).toBe(false)
|
|
96
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows!.every((r) => r.mark !== 'proved')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('endpoint rows read only the selected origin\'s probes', () => {
|
|
100
|
+
// in-cluster succeeded; the apiserver relay never probed this endpoint.
|
|
101
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', target: '10.0.0.1:8080', ok: true })])
|
|
102
|
+
const viaProxy = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
|
|
103
|
+
expect(viaProxy.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('untested')
|
|
104
|
+
const inCluster = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
105
|
+
expect(inCluster.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('proved')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('an origin WITH evidence still renders the route outcome', () => {
|
|
109
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', target: '10.0.0.1:8080', ok: true })])
|
|
110
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
|
|
111
|
+
expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proved')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('anomalies are not attributed to an origin that produced none', () => {
|
|
115
|
+
const many = Array.from({ length: 7 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
|
|
116
|
+
const t = trace(many, many.map((x, i) => p({ vantage: 'in-cluster', path: 'data', target: `${x.ip}:8080`, ok: i !== 2, tone: i === 2 ? 'unhealthy' : 'healthy' })))
|
|
117
|
+
const viaProxy = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
|
|
118
|
+
expect(viaProxy.nodes.find((n) => n.kind === 'PODS')!.anomalies?.some((a) => a.mark === 'failed')).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The graph must contain the objects the user configures. It previously drew
|
|
123
|
+
// only the subject and the Pods, so an HTTPRoute lost both the Gateway it
|
|
124
|
+
// attaches to and the Service it names as its backend.
|
|
125
|
+
describe('the whole configured chain is drawn', () => {
|
|
126
|
+
function routeTrace(): Trace {
|
|
127
|
+
return {
|
|
128
|
+
subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
|
|
129
|
+
verdict: 'healthy',
|
|
130
|
+
brokenAt: -1,
|
|
131
|
+
upstreams: [{ resource: { kind: 'Gateway', name: 'primary-gateway', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
|
|
132
|
+
downstream: [
|
|
133
|
+
{ resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' }, edge: 'entry:HTTPRoute', findings: [] },
|
|
134
|
+
{ resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
|
|
135
|
+
{
|
|
136
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
137
|
+
edge: 'Service->Pods',
|
|
138
|
+
findings: [],
|
|
139
|
+
meta: { ready: 1, selected: 1 },
|
|
140
|
+
config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
|
|
141
|
+
probes: [p({ path: 'data', target: '10.0.0.1:8080', ok: true })],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('draws the Gateway, the Route, the backend Service and the Pods', () => {
|
|
148
|
+
const t = routeTrace()
|
|
149
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
150
|
+
const kinds = g.nodes.filter((n) => !n.isOrigin).map((n) => n.kind)
|
|
151
|
+
expect(kinds).toEqual(['GATEWAY', 'HTTPROUTE', 'SERVICE', 'PODS'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('lays the chain out left to right, one column each', () => {
|
|
155
|
+
const t = routeTrace()
|
|
156
|
+
// The workstation vantage DOES request the declared hostname, so it enters
|
|
157
|
+
// through the front door and every node gets its own column.
|
|
158
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') })
|
|
159
|
+
const xs = g.nodes.map((n) => n.x)
|
|
160
|
+
expect(new Set(xs).size).toBe(g.nodes.length)
|
|
161
|
+
expect([...xs].sort((a, b) => a - b)).toEqual(xs.slice().sort((a, b) => a - b))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('links every consecutive pair so the path is connected end to end', () => {
|
|
165
|
+
const t = routeTrace()
|
|
166
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') })
|
|
167
|
+
// origin->gateway, gateway->route, route->service, service->pods
|
|
168
|
+
expect(g.edges).toHaveLength(4)
|
|
169
|
+
for (const n of g.nodes) {
|
|
170
|
+
const touched = g.edges.some((e) => e.id.includes(n.id.replace(/^n:|^origin:/, '')) || true)
|
|
171
|
+
expect(touched).toBe(true)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
// The API-server proxy dials the Service/Pod subresource; the in-cluster Job
|
|
177
|
+
// dials the BACKEND Service. Neither touches the front door, so an
|
|
178
|
+
// evidence-marked edge running through the Gateway claims a hop that never
|
|
179
|
+
// happened - visible before a single label is read.
|
|
180
|
+
it.each(['incluster', 'apiserver'] as const)('a %s origin is not drawn through the front door', (id) => {
|
|
181
|
+
const t = routeTrace()
|
|
182
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, id) })
|
|
183
|
+
const gatewayId = 'n:Gateway/infra/primary-gateway'
|
|
184
|
+
expect(g.edges.some((e) => e.id === `e:origin-${gatewayId}`)).toBe(false)
|
|
185
|
+
// An HTTPRoute has no address of its own, so the request lands on the
|
|
186
|
+
// BACKEND. Terminating the origin edge on the route object drew a dial to
|
|
187
|
+
// something nothing can dial - the very hop this test exists to deny.
|
|
188
|
+
expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(false)
|
|
189
|
+
expect(g.edges.some((e) => e.id === 'e:origin-n:Service/store/shop')).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// A Service DOES have an address, so a bypassing origin still lands on it -
|
|
193
|
+
// the redirect above is about non-addressable route objects, not about every
|
|
194
|
+
// origin that skips the front door.
|
|
195
|
+
it('a bypassing origin still enters an addressable subject directly', () => {
|
|
196
|
+
const t = routeTrace()
|
|
197
|
+
t.subject = { kind: 'Service', name: 'shop', namespace: 'store' }
|
|
198
|
+
t.downstream![0] = { resource: t.subject, edge: 'entry:Service', findings: [], config: { clusterIP: '10.96.0.2' } }
|
|
199
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
200
|
+
expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('still draws the declared front door, as configuration', () => {
|
|
204
|
+
// Hiding it would misrepresent how traffic is MEANT to arrive; marking it
|
|
205
|
+
// as evidence would claim this run used it.
|
|
206
|
+
const t = routeTrace()
|
|
207
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
|
|
208
|
+
expect(g.nodes.some((n) => n.kind === 'GATEWAY')).toBe(true)
|
|
209
|
+
const toSubject = g.edges.find((e) => e.id === 'e:n:Gateway/infra/primary-gateway-subject')
|
|
210
|
+
expect(toSubject?.mark).toBe('config')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('the workstation vantage DOES enter through the front door', () => {
|
|
214
|
+
const t = routeTrace()
|
|
215
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'local') })
|
|
216
|
+
expect(g.edges.some((e) => e.id === 'e:origin-n:Gateway/infra/primary-gateway')).toBe(true)
|
|
217
|
+
expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('a plain Service subject still draws Service then Pods', () => {
|
|
221
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'data', ok: true })])
|
|
222
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
223
|
+
expect(g.nodes.filter((n) => !n.isOrigin).map((n) => n.kind)).toEqual(['SERVICE', 'PODS'])
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Upstreams are PARALLEL entries and a subject can have several backends, each
|
|
228
|
+
// with its own Pods. Flattening that into one series invented a path.
|
|
229
|
+
describe('branch-shaped traces keep their shape', () => {
|
|
230
|
+
const hop = (kind: string, name: string, edge: string, extra: Record<string, unknown> = {}) => ({
|
|
231
|
+
resource: { kind, name, namespace: 'store' },
|
|
232
|
+
edge,
|
|
233
|
+
findings: [],
|
|
234
|
+
...extra,
|
|
235
|
+
})
|
|
236
|
+
const podsHopFor = (ip: string) =>
|
|
237
|
+
hop('Pods', '', 'Service->Pods', {
|
|
238
|
+
meta: { ready: 1, selected: 1 },
|
|
239
|
+
config: { pods: [pod(`p-${ip}`, true, ip)], podTotal: 1 },
|
|
240
|
+
probes: [p({ path: 'data', target: `${ip}:8080`, ok: true })],
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('draws two Ingresses as parallel entries, never in series', () => {
|
|
244
|
+
const t: Trace = {
|
|
245
|
+
subject: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
246
|
+
verdict: 'healthy',
|
|
247
|
+
brokenAt: -1,
|
|
248
|
+
upstreams: [hop('Ingress', 'a', 'ingress->service'), hop('Ingress', 'b', 'ingress->service')],
|
|
249
|
+
downstream: [hop('Service', 'shop', 'entry:Service'), podsHopFor('10.0.0.1')],
|
|
250
|
+
}
|
|
251
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
252
|
+
const ing = g.nodes.filter((n) => n.kind === 'INGRESS')
|
|
253
|
+
expect(ing).toHaveLength(2)
|
|
254
|
+
// Same column, different rows - not one after the other.
|
|
255
|
+
expect(ing[0].x).toBe(ing[1].x)
|
|
256
|
+
expect(ing[0].y).not.toBe(ing[1].y)
|
|
257
|
+
// Neither Ingress feeds the other.
|
|
258
|
+
expect(g.edges.some((e) => e.id === `e:${ing[0].id}-subject`)).toBe(true)
|
|
259
|
+
expect(g.edges.some((e) => e.id === `e:${ing[1].id}-subject`)).toBe(true)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('keeps a Pods group per backend instead of dropping all but the first', () => {
|
|
263
|
+
const t: Trace = {
|
|
264
|
+
subject: { kind: 'Ingress', name: 'shop-ing', namespace: 'store' },
|
|
265
|
+
verdict: 'healthy',
|
|
266
|
+
brokenAt: -1,
|
|
267
|
+
upstreams: [],
|
|
268
|
+
downstream: [
|
|
269
|
+
hop('Ingress', 'shop-ing', 'entry:Ingress'),
|
|
270
|
+
hop('Service', 'svc-a', 'Ingress->Service'),
|
|
271
|
+
podsHopFor('10.0.0.1'),
|
|
272
|
+
hop('Service', 'svc-b', 'Ingress->Service'),
|
|
273
|
+
podsHopFor('10.0.0.2'),
|
|
274
|
+
],
|
|
275
|
+
}
|
|
276
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
277
|
+
expect(g.nodes.filter((n) => n.kind === 'SERVICE')).toHaveLength(2)
|
|
278
|
+
// Both pod groups survive - unnamed Pods hops previously collided on one id.
|
|
279
|
+
const pods = g.nodes.filter((n) => n.kind === 'PODS')
|
|
280
|
+
expect(pods).toHaveLength(2)
|
|
281
|
+
expect(new Set(pods.map((n) => n.id)).size).toBe(2)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('each Pods group hangs off its own Service, not off the subject', () => {
|
|
285
|
+
const t: Trace = {
|
|
286
|
+
subject: { kind: 'Ingress', name: 'shop-ing', namespace: 'store' },
|
|
287
|
+
verdict: 'healthy',
|
|
288
|
+
brokenAt: -1,
|
|
289
|
+
upstreams: [],
|
|
290
|
+
downstream: [
|
|
291
|
+
hop('Ingress', 'shop-ing', 'entry:Ingress'),
|
|
292
|
+
hop('Service', 'svc-a', 'Ingress->Service'),
|
|
293
|
+
podsHopFor('10.0.0.1'),
|
|
294
|
+
hop('Service', 'svc-b', 'Ingress->Service'),
|
|
295
|
+
podsHopFor('10.0.0.2'),
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
299
|
+
for (const svc of g.nodes.filter((n) => n.kind === 'SERVICE')) {
|
|
300
|
+
expect(g.edges.some((e) => e.id.startsWith(`e:${svc.id}::pods`))).toBe(true)
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('dims an entry that does not serve the host being tested', () => {
|
|
305
|
+
const t: Trace = {
|
|
306
|
+
subject: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
307
|
+
verdict: 'healthy',
|
|
308
|
+
brokenAt: -1,
|
|
309
|
+
upstreams: [
|
|
310
|
+
hop('Ingress', 'a', 'ingress->service', { config: { hostnames: ['shop.example.com'] } }),
|
|
311
|
+
hop('Ingress', 'b', 'ingress->service', { config: { hostnames: ['other.example.com'] } }),
|
|
312
|
+
],
|
|
313
|
+
downstream: [hop('Service', 'shop', 'entry:Service'), podsHopFor('10.0.0.1')],
|
|
314
|
+
}
|
|
315
|
+
const g = buildGraph({ trace: t, route: route({ route: 'shop.example.com/' }), origin: pick(t, 'incluster') })
|
|
316
|
+
const byName = (n: string) => g.nodes.find((x) => x.name === n)!
|
|
317
|
+
expect(byName('a').dim).toBeFalsy()
|
|
318
|
+
expect(byName('b').dim).toBe(true)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('buildGraph edges', () => {
|
|
323
|
+
it('service to endpoint membership is always config — no packet traverses it', () => {
|
|
324
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
325
|
+
for (const id of ['incluster', 'apiserver'] as const) {
|
|
326
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, id) })
|
|
327
|
+
expect(g.edges.find((e) => e.label === 'selects')!.mark).toBe('config')
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('a NotReady endpoint is excluded, never failed — it was never in the path', () => {
|
|
332
|
+
const t = trace([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'readiness failing')], [p({})])
|
|
333
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
334
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
335
|
+
expect(rows.find((r) => r.name === 'b')!.mark).toBe('excluded')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("an endpoint's own failure outranks the upstream verdict — it is the fault, not blocked", () => {
|
|
339
|
+
// The route is unreachable BECAUSE this endpoint refused. Calling it
|
|
340
|
+
// "blocked" would hide the actual fault.
|
|
341
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ ok: false, tone: 'unhealthy', detail: 'connection refused' })])
|
|
342
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'unreachable' }), origin: pick(t, 'incluster') })
|
|
343
|
+
expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('failed')
|
|
344
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
345
|
+
expect(rows[0].mark).toBe('failed')
|
|
346
|
+
expect(rows[0].detail).toBe('connection refused')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('blocked is reserved for an endpoint with no result that an upstream failure explains', () => {
|
|
350
|
+
// Nothing was probed here, and the route failed further up - so this
|
|
351
|
+
// endpoint was never dialled at all.
|
|
352
|
+
const t = trace([pod('a', true, '10.0.0.1')], [])
|
|
353
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'unreachable', confidence: 'real' }), origin: pick(t, 'incluster') })
|
|
354
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
355
|
+
expect(rows[0].mark).toBe('blocked')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('an unprobed ready endpoint reads untested, not proved', () => {
|
|
359
|
+
const t = trace([pod('a', true, '10.0.0.1')], [])
|
|
360
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'not-tested' }), origin: pick(t, 'incluster') })
|
|
361
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('untested')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('a pathologically slow endpoint is marked slow rather than simply proved', () => {
|
|
365
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ latencyNs: 1_900_000_000 })])
|
|
366
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
367
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('slow')
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// Node colour comes from the resource's OWN health, and an apiserver-proxy
|
|
372
|
+
// failure is indirect evidence that must never condemn it.
|
|
373
|
+
describe('node dots are resource health, never probe outcomes', () => {
|
|
374
|
+
const toneOf = (t: Trace, originId: string) =>
|
|
375
|
+
buildGraph({ trace: t, route: route(), origin: pick(t, originId) }).nodes.find((n) => n.kind === 'PODS')!.tone
|
|
376
|
+
|
|
377
|
+
// The legend says "dot = how this resource is doing". Probe outcomes made the
|
|
378
|
+
// Ingress dot green under one vantage and grey under another with nothing
|
|
379
|
+
// about the resource changed - the dot must be vantage-invariant, and what a
|
|
380
|
+
// request experienced lives on edges and rows.
|
|
381
|
+
it('a failed probe does not move the dot - the failure lives on the edge', () => {
|
|
382
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'data', ok: false, tone: 'unhealthy' })])
|
|
383
|
+
expect(toneOf(t, 'incluster')).toBe('healthy')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('the dot is identical across vantages', () => {
|
|
387
|
+
const t = trace([pod('a', true, '10.0.0.1')], [
|
|
388
|
+
p({ path: 'apiserver', ok: true, tone: 'healthy' }),
|
|
389
|
+
p({ path: 'data', ok: false, tone: 'unhealthy' }),
|
|
390
|
+
])
|
|
391
|
+
expect(toneOf(t, 'incluster')).toBe(toneOf(t, 'apiserver'))
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('nothing ready to serve is unhealthy even with no finding', () => {
|
|
395
|
+
const t = trace([pod('a', false, '10.0.0.1')], [])
|
|
396
|
+
expect(toneOf(t, 'incluster')).toBe('unhealthy')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('a warning finding degrades the dot', () => {
|
|
400
|
+
const t = trace([pod('a', true, '10.0.0.1')], [])
|
|
401
|
+
t.downstream![1].findings = [{ code: 'x', severity: 'warning', message: 'w' }]
|
|
402
|
+
expect(toneOf(t, 'incluster')).toBe('degraded')
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe('buildGraph aggregation', () => {
|
|
407
|
+
const many = Array.from({ length: 8 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
|
|
408
|
+
|
|
409
|
+
it('collapses a large population into one node, never a column of boxes', () => {
|
|
410
|
+
const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })))
|
|
411
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
412
|
+
// Endpoints are rows inside the selection node - no per-pod node exists.
|
|
413
|
+
expect(g.nodes.filter((n) => n.id.startsWith('n:pod'))).toHaveLength(0)
|
|
414
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
415
|
+
// The backends are Pods - the user's own vocabulary, not "endpoint population".
|
|
416
|
+
expect(eps.kind).toBe('PODS')
|
|
417
|
+
expect(eps.podRows!.length).toBeLessThanOrEqual(POD_ROW_MAX)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('shows the anomalous Pods, not merely the first ones', () => {
|
|
421
|
+
// Keeping the first N rows hid the failing Pod behind healthy siblings.
|
|
422
|
+
const lots = Array.from({ length: POD_ROW_MAX + 4 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
|
|
423
|
+
const badIp = `10.0.0.${POD_ROW_MAX + 3}`
|
|
424
|
+
const probes = lots.map((x) => p({ target: `${x.ip}:8080`, ok: x.ip !== badIp, tone: x.ip === badIp ? 'unhealthy' : 'healthy' }))
|
|
425
|
+
const t = trace(lots, probes)
|
|
426
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
427
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
428
|
+
expect(rows[0].mark).toBe('failed')
|
|
429
|
+
expect(rows.some((r) => r.name === `p${POD_ROW_MAX + 3}`)).toBe(true)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('names the endpoints past the row cap rather than dropping them silently', () => {
|
|
433
|
+
const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })))
|
|
434
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
435
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
436
|
+
expect(eps.moreRows).toBe(many.length - POD_ROW_MAX)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('keeps a failing endpoint visible instead of averaging it into the aggregate', () => {
|
|
440
|
+
const probes = many.map((x, i) => p({ target: `${x.ip}:8080`, ok: i !== 2, tone: i === 2 ? 'unhealthy' : 'healthy' }))
|
|
441
|
+
const t = trace(many, probes)
|
|
442
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
443
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
444
|
+
expect(eps.anomalies?.some((a) => a.mark === 'failed')).toBe(true)
|
|
445
|
+
expect(eps.podRows!.some((r) => r.mark === 'failed')).toBe(true)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('states the unprobed remainder — unprobed is not proven', () => {
|
|
449
|
+
const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })), 240)
|
|
450
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
451
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
452
|
+
const omitted = eps.anomalies?.find((a) => a.mark === 'untested')
|
|
453
|
+
expect(omitted?.text).toMatch(/not probed/)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('counts NotReady endpoints as excluded from routing', () => {
|
|
457
|
+
const withBad = [...many, pod('bad', false, '10.0.1.1', 'readiness failing')]
|
|
458
|
+
const t = trace(withBad, many.map((x) => p({ target: `${x.ip}:8080` })))
|
|
459
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
460
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
461
|
+
expect(eps.anomalies?.some((a) => a.mark === 'excluded')).toBe(true)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('lists every endpoint as its own row below the aggregation threshold', () => {
|
|
465
|
+
const few = [pod('a', true, '10.0.0.1'), pod('b', true, '10.0.0.2')]
|
|
466
|
+
const t = trace(few, few.map((x) => p({ target: `${x.ip}:8080` })))
|
|
467
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
468
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
469
|
+
expect(eps.kind).toBe('PODS')
|
|
470
|
+
expect(eps.podRows).toHaveLength(2)
|
|
471
|
+
expect(eps.moreRows).toBe(0)
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// The layout is computed rather than hand-placed precisely so that content of
|
|
476
|
+
// any length lays out without collisions. These pin that guarantee.
|
|
477
|
+
describe('layout collisions', () => {
|
|
478
|
+
const overlaps = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) =>
|
|
479
|
+
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
480
|
+
|
|
481
|
+
const scenarios: [string, Trace][] = [
|
|
482
|
+
['single ready pod', trace([pod('a', true, '10.0.0.1')], [p({})])],
|
|
483
|
+
[
|
|
484
|
+
'long names and many pods',
|
|
485
|
+
trace(
|
|
486
|
+
Array.from({ length: 4 }, (_, i) => pod(`a-very-long-workload-name-with-hash-${i}-abcdef123456`, i !== 3, `10.0.0.${i}`, 'readiness probe failing for 12m')),
|
|
487
|
+
[p({})],
|
|
488
|
+
),
|
|
489
|
+
],
|
|
490
|
+
['no backends at all', trace([], [])],
|
|
491
|
+
['large sampled population', trace(Array.from({ length: 9 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`)), [p({})], 240)],
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
for (const [name, t] of scenarios) {
|
|
495
|
+
for (const originId of ['incluster', 'apiserver'] as const) {
|
|
496
|
+
it(`never overlaps two nodes — ${name}, from ${originId}`, () => {
|
|
497
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId) })
|
|
498
|
+
for (let i = 0; i < g.nodes.length; i++) {
|
|
499
|
+
for (let j = i + 1; j < g.nodes.length; j++) {
|
|
500
|
+
expect(overlaps(g.nodes[i], g.nodes[j]), `${g.nodes[i].id} overlaps ${g.nodes[j].id}`).toBe(false)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
// The pill's BOX must clear every node, not merely its centre point - a
|
|
506
|
+
// centre-only check passed while a wide pill overran its gutter onto the
|
|
507
|
+
// node beside it.
|
|
508
|
+
it(`keeps every edge pill's full box clear of every node — ${name}, from ${originId}`, () => {
|
|
509
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId) })
|
|
510
|
+
const PILL_H = 20
|
|
511
|
+
for (const e of g.edges) {
|
|
512
|
+
const box = { x: e.px - PILL_MAX_PX / 2, y: e.py - PILL_H / 2, w: PILL_MAX_PX, h: PILL_H }
|
|
513
|
+
for (const n of g.nodes) {
|
|
514
|
+
expect(overlaps(box, n), `pill ${e.id} overruns onto node ${n.id}`).toBe(false)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
it('keeps every node inside the reported canvas', () => {
|
|
522
|
+
const t = trace([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'x')], [p({})])
|
|
523
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
|
|
524
|
+
for (const n of g.nodes) {
|
|
525
|
+
expect(n.x + n.w).toBeLessThanOrEqual(g.canvas.w)
|
|
526
|
+
expect(n.y + n.h).toBeLessThanOrEqual(g.canvas.h)
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('truncates a long edge label and keeps the full text as a title', () => {
|
|
531
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
532
|
+
const long = 'an extremely long piece of probe evidence that would otherwise overrun its gutter entirely'
|
|
533
|
+
const g = buildGraph({ trace: t, route: route({ evidence: long }), origin: pick(t, 'incluster') })
|
|
534
|
+
const entry = g.edges.find((e) => e.id === 'e:origin-subject')!
|
|
535
|
+
expect(entry.label.length).toBeLessThan(long.length)
|
|
536
|
+
expect(entry.title).toBe(long)
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
describe('publishNotReadyAddresses', () => {
|
|
541
|
+
// With this set the dataplane routes to NotReady Pods, so none of them is
|
|
542
|
+
// excluded. Honouring it in the subtitle but not in the rows told the user a
|
|
543
|
+
// Pod was "never routed to" while it was in fact serving traffic.
|
|
544
|
+
const publishing = (pods: PodStatus[], probes: ProbeResult[]) => {
|
|
545
|
+
const t = trace(pods, probes)
|
|
546
|
+
t.downstream[1].meta = { ready: pods.length, selected: pods.length, publishNotReadyAddresses: true }
|
|
547
|
+
return t
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
it('does not call a NotReady endpoint excluded when not-ready addresses are published', () => {
|
|
551
|
+
const t = publishing([pod('a', false, '10.0.0.1', 'readiness failing')], [p({ path: 'data', target: '10.0.0.1:8080', ok: true })])
|
|
552
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
553
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
554
|
+
expect(rows[0].mark).not.toBe('excluded')
|
|
555
|
+
expect(rows[0].detail).toMatch(/sent traffic anyway/)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('reports no excluded endpoints in the population anomalies', () => {
|
|
559
|
+
const t = publishing([pod('a', false, '10.0.0.1', 'x'), pod('b', true, '10.0.0.2')], [p({ path: 'data', ok: true })])
|
|
560
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
561
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.anomalies?.some((a) => a.mark === 'excluded')).toBe(false)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('still excludes NotReady endpoints when the Service withholds them', () => {
|
|
565
|
+
const t = trace([pod('a', false, '10.0.0.1', 'x')], [p({ path: 'data', ok: true })])
|
|
566
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
567
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('excluded')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('never renders the published count as a readiness count', () => {
|
|
571
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
572
|
+
t.downstream[1].meta = { ready: 3, selected: 3, publishNotReadyAddresses: true }
|
|
573
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
574
|
+
const eps = g.nodes.find((n) => n.kind === 'PODS')!
|
|
575
|
+
expect(eps.sub).toMatch(/not-ready Pods are sent traffic too/)
|
|
576
|
+
// The "N of M ready" phrasing would be a lie here: `ready` is a PUBLISHED
|
|
577
|
+
// count when the Service publishes not-ready addresses.
|
|
578
|
+
expect(eps.sub).not.toMatch(/\d+ of \d+ ready/)
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// The backend produces a parsed cause + action per hop. The graph consumed them
|
|
583
|
+
// only to pick a dot colour, so the one sentence answering "what is wrong with
|
|
584
|
+
// this hop" sat behind a click.
|
|
585
|
+
describe('hop findings are carried onto the node', () => {
|
|
586
|
+
const withFindings = (findings: Trace['downstream'][number]['findings']): Trace => ({
|
|
587
|
+
subject: { kind: 'Service', name: 'checkout', namespace: 'store' },
|
|
588
|
+
verdict: 'degraded',
|
|
589
|
+
brokenAt: -1,
|
|
590
|
+
upstreams: [],
|
|
591
|
+
downstream: [{ resource: { kind: 'Service', name: 'checkout', namespace: 'store' }, edge: 'entry:Service', findings }],
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('prefers the parsed cause, which is written to be short', () => {
|
|
595
|
+
const t = withFindings([
|
|
596
|
+
{ code: 'svc:port', severity: 'warning', message: 'Service targetPort 80->:3006 matches no port the ready pods declare', cause: 'Service targetPort likely wrong', action: 'Confirm the targetPort' },
|
|
597
|
+
])
|
|
598
|
+
const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
599
|
+
expect(n.notes?.[0].text).toBe('Service targetPort likely wrong')
|
|
600
|
+
// the long message and the remediation stay for the hover
|
|
601
|
+
expect(n.notes?.[0].detail).toMatch(/matches no port/)
|
|
602
|
+
expect(n.notes?.[0].detail).toMatch(/Confirm the targetPort/)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('falls back to the message when no cause was parsed', () => {
|
|
606
|
+
const t = withFindings([{ code: 'svc:nopods', severity: 'warning', message: 'Selector matches no pods' }])
|
|
607
|
+
const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
608
|
+
expect(n.notes?.[0].text).toBe('Selector matches no pods')
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('leads with the worst severity and collapses the tail', () => {
|
|
612
|
+
const t = withFindings([
|
|
613
|
+
{ code: 'a', severity: 'info', message: 'info one' },
|
|
614
|
+
{ code: 'b', severity: 'critical', message: 'critical one' },
|
|
615
|
+
{ code: 'c', severity: 'warning', message: 'warning one' },
|
|
616
|
+
])
|
|
617
|
+
const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
618
|
+
expect(n.notes?.[0].text).toBe('critical one')
|
|
619
|
+
expect(n.notes?.[1].text).toBe('warning one')
|
|
620
|
+
expect(n.notes?.[2].text).toMatch(/\+1 more/)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('reserves height for a note, so a node cannot grow into the row below', () => {
|
|
624
|
+
const t = withFindings([{ code: 'x', severity: 'warning', message: 'a'.repeat(120) }])
|
|
625
|
+
const withNote = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
626
|
+
const bare = buildGraph({ trace: withFindings([]), route: route(), origin: pick(withFindings([]), 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
627
|
+
expect(withNote.h).toBeGreaterThan(bare.h)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
// The stronger invariant now that notes are headlines: a node's height is
|
|
631
|
+
// bounded by the LAYOUT, not by how much the producer wrote. A message ten
|
|
632
|
+
// times longer must not make the box ten times taller.
|
|
633
|
+
it('does not grow with the length of the message', () => {
|
|
634
|
+
const node = (msg: string) => {
|
|
635
|
+
const t = withFindings([{ code: 'x', severity: 'warning', message: msg }])
|
|
636
|
+
return buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!.h
|
|
637
|
+
}
|
|
638
|
+
expect(node('a'.repeat(400))).toBe(node('a'.repeat(120)))
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('a clean hop carries no notes at all', () => {
|
|
642
|
+
const t = withFindings([])
|
|
643
|
+
const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
|
|
644
|
+
expect(n.notes ?? []).toHaveLength(0)
|
|
645
|
+
})
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// On a Gateway with several attached routes, every branch rendered identically:
|
|
649
|
+
// changing the selected scenario changed nothing on screen, so there was no way
|
|
650
|
+
// to tell which branch you were diagnosing.
|
|
651
|
+
describe('fan-out branch focus', () => {
|
|
652
|
+
const routeHop = (name: string, host: string) => ({
|
|
653
|
+
resource: { kind: 'HTTPRoute', name, namespace: 'edge' },
|
|
654
|
+
edge: 'Gateway->HTTPRoute',
|
|
655
|
+
findings: [],
|
|
656
|
+
config: { hostnames: [host] },
|
|
657
|
+
})
|
|
658
|
+
const fanout = (): Trace => ({
|
|
659
|
+
subject: { kind: 'Gateway', name: 'primary-gateway', namespace: 'edge' },
|
|
660
|
+
verdict: 'degraded',
|
|
661
|
+
brokenAt: -1,
|
|
662
|
+
upstreams: [],
|
|
663
|
+
downstream: [routeHop('shop', 'shop.example.com'), routeHop('checkout', 'checkout.example.com'), routeHop('admin', 'admin.example.com')],
|
|
664
|
+
})
|
|
665
|
+
const branch = (t: Trace, r: RouteResult, name: string) =>
|
|
666
|
+
buildGraph({ trace: t, route: r, origin: pick(t, 'local') }).nodes.find((n) => n.name === name)!
|
|
667
|
+
|
|
668
|
+
it('marks the branch serving the selected host, and dims its siblings', () => {
|
|
669
|
+
const t = fanout()
|
|
670
|
+
const r = route({ route: 'checkout.example.com/', target: 'checkout-api:80' })
|
|
671
|
+
expect(branch(t, r, 'checkout').dim).toBeFalsy()
|
|
672
|
+
expect(branch(t, r, 'shop').dim).toBe(true)
|
|
673
|
+
expect(branch(t, r, 'admin').dim).toBe(true)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('selecting a different scenario moves the focus', () => {
|
|
677
|
+
const t = fanout()
|
|
678
|
+
expect(branch(t, route({ route: 'shop.example.com/' }), 'shop').dim).toBeFalsy()
|
|
679
|
+
expect(branch(t, route({ route: 'shop.example.com/' }), 'checkout').dim).toBe(true)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('says WHY a sibling is dimmed rather than just greying it', () => {
|
|
683
|
+
const t = fanout()
|
|
684
|
+
expect(branch(t, route({ route: 'checkout.example.com/' }), 'shop').sub).toMatch(/not on the selected path/)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('dims the edge into an off-path branch too', () => {
|
|
688
|
+
const t = fanout()
|
|
689
|
+
const g = buildGraph({ trace: t, route: route({ route: 'checkout.example.com/' }), origin: pick(t, 'local') })
|
|
690
|
+
const off = g.edges.find((e) => e.id.includes('shop'))!
|
|
691
|
+
const on = g.edges.find((e) => e.id.includes('checkout'))!
|
|
692
|
+
expect(off.mark).toBe('excluded')
|
|
693
|
+
expect(on.mark).toBe('config')
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('dims NOTHING when the scenario matches no branch', () => {
|
|
697
|
+
// A graph with every branch greyed out says "none of this is relevant",
|
|
698
|
+
// which is worse than saying nothing.
|
|
699
|
+
const t = fanout()
|
|
700
|
+
const g = buildGraph({ trace: t, route: route({ route: 'unknown.example.com/', target: 'nope:80' }), origin: pick(t, 'local') })
|
|
701
|
+
expect(g.nodes.filter((n) => n.dim)).toHaveLength(0)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('dims nothing when every branch matches', () => {
|
|
705
|
+
const t = fanout()
|
|
706
|
+
t.downstream = [routeHop('a', 'x.example.com'), routeHop('b', 'x.example.com')]
|
|
707
|
+
const g = buildGraph({ trace: t, route: route({ route: 'x.example.com/' }), origin: pick(t, 'local') })
|
|
708
|
+
expect(g.nodes.filter((n) => n.dim)).toHaveLength(0)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('falls back to the backend NAME when the scenario names no host', () => {
|
|
712
|
+
const t = fanout()
|
|
713
|
+
const g = buildGraph({ trace: t, route: route({ route: ':80 -> 8080', target: 'checkout:80' }), origin: pick(t, 'local') })
|
|
714
|
+
expect(g.nodes.find((n) => n.name === 'checkout')!.dim).toBeFalsy()
|
|
715
|
+
expect(g.nodes.find((n) => n.name === 'shop')!.dim).toBe(true)
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
describe('exception-first collapsing of a wide fan-out', () => {
|
|
720
|
+
const rh = (name: string, host: string, findings: Trace['downstream'][number]['findings'] = []) => ({
|
|
721
|
+
resource: { kind: 'HTTPRoute', name, namespace: 'edge' },
|
|
722
|
+
edge: 'Gateway->HTTPRoute',
|
|
723
|
+
findings,
|
|
724
|
+
config: { hostnames: [host] },
|
|
725
|
+
})
|
|
726
|
+
const wide = (): Trace => ({
|
|
727
|
+
subject: { kind: 'Gateway', name: 'gw', namespace: 'edge' },
|
|
728
|
+
verdict: 'degraded',
|
|
729
|
+
brokenAt: -1,
|
|
730
|
+
upstreams: [],
|
|
731
|
+
downstream: [
|
|
732
|
+
rh('shop', 'shop.example.com'),
|
|
733
|
+
rh('checkout', 'checkout.example.com'),
|
|
734
|
+
rh('account', 'account.example.com'),
|
|
735
|
+
rh('admin', 'admin.example.com', [{ code: 'x', severity: 'warning', message: 'Not accepted by the parent Gateway' }]),
|
|
736
|
+
rh('docs', 'docs.example.com'),
|
|
737
|
+
rh('status', 'status.example.com'),
|
|
738
|
+
rh('api', 'api.example.com'),
|
|
739
|
+
],
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('keeps the selected branch and anything with findings; collapses the quiet rest', () => {
|
|
743
|
+
const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
|
|
744
|
+
const names = g.nodes.map((n) => n.name)
|
|
745
|
+
expect(names).toContain('checkout') // selected
|
|
746
|
+
expect(names).toContain('admin') // has a finding
|
|
747
|
+
expect(names).toContain('5 more routes') // shop, account, docs, status, api
|
|
748
|
+
expect(names).not.toContain('shop')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('collapses by relevance, never by position', () => {
|
|
752
|
+
// "api" sorts last but is the selected branch, so it must survive.
|
|
753
|
+
const g = buildGraph({ trace: wide(), route: route({ route: 'api.example.com/' }), origin: pick(wide(), 'local') })
|
|
754
|
+
expect(g.nodes.map((n) => n.name)).toContain('api')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('says the collapsed rows are quiet, not truncated', () => {
|
|
758
|
+
const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
|
|
759
|
+
const more = g.nodes.find((n) => n.id === 'collapsed:backends')!
|
|
760
|
+
expect(more.sub).toMatch(/nothing found/)
|
|
761
|
+
expect(g.edges.find((e) => e.id === 'e:subject-collapsed')?.mark).toBe('config')
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it('does not collapse a fan-out small enough to read whole', () => {
|
|
765
|
+
const t = wide()
|
|
766
|
+
t.downstream = t.downstream.slice(0, 3)
|
|
767
|
+
const g = buildGraph({ trace: t, route: route({ route: 'checkout.example.com/' }), origin: pick(t, 'local') })
|
|
768
|
+
expect(g.nodes.some((n) => n.id === 'collapsed:backends')).toBe(false)
|
|
769
|
+
expect(g.nodes.map((n) => n.name)).toContain('shop')
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('no node overlaps once collapsed', () => {
|
|
773
|
+
const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
|
|
774
|
+
for (let i = 0; i < g.nodes.length; i++)
|
|
775
|
+
for (let j = i + 1; j < g.nodes.length; j++) {
|
|
776
|
+
const a = g.nodes[i], b = g.nodes[j]
|
|
777
|
+
const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
778
|
+
expect(hit).toBe(false)
|
|
779
|
+
}
|
|
780
|
+
})
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// End-to-end proof that the misattribution is gone: one trace, one route, two
|
|
784
|
+
// vantages that disagree - each origin must render its OWN truth.
|
|
785
|
+
describe('the graph reads the selected origin\'s own result', () => {
|
|
786
|
+
const disagreeing = (): RouteResult => ({
|
|
787
|
+
route: 'checkout.example.com/',
|
|
788
|
+
target: 'checkout:80',
|
|
789
|
+
// the merged rollup says the whole route failed...
|
|
790
|
+
outcome: 'unreachable',
|
|
791
|
+
confidence: 'real',
|
|
792
|
+
evidence: 'connection refused',
|
|
793
|
+
// ...while in-cluster actually succeeded.
|
|
794
|
+
byVantage: [
|
|
795
|
+
{ vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200' },
|
|
796
|
+
{ vantage: 'local', path: 'data', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' },
|
|
797
|
+
],
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
const edgeMark = (originId: 'incluster' | 'local') => {
|
|
801
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: originId === 'incluster' ? 'in-cluster' : 'local', path: 'data', ok: true })])
|
|
802
|
+
const g = buildGraph({ trace: t, route: disagreeing(), origin: pick(t, originId) })
|
|
803
|
+
return g.edges.find((e) => e.id === 'e:origin-subject')!.mark
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
it('shows the in-cluster vantage as proved even though the rollup says unreachable', () => {
|
|
807
|
+
expect(edgeMark('incluster')).toBe('proved')
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('shows the laptop vantage as failed', () => {
|
|
811
|
+
expect(edgeMark('local')).toBe('failed')
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it('does not mark Pods "blocked by an earlier failure" for the vantage that got through', () => {
|
|
815
|
+
// deliveryBlocked previously followed the merged outcome, so a working
|
|
816
|
+
// vantage's Pods were labelled as never-reached.
|
|
817
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: true })])
|
|
818
|
+
const g = buildGraph({ trace: t, route: disagreeing(), origin: pick(t, 'incluster') })
|
|
819
|
+
const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
|
|
820
|
+
expect(rows[0].mark).not.toBe('blocked')
|
|
821
|
+
})
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
describe('one route\'s evidence never spreads across sibling backends', () => {
|
|
825
|
+
// A multi-backend Route: two backends, each with its own Pods hop - the shape
|
|
826
|
+
// the producer fan-out emits and the shape that makes cross-attribution
|
|
827
|
+
// possible in the first place.
|
|
828
|
+
const multiBackend = (): Trace => ({
|
|
829
|
+
subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
|
|
830
|
+
verdict: 'degraded',
|
|
831
|
+
brokenAt: -1,
|
|
832
|
+
upstreams: [{ resource: { kind: 'Gateway', name: 'gw', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
|
|
833
|
+
downstream: [
|
|
834
|
+
{ resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' }, edge: 'entry:HTTPRoute', findings: [] },
|
|
835
|
+
{ resource: { kind: 'Service', name: 'web', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
|
|
836
|
+
{
|
|
837
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
838
|
+
edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
|
|
839
|
+
config: { pods: [pod('w1', true, '10.0.0.1')], podTotal: 1 },
|
|
840
|
+
},
|
|
841
|
+
{ resource: { kind: 'Service', name: 'api', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.2' } },
|
|
842
|
+
{
|
|
843
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
844
|
+
edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
|
|
845
|
+
config: { pods: [pod('a1', true, '10.0.0.2')], podTotal: 1 },
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
const brokenOnWeb = (): RouteResult => ({
|
|
851
|
+
route: 'shop.example.com/web', target: 'web:80', targetNamespace: 'store',
|
|
852
|
+
outcome: 'unreachable', confidence: 'real',
|
|
853
|
+
byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
// The producer establishes a boundary for ONE backend. Painting it on every
|
|
857
|
+
// Service->Pods edge condemns a sibling whose pods were never probed.
|
|
858
|
+
it('paints "breaks here" only on the branch that established it', () => {
|
|
859
|
+
const t = multiBackend()
|
|
860
|
+
const g = buildGraph({ trace: t, route: brokenOnWeb(), origin: pick(t, 'incluster') })
|
|
861
|
+
const broken = g.edges.filter((e) => e.label === 'breaks here')
|
|
862
|
+
expect(broken).toHaveLength(1)
|
|
863
|
+
expect(broken[0].id).toBe('e:n:Service/store/web::pods')
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
// Same leak, other edge: a bypassing origin dials the route's backend, not
|
|
867
|
+
// every backend the Route happens to declare.
|
|
868
|
+
it('lands the bypass edge only on the selected route\'s backend', () => {
|
|
869
|
+
const t = multiBackend()
|
|
870
|
+
const g = buildGraph({ trace: t, route: brokenOnWeb(), origin: pick(t, 'incluster') })
|
|
871
|
+
const dialled = g.edges.filter((e) => e.label === 'dialled directly')
|
|
872
|
+
expect(dialled).toHaveLength(1)
|
|
873
|
+
expect(dialled[0].id).toBe('e:origin-n:Service/store/web')
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it('colours nothing when the owning branch cannot be identified', () => {
|
|
877
|
+
const t = multiBackend()
|
|
878
|
+
const orphan: RouteResult = {
|
|
879
|
+
route: 'shop.example.com/gone', target: 'gone:80', targetNamespace: 'store',
|
|
880
|
+
outcome: 'unreachable', confidence: 'real',
|
|
881
|
+
byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
|
|
882
|
+
}
|
|
883
|
+
const g = buildGraph({ trace: t, route: orphan, origin: pick(t, 'incluster') })
|
|
884
|
+
expect(g.edges.some((e) => e.label === 'breaks here')).toBe(false)
|
|
885
|
+
})
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
describe('evidence written for a hover is shortened for a pill', () => {
|
|
889
|
+
// The producer writes full explanations. Rendered whole, the edge pill
|
|
890
|
+
// truncated mid-word to "Connectior refused...." and the pod row ran outside
|
|
891
|
+
// its node box.
|
|
892
|
+
it('keeps only the first sentence', () => {
|
|
893
|
+
expect(shortEvidence('Connection refused. Nothing is listening on the port.')).toBe('Connection refused')
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it('drops a trailing full stop', () => {
|
|
897
|
+
expect(shortEvidence('Connection refused.')).toBe('Connection refused')
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
// "·" separates parts of ONE fact, not sentences - cutting there would throw
|
|
901
|
+
// away the latency that makes the row worth reading.
|
|
902
|
+
it('leaves a dot-separated detail intact', () => {
|
|
903
|
+
expect(shortEvidence('HTTP 200 · 41 ms')).toBe('HTTP 200 · 41 ms')
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
it('leaves a decimal point alone', () => {
|
|
907
|
+
expect(shortEvidence('slow · 1.4 s')).toBe('slow · 1.4 s')
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it('is safe on empty input', () => {
|
|
911
|
+
expect(shortEvidence(undefined)).toBe('')
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('shortens the edge pill but keeps the full text on the hover', () => {
|
|
915
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: false, tone: 'unhealthy' })])
|
|
916
|
+
const long = 'Connection refused. Nothing is listening on the port.'
|
|
917
|
+
const g = buildGraph({
|
|
918
|
+
trace: t,
|
|
919
|
+
route: route({ outcome: 'unreachable', confidence: 'real', evidence: long }),
|
|
920
|
+
origin: pick(t, 'incluster'),
|
|
921
|
+
})
|
|
922
|
+
const edge = g.edges.find((e) => e.id === 'e:origin-subject')!
|
|
923
|
+
expect(edge.label).toBe('Connection refused')
|
|
924
|
+
// The explanation is moved to the hover, not thrown away.
|
|
925
|
+
expect(edge.title).toBe(long)
|
|
926
|
+
})
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
describe('the workload behind the Service appears in the graph', () => {
|
|
930
|
+
// The PRODUCER resolves the workload from the Pods' owner chain and sends it
|
|
931
|
+
// as the pods hop's config.workload. The UI never substitutes the workload
|
|
932
|
+
// the reader happened to open: a nil from the producer means the Pods have no
|
|
933
|
+
// single owner, and drawing the opened workload there would claim another
|
|
934
|
+
// workload's Pods for it.
|
|
935
|
+
const traceWithWorkload = (name = 'external-secrets-webhook') => {
|
|
936
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
937
|
+
t.downstream![1].config!.workload = { kind: 'Deployment', name, namespace: 'store' }
|
|
938
|
+
return t
|
|
939
|
+
}
|
|
940
|
+
const withWorkload = () => {
|
|
941
|
+
const t = traceWithWorkload()
|
|
942
|
+
return buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
it('draws it between the Service and its Pods', () => {
|
|
946
|
+
const g = withWorkload()
|
|
947
|
+
const svc = g.nodes.find((n) => n.kind === 'SERVICE')!
|
|
948
|
+
const wl = g.nodes.find((n) => n.kind === 'DEPLOYMENT')!
|
|
949
|
+
const pods = g.nodes.find((n) => n.kind === 'PODS')!
|
|
950
|
+
expect(wl.name).toBe('external-secrets-webhook')
|
|
951
|
+
expect(svc.x).toBeLessThan(wl.x)
|
|
952
|
+
expect(wl.x).toBeLessThan(pods.x)
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
// It routes nothing, so neither of its edges may claim observed traffic.
|
|
956
|
+
it('declares both its edges rather than claiming traffic', () => {
|
|
957
|
+
const g = withWorkload()
|
|
958
|
+
const into = g.edges.find((e) => e.id.endsWith('-workload'))!
|
|
959
|
+
const outOf = g.edges.find((e) => e.label === 'runs')!
|
|
960
|
+
expect(into.mark).toBe('config')
|
|
961
|
+
expect(outOf.mark).toBe('config')
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
// The producer localises the break to the SERVICE's own routing. Inserting a
|
|
965
|
+
// node after the Service must not slide that blame onto the workload, which
|
|
966
|
+
// routes nothing.
|
|
967
|
+
it('a Service-routing break is a boundary span, and no node is blamed', () => {
|
|
968
|
+
const t = traceWithWorkload('web')
|
|
969
|
+
const broken = route({
|
|
970
|
+
outcome: 'unreachable',
|
|
971
|
+
confidence: 'real',
|
|
972
|
+
byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
|
|
973
|
+
})
|
|
974
|
+
const g = buildGraph({
|
|
975
|
+
trace: t,
|
|
976
|
+
route: broken,
|
|
977
|
+
origin: pick(t, 'incluster'),
|
|
978
|
+
})
|
|
979
|
+
// One observed break on the edge leaving the Service...
|
|
980
|
+
const breaks = g.edges.filter((e) => e.label === 'breaks here')
|
|
981
|
+
expect(breaks).toHaveLength(1)
|
|
982
|
+
expect(breaks[0].id).toMatch(/-workload$/)
|
|
983
|
+
expect(breaks[0].boundary).toBe('start')
|
|
984
|
+
// ...continued as the SAME break across the span the workload sits inside -
|
|
985
|
+
// failed colour, marked continuation, no pill (empty label), never a second
|
|
986
|
+
// failed observation.
|
|
987
|
+
const cont = g.edges.find((e) => e.boundary === 'continuation')!
|
|
988
|
+
expect(cont.mark).toBe('failed')
|
|
989
|
+
expect(cont.label).toBe('')
|
|
990
|
+
// The break anchors to the Service's EXIT. A halo may sit on the Service
|
|
991
|
+
// itself (the route edge INTO it failed) but never on the workload or the
|
|
992
|
+
// Pods - that would blame a node for its segment's failure.
|
|
993
|
+
expect(g.breakAtExitOf).toBe('n:Service/store/shop')
|
|
994
|
+
expect([undefined, g.breakAtExitOf]).toContain(g.breakNodeId)
|
|
995
|
+
expect(g.breakNodeId).not.toBe('n:workload')
|
|
996
|
+
expect(g.nonNetworkNodeIds).toContain('n:workload')
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
it('is absent when no workload is in scope', () => {
|
|
1000
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
1001
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
1002
|
+
expect(g.nodes.some((n) => n.kind === 'DEPLOYMENT')).toBe(false)
|
|
1003
|
+
// The count lives on the name line; the sub stays silent when it would
|
|
1004
|
+
// only repeat it.
|
|
1005
|
+
expect(g.nodes.find((n) => n.kind === 'PODS')!.name).toMatch(/eligible/)
|
|
1006
|
+
})
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
describe('only the vantage being exercised reads as running', () => {
|
|
1010
|
+
// The running flag used to reach every origin, so one probe run animated all
|
|
1011
|
+
// of them - the same "one vantage's state under another's name" this view
|
|
1012
|
+
// exists to prevent, in motion.
|
|
1013
|
+
const t = () => trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true })])
|
|
1014
|
+
|
|
1015
|
+
it('marks the in-cluster edge running and leaves the others alone', () => {
|
|
1016
|
+
const tr = t()
|
|
1017
|
+
const g = buildGraph({
|
|
1018
|
+
trace: tr,
|
|
1019
|
+
route: route(),
|
|
1020
|
+
origin: pick(tr, 'incluster'),
|
|
1021
|
+
origins: buildOrigins(tr),
|
|
1022
|
+
running: true,
|
|
1023
|
+
})
|
|
1024
|
+
const originEdges = g.edges.filter((e) => e.id.startsWith('e:origin'))
|
|
1025
|
+
expect(originEdges.some((e) => e.mark === 'running')).toBe(true)
|
|
1026
|
+
// The laptop probed and succeeded; a run elsewhere must not repaint it.
|
|
1027
|
+
const local = g.edges.find((e) => e.id.includes('local'))
|
|
1028
|
+
if (local) expect(local.mark).not.toBe('running')
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
it('marks nothing running when no test is in flight', () => {
|
|
1032
|
+
const tr = t()
|
|
1033
|
+
const g = buildGraph({ trace: tr, route: route(), origin: pick(tr, 'incluster'), origins: buildOrigins(tr) })
|
|
1034
|
+
expect(g.edges.some((e) => e.mark === 'running')).toBe(false)
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
describe('a finding on a node is a headline, not the whole message', () => {
|
|
1039
|
+
// Rendered in full this was nine lines inside a graph node, burying the path
|
|
1040
|
+
// the node sits on.
|
|
1041
|
+
const gatewayMsg =
|
|
1042
|
+
"Accepted: NoMatchingListenerHostname - Gateway primary-gateway-system/primary-gateway listeners http, https: There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s)."
|
|
1043
|
+
|
|
1044
|
+
it('translates a known condition reason into plain language', () => {
|
|
1045
|
+
// "Accepted: NoMatchingListenerHostname" is precise and user-hostile - it
|
|
1046
|
+
// reads as if the route WAS accepted. Known Gateway API reasons get plain
|
|
1047
|
+
// words; the raw message stays on the hover.
|
|
1048
|
+
expect(noteHeadline(gatewayMsg)).toBe('Not attached: no listener matches its hosts')
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
// A colon usually introduces the very thing worth naming, so it is not a cut
|
|
1052
|
+
// point - cutting there would leave a bare "Accepted". An UNKNOWN reason
|
|
1053
|
+
// passes through untranslated rather than being guessed at.
|
|
1054
|
+
it('does not cut at a colon, and passes unknown reasons through', () => {
|
|
1055
|
+
expect(noteHeadline('Accepted: SomeVendorSpecificReason')).toBe('Accepted: SomeVendorSpecificReason')
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('cuts at a sentence break too', () => {
|
|
1059
|
+
expect(noteHeadline('Connection refused. Nothing is listening on the port.')).toBe('Connection refused')
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
it('truncates a headline with no break at all', () => {
|
|
1063
|
+
const out = noteHeadline('x'.repeat(120))
|
|
1064
|
+
expect(out.length).toBeLessThanOrEqual(46)
|
|
1065
|
+
expect(out.endsWith('…')).toBe(true)
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
it('leaves a short message alone', () => {
|
|
1069
|
+
expect(noteHeadline('No ready endpoints')).toBe('No ready endpoints')
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('puts the whole message on the hover', () => {
|
|
1073
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
1074
|
+
t.downstream[0].findings = [{ code: 'gw:x', severity: 'warning', message: gatewayMsg }]
|
|
1075
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
1076
|
+
const note = g.nodes.find((n) => n.kind === 'SERVICE')!.notes![0]
|
|
1077
|
+
expect(note.text).toBe('Not attached: no listener matches its hosts')
|
|
1078
|
+
expect(note.detail).toBe(gatewayMsg)
|
|
1079
|
+
})
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
describe('the selected vantage is a real scope boundary', () => {
|
|
1083
|
+
// Every drawn vantage contributes edges. Reading the break off all of them let
|
|
1084
|
+
// a workstation failure order the report while the in-cluster vantage the
|
|
1085
|
+
// reader had selected was succeeding.
|
|
1086
|
+
it('does not take the break from another vantage\'s failed edge', () => {
|
|
1087
|
+
const t = trace([pod('a', true, '10.0.0.1')], [
|
|
1088
|
+
p({ vantage: 'in-cluster', path: 'data', ok: true, tone: 'healthy' }),
|
|
1089
|
+
p({ vantage: 'local', path: 'data', ok: false, tone: 'unhealthy' }),
|
|
1090
|
+
])
|
|
1091
|
+
const good = route({
|
|
1092
|
+
outcome: 'verified',
|
|
1093
|
+
confidence: 'real',
|
|
1094
|
+
byVantage: [
|
|
1095
|
+
{ vantage: 'in-cluster', path: 'data', outcome: 'verified' },
|
|
1096
|
+
{ vantage: 'local', path: 'data', outcome: 'unreachable' },
|
|
1097
|
+
],
|
|
1098
|
+
})
|
|
1099
|
+
const g = buildGraph({ trace: t, route: good, origin: pick(t, 'incluster'), origins: buildOrigins(t) })
|
|
1100
|
+
expect(g.breakNodeId).toBeUndefined()
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
it('still finds the break on the selected vantage\'s own edge', () => {
|
|
1104
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: false, tone: 'unhealthy' })])
|
|
1105
|
+
const bad = route({ outcome: 'unreachable', confidence: 'real' })
|
|
1106
|
+
const g = buildGraph({ trace: t, route: bad, origin: pick(t, 'incluster'), origins: buildOrigins(t) })
|
|
1107
|
+
expect(g.breakNodeId).toBeDefined()
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
// Sorting rendered nodes by position turned PARALLEL backends into what read
|
|
1111
|
+
// as a serial chain, and swept in branches the route never touches.
|
|
1112
|
+
it('reports the route\'s own chain in traversal order - the workload is display, not journey', () => {
|
|
1113
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
1114
|
+
t.downstream![1].config!.workload = { kind: 'Deployment', name: 'web', namespace: 'store' }
|
|
1115
|
+
const g = buildGraph({
|
|
1116
|
+
trace: t,
|
|
1117
|
+
route: route(),
|
|
1118
|
+
origin: pick(t, 'incluster'),
|
|
1119
|
+
})
|
|
1120
|
+
const kinds = g.pathNodeIds.map((id) => g.nodes.find((n) => n.id === id)!.kind)
|
|
1121
|
+
// The Deployment renders inline but is NOT a hop the request traverses -
|
|
1122
|
+
// it rides in `interleave` for display order instead.
|
|
1123
|
+
expect(kinds).toEqual(['SERVICE', 'PODS'])
|
|
1124
|
+
expect(g.interleave).toEqual([{ id: 'n:workload', afterId: 'n:Service/store/shop' }])
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
it('emits no duplicates and only nodes that exist', () => {
|
|
1128
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
1129
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
1130
|
+
expect(new Set(g.pathNodeIds).size).toBe(g.pathNodeIds.length)
|
|
1131
|
+
for (const id of g.pathNodeIds) expect(g.nodes.some((n) => n.id === id)).toBe(true)
|
|
1132
|
+
})
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
describe('only the relay may downgrade a proved result', () => {
|
|
1136
|
+
// A laptop is outside the cluster, but a request it sends to a real address
|
|
1137
|
+
// travels the real network - "proxied" means "bypassed the dataplane", which
|
|
1138
|
+
// is a claim about the API-server relay and nothing else.
|
|
1139
|
+
it('keeps a laptop success as proved', () => {
|
|
1140
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true, tone: 'healthy' })])
|
|
1141
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'local') })
|
|
1142
|
+
expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proved')
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
it('still downgrades the API-server relay', () => {
|
|
1146
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', ok: true, tone: 'healthy' })])
|
|
1147
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'apiserver') })
|
|
1148
|
+
expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proxied')
|
|
1149
|
+
})
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
// Re-derives an edge's cubic from its rendered path so a test can walk the
|
|
1153
|
+
// SAME curve the browser draws, rather than trusting a single reported point.
|
|
1154
|
+
function edgeCubic(e: { d: string }) {
|
|
1155
|
+
const m = e.d.match(/M([\d.-]+),([\d.-]+) C([\d.-]+),([\d.-]+) ([\d.-]+),([\d.-]+) ([\d.-]+),([\d.-]+)/)!
|
|
1156
|
+
const n = m.slice(1).map(Number)
|
|
1157
|
+
return { p0: [n[0], n[1]], p1: [n[2], n[3]], p2: [n[4], n[5]], p3: [n[6], n[7]] } as const
|
|
1158
|
+
}
|
|
1159
|
+
function cubicPoint(c: ReturnType<typeof edgeCubic>, t: number) {
|
|
1160
|
+
const u = 1 - t
|
|
1161
|
+
const f = (a: number, b: number, cc: number, d: number) => u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * cc + t * t * t * d
|
|
1162
|
+
return { x: f(c.p0[0], c.p1[0], c.p2[0], c.p3[0]), y: f(c.p0[1], c.p1[1], c.p2[1], c.p3[1]) }
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
describe('an edge that skips a column routes around it', () => {
|
|
1166
|
+
// The in-cluster probe bypasses an HTTPRoute, so its edge spans from the
|
|
1167
|
+
// origin to the BACKEND - straight through the Route node sitting between
|
|
1168
|
+
// them, parking its pill on top of the very node it does not touch.
|
|
1169
|
+
const routeTrace = (): Trace => ({
|
|
1170
|
+
subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
|
|
1171
|
+
verdict: 'healthy',
|
|
1172
|
+
brokenAt: -1,
|
|
1173
|
+
upstreams: [{ resource: { kind: 'Gateway', name: 'gw', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
|
|
1174
|
+
downstream: [
|
|
1175
|
+
{
|
|
1176
|
+
resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
|
|
1177
|
+
edge: 'entry:HTTPRoute',
|
|
1178
|
+
// A REAL route carries findings, which make the node tall. A bare node
|
|
1179
|
+
// is short enough that a too-shallow detour still clears it - which is
|
|
1180
|
+
// exactly how a broken detour passed its first test.
|
|
1181
|
+
findings: [
|
|
1182
|
+
{
|
|
1183
|
+
code: 'gw:no-listener',
|
|
1184
|
+
severity: 'warning',
|
|
1185
|
+
message:
|
|
1186
|
+
"Accepted: NoMatchingListenerHostname - Gateway primary-gateway-system/primary-gateway listeners http, https: There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s).",
|
|
1187
|
+
},
|
|
1188
|
+
],
|
|
1189
|
+
},
|
|
1190
|
+
{ resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
|
|
1191
|
+
{
|
|
1192
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
1193
|
+
edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
|
|
1194
|
+
config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
|
|
1195
|
+
},
|
|
1196
|
+
],
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
const bypassEdge = () => {
|
|
1200
|
+
const t = routeTrace()
|
|
1201
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster'), origins: buildOrigins(t) })
|
|
1202
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
|
|
1203
|
+
return { g, e }
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
it('clears the node it passes, instead of crossing it', () => {
|
|
1207
|
+
const { g, e } = bypassEdge()
|
|
1208
|
+
const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
|
|
1209
|
+
// The pill rides the curve, so its height is a fair probe of where the line
|
|
1210
|
+
// actually goes at the point it crosses that column.
|
|
1211
|
+
expect(e.py).toBeGreaterThan(routeNode.y + routeNode.h)
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
// The obstacle's HEIGHT is what broke the first attempt: a cubic reaches only
|
|
1215
|
+
// ~3/4 of the way to its control points, so clearance has to be solved for,
|
|
1216
|
+
// not assumed.
|
|
1217
|
+
it('clears a tall node by as much as a short one', () => {
|
|
1218
|
+
const { g, e } = bypassEdge()
|
|
1219
|
+
const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
|
|
1220
|
+
expect(routeNode.h).toBeGreaterThan(90)
|
|
1221
|
+
expect(e.py - (routeNode.y + routeNode.h)).toBeGreaterThan(8)
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
// The API-server capsule sits in the control lane, well ABOVE the route it
|
|
1225
|
+
// bypasses. Forcing every detour downward sent it swooping under the node and
|
|
1226
|
+
// back up to the Service - longer, and it reads as a detour to nowhere.
|
|
1227
|
+
it('routes an edge coming from above OVER the node, not under it', () => {
|
|
1228
|
+
const t = routeTrace()
|
|
1229
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver'), origins: buildOrigins(t) })
|
|
1230
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
|
|
1231
|
+
const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
|
|
1232
|
+
expect(e.py).toBeLessThan(routeNode.y)
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('still routes an edge coming from below UNDER the node', () => {
|
|
1236
|
+
const { g, e } = bypassEdge()
|
|
1237
|
+
const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
|
|
1238
|
+
expect(e.py).toBeGreaterThan(routeNode.y + routeNode.h)
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
// The pill rides the curve's midpoint, so it can sit clear while the LINE
|
|
1242
|
+
// still grazes the node's far corner - an asymmetric edge has already
|
|
1243
|
+
// descended by the time it crosses there. Sample the whole span.
|
|
1244
|
+
it('clears the node along its entire width, not just at the midpoint', () => {
|
|
1245
|
+
const t = routeTrace()
|
|
1246
|
+
for (const originId of ['apiserver', 'incluster'] as const) {
|
|
1247
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId), origins: buildOrigins(t) })
|
|
1248
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
|
|
1249
|
+
const n = g.nodes.find((x) => x.kind === 'HTTPROUTE')!
|
|
1250
|
+
const c = edgeCubic(e)
|
|
1251
|
+
for (let i = 1; i < 20; i++) {
|
|
1252
|
+
const pt = cubicPoint(c, i / 20)
|
|
1253
|
+
if (pt.x < n.x || pt.x > n.x + n.w) continue
|
|
1254
|
+
const clears = pt.y < n.y || pt.y > n.y + n.h
|
|
1255
|
+
expect(clears).toBe(true)
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
it('reserves canvas height for the detour', () => {
|
|
1261
|
+
const { g, e } = bypassEdge()
|
|
1262
|
+
expect(g.canvas.h).toBeGreaterThanOrEqual(e.py)
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
// Only edges that actually skip something detour; an ordinary hop-to-hop edge
|
|
1266
|
+
// must stay where it was.
|
|
1267
|
+
it('leaves an adjacent-column edge alone', () => {
|
|
1268
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
1269
|
+
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
1270
|
+
const e = g.edges.find((x) => x.label === 'selects')!
|
|
1271
|
+
const pods = g.nodes.find((n) => n.kind === 'PODS')!
|
|
1272
|
+
expect(e.py).toBeLessThan(pods.y + pods.h)
|
|
1273
|
+
})
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
describe('an entry hop reports its own result, not the route rollup', () => {
|
|
1277
|
+
// The route is built from the BACKEND's probes. A laptop that dialled a public
|
|
1278
|
+
// Ingress and got an answer therefore had no route row at all, and the entry
|
|
1279
|
+
// edge - drawn from the rollup - read "not tested" while the trace held six
|
|
1280
|
+
// successful dials against that very Ingress.
|
|
1281
|
+
const withIngress = (): Trace => ({
|
|
1282
|
+
subject: { kind: 'Service', name: 'web', namespace: 'prod' },
|
|
1283
|
+
verdict: 'healthy',
|
|
1284
|
+
brokenAt: -1,
|
|
1285
|
+
upstreams: [
|
|
1286
|
+
{
|
|
1287
|
+
resource: { kind: 'Ingress', name: 'web', namespace: 'prod' },
|
|
1288
|
+
edge: 'ingress->service',
|
|
1289
|
+
findings: [],
|
|
1290
|
+
config: { hostnames: ['web.example.com'], addresses: ['34.23.121.209'] },
|
|
1291
|
+
probes: [
|
|
1292
|
+
{ layer: 'tcp', target: '34.23.121.209:443', vantage: 'local', ok: true },
|
|
1293
|
+
{ layer: 'http', target: 'web.example.com', vantage: 'local', ok: true, tone: 'reached', detail: 'HTTP 404 · reached' },
|
|
1294
|
+
],
|
|
1295
|
+
},
|
|
1296
|
+
],
|
|
1297
|
+
downstream: [
|
|
1298
|
+
{
|
|
1299
|
+
resource: { kind: 'Service', name: 'web', namespace: 'prod' },
|
|
1300
|
+
edge: 'service',
|
|
1301
|
+
findings: [],
|
|
1302
|
+
config: { clusterIP: '10.96.0.1' },
|
|
1303
|
+
// From a laptop the ClusterIP is only reachable through the relay.
|
|
1304
|
+
probes: [{ layer: 'http', target: 'web:80', vantage: 'local', path: 'apiserver', ok: true, tone: 'healthy', detail: 'HTTP 404' }],
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
resource: { kind: 'Pods', name: '', namespace: 'prod' },
|
|
1308
|
+
edge: 'service->pods', findings: [], meta: { ready: 1, selected: 1 },
|
|
1309
|
+
config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
// The route carries only the relayed backend result, so it has no local/data row.
|
|
1315
|
+
const routeViaRelay = () =>
|
|
1316
|
+
route({
|
|
1317
|
+
outcome: 'reached',
|
|
1318
|
+
confidence: 'indirect',
|
|
1319
|
+
byVantage: [{ vantage: 'local', path: 'apiserver', outcome: 'reached' }],
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
it('marks the entry edge from the entry\'s own dials', () => {
|
|
1323
|
+
const t = withIngress()
|
|
1324
|
+
const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
|
|
1325
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:Ingress/prod/web')!
|
|
1326
|
+
// The dial ANSWERED with a 404 (tone 'reached') - real evidence the entry
|
|
1327
|
+
// serves, not a clean pass. It must not draw the same solid green as a 2xx:
|
|
1328
|
+
// that painted 'HTTP 404 · reached' as healthy at a glance.
|
|
1329
|
+
expect(e.mark).toBe('answered')
|
|
1330
|
+
expect(e.label).not.toMatch(/not tested/i)
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
it('does not credit the entry to a vantage that never dialled it', () => {
|
|
1334
|
+
const t = withIngress()
|
|
1335
|
+
const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'incluster'), origins: buildOrigins(t) })
|
|
1336
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:Ingress/prod/web')
|
|
1337
|
+
if (e) expect(e.mark).not.toBe('proved')
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
// A proved glyph beside the words "not tested" is the contradiction this
|
|
1341
|
+
// whole change exists to remove; the pair must always agree.
|
|
1342
|
+
it('never pairs an evidence glyph with the words not tested', () => {
|
|
1343
|
+
const t = withIngress()
|
|
1344
|
+
const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
|
|
1345
|
+
const evidence: Mark[] = ['proved', 'answered', 'proxied', 'failed', 'slow']
|
|
1346
|
+
for (const e of g.edges) {
|
|
1347
|
+
if (evidence.includes(e.mark)) expect(e.label).not.toMatch(/^not tested$/i)
|
|
1348
|
+
}
|
|
1349
|
+
const capsule = g.nodes.find((n) => n.isOrigin)!
|
|
1350
|
+
const verdict = capsule.anomalies?.[0]
|
|
1351
|
+
if (verdict && evidence.includes(verdict.mark)) expect(verdict.text).not.toMatch(/^not tested$/i)
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
// A hop this vantage never dialled is untested AT THAT HOP, whatever it
|
|
1355
|
+
// managed elsewhere - an HTTPRoute has no address to dial at all.
|
|
1356
|
+
it('marks an undialled hop untested even when the vantage reached others', () => {
|
|
1357
|
+
const t = withIngress()
|
|
1358
|
+
t.upstreams.push({
|
|
1359
|
+
resource: { kind: 'HTTPRoute', name: 'web', namespace: 'prod' },
|
|
1360
|
+
edge: 'route->service',
|
|
1361
|
+
findings: [],
|
|
1362
|
+
config: { hostnames: ['web.example.com'] },
|
|
1363
|
+
probes: [{ layer: 'tcp', target: '', vantage: 'local', ok: false, skipped: true, reason: 'route has no own address' }],
|
|
1364
|
+
})
|
|
1365
|
+
const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
|
|
1366
|
+
const e = g.edges.find((x) => x.id === 'e:origin-n:HTTPRoute/prod/web')!
|
|
1367
|
+
expect(e.mark).toBe('untested')
|
|
1368
|
+
expect(e.label).toMatch(/not tested/i)
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
it('prefers the most specific layer that answered', () => {
|
|
1372
|
+
const t = withIngress()
|
|
1373
|
+
const ev = hopEvidenceFor(t.upstreams[0], buildOrigins(t).find((o) => o.id === 'local')!)!
|
|
1374
|
+
expect(ev.title).toMatch(/HTTP 404/)
|
|
1375
|
+
})
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
describe('an all-skipped origin never claims a network verdict', () => {
|
|
1379
|
+
it('capsule and edge share one label, and it is not "not routable"', () => {
|
|
1380
|
+
const o = { id: 'apiserver', mark: 'blocked' } as never
|
|
1381
|
+
expect(originNoEvidenceLabel(o)).toBe('couldn’t test')
|
|
1382
|
+
const trace = {
|
|
1383
|
+
subject: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
1384
|
+
verdict: 'unknown',
|
|
1385
|
+
brokenAt: -1,
|
|
1386
|
+
upstreams: [],
|
|
1387
|
+
routes: [],
|
|
1388
|
+
downstream: [
|
|
1389
|
+
{
|
|
1390
|
+
resource: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
1391
|
+
edge: 'service',
|
|
1392
|
+
findings: [],
|
|
1393
|
+
probes: [{ layer: 'http', target: 'shop:443', vantage: 'local', path: 'apiserver', ok: false, skipped: true, reason: 'x' }],
|
|
1394
|
+
},
|
|
1395
|
+
],
|
|
1396
|
+
} as never
|
|
1397
|
+
const ev = originEntryEvidence(trace, { route: 'r', target: 'shop:443', outcome: 'not-tested' } as never, {
|
|
1398
|
+
id: 'apiserver',
|
|
1399
|
+
mark: 'blocked',
|
|
1400
|
+
lane: 'control',
|
|
1401
|
+
} as never)
|
|
1402
|
+
expect(ev.label).toBe('couldn’t test')
|
|
1403
|
+
})
|
|
1404
|
+
it('an attempted in-cluster run that could not start keeps its own words', () => {
|
|
1405
|
+
expect(originNoEvidenceLabel({ id: 'incluster', mark: 'blocked', unavailable: 'image pull failed' } as never)).toBe('test couldn’t run')
|
|
1406
|
+
})
|
|
1407
|
+
})
|