@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,892 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildSidebar, buildVerdict } from './reachInspector'
|
|
3
|
+
import { buildGraph } from './reachGraphModel'
|
|
4
|
+
import { buildOrigins } from './reachOrigins'
|
|
5
|
+
import type { Trace, RouteResult, ProbeResult, PodStatus } from './types'
|
|
6
|
+
|
|
7
|
+
const p = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: '10.0.0.1:8080', vantage: 'in-cluster', path: 'data', ok: true, ...o })
|
|
8
|
+
const pod = (name: string, ready: boolean, ip: string, reason?: string): PodStatus => ({ name, ready, ip, reason })
|
|
9
|
+
|
|
10
|
+
function mk(pods: PodStatus[], probes: ProbeResult[]): Trace {
|
|
11
|
+
return {
|
|
12
|
+
subject: { kind: 'Service', name: 'shop', namespace: 'store' },
|
|
13
|
+
verdict: 'healthy',
|
|
14
|
+
brokenAt: -1,
|
|
15
|
+
upstreams: [],
|
|
16
|
+
// The producer attaches a concrete request to every probed route; without
|
|
17
|
+
// one the server has nothing to send, so a fixture that omits it is not a
|
|
18
|
+
// realistic trace.
|
|
19
|
+
routes: [{ route: 'GET /', target: 'shop:80', outcome: 'verified', confidence: 'real', inClusterRequest: { scheme: 'http', path: '/' } }],
|
|
20
|
+
downstream: [
|
|
21
|
+
{ resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [], config: { clusterIP: '10.96.0.1', selector: { app: 'shop' } } },
|
|
22
|
+
{
|
|
23
|
+
resource: { kind: 'Pods', name: '', namespace: 'store' },
|
|
24
|
+
edge: 'service->pods',
|
|
25
|
+
findings: [],
|
|
26
|
+
meta: { ready: pods.filter((x) => x.ready).length, selected: pods.length },
|
|
27
|
+
config: { pods, podTotal: pods.length },
|
|
28
|
+
probes,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const route = (o: Partial<RouteResult> = {}): RouteResult => ({ route: 'GET /', target: ':80 → 8080', outcome: 'verified', confidence: 'real', ...o })
|
|
35
|
+
|
|
36
|
+
// Pods nodes are parent-scoped so multi-backend routes keep a group each, so
|
|
37
|
+
// tests address them by role rather than by a fixed id.
|
|
38
|
+
function podsNodeId(t: Trace): string {
|
|
39
|
+
const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
|
|
40
|
+
return g.nodes.find((n) => n.kind === 'PODS')!.id
|
|
41
|
+
}
|
|
42
|
+
function podsEdgeId(t: Trace): string {
|
|
43
|
+
const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
|
|
44
|
+
return g.edges.find((e) => e.label === 'selects')!.id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ctx(t: Trace, originId: string, r = route()) {
|
|
48
|
+
const origins = buildOrigins(t)
|
|
49
|
+
const origin = origins.find((o) => o.id === originId)!
|
|
50
|
+
const g = buildGraph({ trace: t, route: r, origin })
|
|
51
|
+
return {
|
|
52
|
+
trace: t,
|
|
53
|
+
route: r,
|
|
54
|
+
origin,
|
|
55
|
+
origins,
|
|
56
|
+
nodes: g.nodes,
|
|
57
|
+
breakNodeId: g.breakNodeId,
|
|
58
|
+
breakAtExitOf: g.breakAtExitOf,
|
|
59
|
+
nonNetworkNodeIds: g.nonNetworkNodeIds,
|
|
60
|
+
contextNodeIds: g.contextNodeIds,
|
|
61
|
+
interleave: g.interleave,
|
|
62
|
+
entryParallelCount: g.entryParallelCount,
|
|
63
|
+
journeyEntryNodeIds: g.journeyEntryNodeIds,
|
|
64
|
+
pathNodeIds: g.pathNodeIds,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('the diagnosis is always present', () => {
|
|
69
|
+
// The reason the tab exists is "did traffic get through". That must never
|
|
70
|
+
// require a click, so the path section is computed regardless of selection.
|
|
71
|
+
it('answers the path question with nothing selected', () => {
|
|
72
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
73
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster'))
|
|
74
|
+
expect(s.path.title).toBeTruthy()
|
|
75
|
+
expect(s.path.evidence.length).toBeGreaterThan(0)
|
|
76
|
+
// The panel always answers the path question; a next-step block is offered
|
|
77
|
+
// only when there is genuinely a next step.
|
|
78
|
+
expect(s.path.body).toBeTruthy()
|
|
79
|
+
expect(s.resource).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Every hop is reported whether or not anything was clicked; a selection only
|
|
83
|
+
// changes which one is open. Understanding a path used to take three clicks.
|
|
84
|
+
it('reports the whole path without any selection, and a click only opens one', () => {
|
|
85
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
86
|
+
const c = ctx(t, 'incluster')
|
|
87
|
+
const before = buildSidebar(undefined, c)
|
|
88
|
+
expect(before.hops.map((h) => h.kind)).toContain('PODS')
|
|
89
|
+
const after = buildSidebar(podsNodeId(t), c)
|
|
90
|
+
expect(after.path).toEqual(before.path)
|
|
91
|
+
expect(after.hops.find((h) => h.kind === 'PODS')!.expanded).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('an apiserver result always states what it skipped', () => {
|
|
95
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
96
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver'))
|
|
97
|
+
expect(s.path.notProve.join(' ')).toMatch(/relayed|routing|network policy|mesh/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('a synthetic result always states the identity gap', () => {
|
|
101
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
102
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster'))
|
|
103
|
+
expect(s.path.notProve.join(' ')).toMatch(/not as your application|who is calling/i)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('never claims complete evidence', () => {
|
|
107
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
108
|
+
expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.length).toBeGreaterThan(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not claim a front-door gap on a resource with no entry point', () => {
|
|
112
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
113
|
+
expect(t.upstreams).toHaveLength(0)
|
|
114
|
+
expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.join(' ')).not.toMatch(/internet|outside/i)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('offers only actions that can be taken', () => {
|
|
118
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
119
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver'))
|
|
120
|
+
expect(s.path.next.ctas.every((c) => !c.disabledReason)).toBe(true)
|
|
121
|
+
expect(s.path.next.body).toMatch(/in-cluster/i)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('the RBAC prompt names the permission and copies a command that runs', () => {
|
|
125
|
+
// Every other vantage must be spent, or offering one of those would be the
|
|
126
|
+
// better next step than asking for permission.
|
|
127
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
|
|
128
|
+
const origins = buildOrigins(t, { inClusterAllowed: false })
|
|
129
|
+
const g = buildGraph({ trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')! })
|
|
130
|
+
const s = buildSidebar(undefined, { trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')!, origins, nodes: g.nodes })
|
|
131
|
+
expect(s.path.next.body).toMatch(/create.*jobs/i)
|
|
132
|
+
expect(s.path.next.ctas[0].command).toMatch(/kubectl auth can-i/)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// The graph already gates on this; the sidebar is the surface people actually
|
|
137
|
+
// read, so it must gate identically or it states another vantage's result under
|
|
138
|
+
// the selected vantage's name.
|
|
139
|
+
describe('the sidebar is scoped to the selected vantage', () => {
|
|
140
|
+
it('an unavailable vantage never inherits another vantage\'s success', () => {
|
|
141
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
142
|
+
const origins = buildOrigins(t)
|
|
143
|
+
const caller = origins.find((o) => o.id === 'caller')!
|
|
144
|
+
const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller })
|
|
145
|
+
const s = buildSidebar(undefined, { trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller, origins, nodes: g.nodes })
|
|
146
|
+
expect(s.path.body).not.toMatch(/a real request went through/i)
|
|
147
|
+
expect(s.path.evidence.every((e) => e.mark !== 'proved')).toBe(true)
|
|
148
|
+
expect(s.path.evidence.map((e) => e.text).join(' ')).toMatch(/cannot test from here/i)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('a vantage that did run still reports its result', () => {
|
|
152
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
153
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster'))
|
|
154
|
+
expect(s.path.body).toMatch(/a real request went through/i)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('a stale result does not lead with its old assertion', () => {
|
|
158
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
159
|
+
t.headline = 'Reachable in-cluster on :80'
|
|
160
|
+
const v = buildVerdict(t, route(), { stale: true })
|
|
161
|
+
expect(v.title).not.toMatch(/Reachable/)
|
|
162
|
+
expect(v.title).toMatch(/out of date/i)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('node detail is additive', () => {
|
|
167
|
+
it('a Pods node reports what is and is not taking traffic', () => {
|
|
168
|
+
const t = mk([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'readiness failing')], [p({})])
|
|
169
|
+
const r = buildSidebar(podsNodeId(t), ctx(t, 'incluster')).hops.find((h) => h.kind === 'PODS')!
|
|
170
|
+
expect(r.facts.find((x) => x.k === 'SITTING OUT')!.v).toMatch(/not ready/)
|
|
171
|
+
// Derived from readiness, so it must not claim observed delivery.
|
|
172
|
+
expect(r.facts.some((x) => x.k === 'ELIGIBLE')).toBe(true)
|
|
173
|
+
expect(r.facts.some((x) => x.k === 'TAKING TRAFFIC')).toBe(false)
|
|
174
|
+
expect(r.notProve.join(' ')).toMatch(/nothing was sent to them/)
|
|
175
|
+
expect(r.rows!.some((x) => x.mark === 'excluded')).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('a resource node carries its own config, not the path result', () => {
|
|
179
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
180
|
+
const c = ctx(t, 'incluster')
|
|
181
|
+
const svc = c.nodes.find((n) => n.kind === 'SERVICE')!
|
|
182
|
+
const r = buildSidebar(svc.id, c).hops.find((h) => h.id === svc.id)!
|
|
183
|
+
expect(r.facts.some((x) => x.k === 'CLUSTER IP')).toBe(true)
|
|
184
|
+
expect(r.openRef?.name).toBe('shop')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// A vantage is not a hop on the path - it is where the path was watched from.
|
|
188
|
+
it('the origin capsule is never a hop in the report', () => {
|
|
189
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
190
|
+
const c = ctx(t, 'incluster')
|
|
191
|
+
const cap = c.nodes.find((n) => n.isOrigin)!
|
|
192
|
+
expect(buildSidebar(cap.id, c).hops.some((h) => h.id === cap.id)).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('verdict band', () => {
|
|
197
|
+
// These rules used to be asserted through the header's fact strip. The strip
|
|
198
|
+
// is gone - every one of its four facts is now shown by the graph or the
|
|
199
|
+
// panel - so each rule is pinned where it actually renders.
|
|
200
|
+
it('reports nothing proven when no origin has run', () => {
|
|
201
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
202
|
+
const v = buildVerdict(t, route({ outcome: 'not-tested' }))
|
|
203
|
+
expect(v.tone).toBe('unknown')
|
|
204
|
+
expect(v.chipText).toMatch(/not tested/i)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('an apiserver-only pass never claims the backend was proven', () => {
|
|
208
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
209
|
+
const v = buildVerdict(t, route({ confidence: 'indirect' }))
|
|
210
|
+
expect(v.tone).toBe('degraded')
|
|
211
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
|
|
212
|
+
// It may say something is SERVING; it must also say that is not the same as
|
|
213
|
+
// the normal path working.
|
|
214
|
+
expect(s.path.body).toMatch(/serving/i)
|
|
215
|
+
expect(s.path.body).toMatch(/not that the normal path works/i)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('points the next step at something Radar can actually do', () => {
|
|
219
|
+
// The real caller is the strongest missing origin AND permanently
|
|
220
|
+
// unavailable. Offering it would give every resource the same
|
|
221
|
+
// un-actionable next step.
|
|
222
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
223
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
|
|
224
|
+
expect(s.path.next.body).toMatch(/in-cluster probe/i)
|
|
225
|
+
expect(s.path.next.header).toMatch(/run this next/i)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('offers nothing when nothing stronger can be run', () => {
|
|
229
|
+
// A resource-level "there is no more to learn" repeated on every vantage was
|
|
230
|
+
// scope mixing; the ceiling is stated with specifics in the caveats and the
|
|
231
|
+
// footer's coverage ledger instead.
|
|
232
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({}), p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
|
|
233
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster'))
|
|
234
|
+
expect(s.path.next.header).toBe('')
|
|
235
|
+
expect(s.path.next.ctas).toHaveLength(0)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('falls back to the backend verdict when there is no route to derive a tone from', () => {
|
|
239
|
+
// A config fault found without probing still has a verdict; a grey
|
|
240
|
+
// "unknown" dot there under-reports what the tracer already concluded.
|
|
241
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
242
|
+
t.verdict = 'degraded'
|
|
243
|
+
t.routes = []
|
|
244
|
+
const v = buildVerdict(t, undefined, buildOrigins(t))
|
|
245
|
+
expect(v.tone).toBe('degraded')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('leads with the named fault rather than burying it under a coverage headline', () => {
|
|
249
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
250
|
+
t.headline = 'Configuration only - not yet tested'
|
|
251
|
+
t.diagnosis = { summary: 'Accepted: NoMatchingListenerHostname - no hostname intersections' }
|
|
252
|
+
const v = buildVerdict(t, undefined, buildOrigins(t))
|
|
253
|
+
expect(v.problem).toMatch(/NoMatchingListenerHostname/)
|
|
254
|
+
// and it is not duplicated into the body
|
|
255
|
+
expect(v.body).toBe('')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('a running test is informational, never green', () => {
|
|
259
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
260
|
+
const v = buildVerdict(t, route(), { running: true })
|
|
261
|
+
expect(v.tone).toBe('info')
|
|
262
|
+
expect(v.chipText).toBe('testing')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('an in-cluster test that could not run says so first', () => {
|
|
267
|
+
// A route whose probes were all skipped never gets a concrete request, so the
|
|
268
|
+
// server answers "not supported for this subject". The panel used to
|
|
269
|
+
// recommend the run as the strongest evidence available and let the operator
|
|
270
|
+
// discover the failure by spending a Job on it.
|
|
271
|
+
const noRunnableRoute = (): Trace => {
|
|
272
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
273
|
+
t.routes = [{ route: 'port 7000', target: 'shop:7000', outcome: 'not-tested', evidence: 'port looks non-HTTP' }]
|
|
274
|
+
return t
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The button moved to the vantage capsule in the graph, so the panel must not
|
|
278
|
+
// offer a third copy of it - it keeps the reasoning instead.
|
|
279
|
+
it('offers the run, disabled, with the reason attached', () => {
|
|
280
|
+
// The action lives WITH its explanation now, so it is present even when it
|
|
281
|
+
// cannot be taken - but never silently clickable into a guaranteed no-op.
|
|
282
|
+
const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
|
|
283
|
+
const run = s.path.next.ctas.find((c) => c.action === 'run-in-cluster')
|
|
284
|
+
expect(run).toBeTruthy()
|
|
285
|
+
expect(run!.disabledReason).toMatch(/nothing to run|no path/i)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('explains it in the body rather than promising stronger evidence', () => {
|
|
289
|
+
const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
|
|
290
|
+
expect(s.path.next.body).not.toMatch(/strongest evidence/i)
|
|
291
|
+
expect(s.path.next.body).toMatch(/skipped before a request could be formed/i)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('still explains the run is available when a route carries a request', () => {
|
|
295
|
+
const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })]), 'apiserver'))
|
|
296
|
+
expect(s.path.next.body).toMatch(/strongest evidence/i)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('the report is ordered around the break', () => {
|
|
301
|
+
// The break is the answer, so it opens; what came before it worked and is
|
|
302
|
+
// context; what came after was never tried and must not read as healthy.
|
|
303
|
+
const brokenTrace = () => mk([pod('a', true, '10.0.0.1')], [p({ ok: false, tone: 'unhealthy' })])
|
|
304
|
+
const brokenRoute = () => route({ outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' })
|
|
305
|
+
|
|
306
|
+
it('opens the hop where the request stopped, not the last one', () => {
|
|
307
|
+
const t = brokenTrace()
|
|
308
|
+
const c = ctx(t, 'incluster', brokenRoute())
|
|
309
|
+
const s = buildSidebar(undefined, c)
|
|
310
|
+
expect(c.breakNodeId).toBeDefined()
|
|
311
|
+
const open = s.hops.filter((h) => h.expanded)
|
|
312
|
+
expect(open).toHaveLength(1)
|
|
313
|
+
expect(open[0].id).toBe(c.breakNodeId)
|
|
314
|
+
expect(open[0].state).toBe('break')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('opens exactly one hop when nothing is selected', () => {
|
|
318
|
+
const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
|
|
319
|
+
expect(s.hops.filter((h) => h.expanded)).toHaveLength(1)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('labels hops past the break as never tried, not as fine', () => {
|
|
323
|
+
const t = brokenTrace()
|
|
324
|
+
const c = ctx(t, 'incluster', brokenRoute())
|
|
325
|
+
const s = buildSidebar(undefined, c)
|
|
326
|
+
expect(c.breakNodeId).toBeDefined()
|
|
327
|
+
const after = s.hops.slice(s.hops.findIndex((h) => h.id === c.breakNodeId) + 1)
|
|
328
|
+
for (const h of after) expect(h.state).toBe('after')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('keeps hops in path order', () => {
|
|
332
|
+
const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
|
|
333
|
+
expect(s.hops.map((h) => h.kind)).toEqual(['SERVICE', 'PODS'])
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('a derived break says which KIND it is', () => {
|
|
338
|
+
const derived = (basis: 'declared-config' | 'cluster-state'): RouteResult => ({
|
|
339
|
+
route: 'GET /', target: 'web:80', outcome: 'unreachable', basis,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// Calling "no ready endpoints" a configuration failure sends the reader to
|
|
343
|
+
// edit YAML when the fix is to get Pods running.
|
|
344
|
+
it('does not call a cluster-state break a configuration failure', () => {
|
|
345
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
346
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster', derived('cluster-state')))
|
|
347
|
+
expect(s.path.body).not.toMatch(/configuration/i)
|
|
348
|
+
expect(s.path.body).toMatch(/ready to serve/i)
|
|
349
|
+
expect(s.path.body).toMatch(/changes when the workload does/i)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('still calls a declared-config break a configuration failure', () => {
|
|
353
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
354
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster', derived('declared-config')))
|
|
355
|
+
expect(s.path.body).toMatch(/configuration itself is broken/i)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// Both are DERIVED: neither was dialled, so neither may read as an
|
|
359
|
+
// observation of the selected vantage.
|
|
360
|
+
it('attributes neither to the selected vantage', () => {
|
|
361
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
362
|
+
for (const b of ['declared-config', 'cluster-state'] as const) {
|
|
363
|
+
const s = buildSidebar(undefined, ctx(t, 'local', derived(b)))
|
|
364
|
+
expect(s.path.body).not.toMatch(/from here/i)
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('the verdict band follows the selected origin', () => {
|
|
370
|
+
// The band sits directly above the inspector, which reads the SELECTED
|
|
371
|
+
// origin's own result. On the merged rollup the two contradicted each other
|
|
372
|
+
// in adjacent panes - "could not get through" over "got through" - on exactly
|
|
373
|
+
// the disagreeing traces per-vantage evidence exists to represent. This seam
|
|
374
|
+
// regressed once already and had no pin.
|
|
375
|
+
const disagreeing = (): RouteResult =>
|
|
376
|
+
route({
|
|
377
|
+
outcome: 'unreachable',
|
|
378
|
+
confidence: 'real',
|
|
379
|
+
byVantage: [
|
|
380
|
+
{ vantage: 'local', path: 'data', outcome: 'unreachable' },
|
|
381
|
+
{ vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' },
|
|
382
|
+
],
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('shows the origin-scoped result, not the rollup, and names its scope', () => {
|
|
386
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
387
|
+
const v = buildVerdict(t, disagreeing(), { originId: 'incluster', originName: 'In-cluster probe' })
|
|
388
|
+
expect(v.tone).not.toBe('unhealthy')
|
|
389
|
+
expect(v.chipText).toBe('got through')
|
|
390
|
+
expect(v.chipScope).toContain('from In-cluster probe')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('an origin absent from a produced breakdown reads not tested, never the rollup chip', () => {
|
|
394
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
395
|
+
const only = route({
|
|
396
|
+
outcome: 'verified',
|
|
397
|
+
confidence: 'real',
|
|
398
|
+
byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' }],
|
|
399
|
+
})
|
|
400
|
+
const v = buildVerdict(t, only, { originId: 'local', originName: 'Radar on your machine' })
|
|
401
|
+
expect(v.chipText).toBe('not tested')
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe('localization facts belong to the vantage that produced them', () => {
|
|
406
|
+
// route.localization rows are apiserver-relay observations (direct-pod dials
|
|
407
|
+
// past the entry point). Crediting them to the in-cluster origin would claim
|
|
408
|
+
// observations that vantage never made.
|
|
409
|
+
const localized = (): RouteResult =>
|
|
410
|
+
route({
|
|
411
|
+
outcome: 'verified',
|
|
412
|
+
confidence: 'indirect',
|
|
413
|
+
byVantage: [{ vantage: 'local', path: 'apiserver', outcome: 'verified' }],
|
|
414
|
+
localization: [{ layer: 'tcp', ok: true, detail: 'Pods answered directly, Service did not' }],
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('shows them under the apiserver origin', () => {
|
|
418
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'apiserver' })])
|
|
419
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver', localized()))
|
|
420
|
+
const texts = s.path.evidence.map((e) => e.text).join('\n')
|
|
421
|
+
expect(texts).toMatch(/checked directly, past the entry point/)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('never credits them to the in-cluster origin', () => {
|
|
425
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
426
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster', localized()))
|
|
427
|
+
const texts = s.path.evidence.map((e) => e.text).join('\n')
|
|
428
|
+
expect(texts).not.toMatch(/checked directly/)
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
describe('a Service-routing boundary in the sidebar', () => {
|
|
434
|
+
const brokenRouting = (): RouteResult =>
|
|
435
|
+
route({
|
|
436
|
+
outcome: 'unreachable',
|
|
437
|
+
confidence: 'real',
|
|
438
|
+
byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
|
|
439
|
+
})
|
|
440
|
+
const withWorkload = () => {
|
|
441
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
442
|
+
t.downstream![1].config!.workload = { kind: 'Deployment', name: 'web', namespace: 'store' }
|
|
443
|
+
return t
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
it('the SERVICE hop carries the break, exit-phrased', () => {
|
|
447
|
+
const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
|
|
448
|
+
const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))!
|
|
449
|
+
expect(svc.state).toBe('break')
|
|
450
|
+
expect(svc.chipText).toMatch(/just past here/)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('the workload hop is never a journey participant', () => {
|
|
454
|
+
const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
|
|
455
|
+
const wl = s.hops.find((h) => h.kind.startsWith('DEPLOYMENT'))!
|
|
456
|
+
expect(wl.state).toBe('plain')
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('bypassed and parallel entries are context, not journey', () => {
|
|
461
|
+
const withEntries = (n = 1): Trace => {
|
|
462
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
463
|
+
t.upstreams = Array.from({ length: n }, (_, i) => ({
|
|
464
|
+
resource: { kind: 'Ingress', name: `web-${i}`, namespace: 'store' },
|
|
465
|
+
edge: 'ingress->service',
|
|
466
|
+
findings: [],
|
|
467
|
+
config: { hostnames: [`h${i}.example.com`], addresses: ['34.0.0.1'] },
|
|
468
|
+
}))
|
|
469
|
+
return t
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
it('the apiserver vantage does not list entries as its path', () => {
|
|
473
|
+
const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
|
|
474
|
+
expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(false)
|
|
475
|
+
expect(s.context?.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
|
|
476
|
+
expect(s.context?.label).toMatch(/RELAYED/)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('the laptop vantage keeps entries on the path', () => {
|
|
480
|
+
const s = buildSidebar(undefined, ctx(withEntries(), 'local'))
|
|
481
|
+
expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
|
|
482
|
+
expect(s.context).toBeUndefined()
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('two entries the route cannot be pinned to read as one parallel stage, never a sequence', () => {
|
|
486
|
+
const s = buildSidebar(undefined, ctx(withEntries(2), 'local'))
|
|
487
|
+
const entries = s.hops.filter((h) => h.kind.startsWith('INGRESS'))
|
|
488
|
+
expect(entries).toHaveLength(2)
|
|
489
|
+
for (const e of entries) expect(e.parallelCount).toBe(2)
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
describe("'testing' is scoped to the vantage that is testing", () => {
|
|
494
|
+
// The Job runs from the in-cluster vantage. Starting it while the LAPTOP is
|
|
495
|
+
// selected must not flip the laptop-scoped band or sidebar to "testing" -
|
|
496
|
+
// that is one vantage's state under another's name, in motion.
|
|
497
|
+
it('the verdict band for a non-running origin never says testing', () => {
|
|
498
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
499
|
+
// The view computes running && origin.id === 'incluster'; for the laptop
|
|
500
|
+
// that is false even mid-run.
|
|
501
|
+
const v = buildVerdict(t, route(), { running: false, originId: 'local', originName: 'Radar on your machine' })
|
|
502
|
+
expect(v.chipText).not.toBe('testing')
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('the sidebar for a non-running origin never says testing now', () => {
|
|
506
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data' })])
|
|
507
|
+
const c = ctx(t, 'local')
|
|
508
|
+
const s = buildSidebar(undefined, { ...c, running: false })
|
|
509
|
+
expect(JSON.stringify(s.path)).not.toMatch(/testing now/)
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
describe('a break at one parallel entry orders nothing', () => {
|
|
514
|
+
// Two entries with no host attribution stay in the journey as one parallel
|
|
515
|
+
// stage. A failed dial of entry A must not make sibling B read "before the
|
|
516
|
+
// break", nor the Service behind them "never tried - something earlier
|
|
517
|
+
// stopped": the request may have gone through B.
|
|
518
|
+
it('siblings and downstream hops read plain, never before/after', () => {
|
|
519
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
520
|
+
t.upstreams = [
|
|
521
|
+
{
|
|
522
|
+
resource: { kind: 'Ingress', name: 'entry-a', namespace: 'store' },
|
|
523
|
+
edge: 'ingress->service',
|
|
524
|
+
findings: [],
|
|
525
|
+
config: { addresses: ['34.0.0.1'] },
|
|
526
|
+
probes: [{ layer: 'tcp', target: '34.0.0.1:443', vantage: 'local', path: 'data', ok: false, tone: 'unhealthy' }],
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
resource: { kind: 'Ingress', name: 'entry-b', namespace: 'store' },
|
|
530
|
+
edge: 'ingress->service',
|
|
531
|
+
findings: [],
|
|
532
|
+
config: { addresses: ['34.0.0.2'] },
|
|
533
|
+
probes: [{ layer: 'http', target: '34.0.0.2:443', vantage: 'local', path: 'data', ok: true, tone: 'healthy' }],
|
|
534
|
+
},
|
|
535
|
+
]
|
|
536
|
+
const s = buildSidebar(undefined, ctx(t, 'local'))
|
|
537
|
+
const a = s.hops.find((h) => h.name === 'entry-a')
|
|
538
|
+
const b = s.hops.find((h) => h.name === 'entry-b')
|
|
539
|
+
const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))
|
|
540
|
+
if (a?.state === 'break') {
|
|
541
|
+
expect(b?.state).toBe('plain')
|
|
542
|
+
expect(svc?.state).toBe('plain')
|
|
543
|
+
} else {
|
|
544
|
+
// No break anchored at the stage in this fixture shape - the serial
|
|
545
|
+
// machine must still not be claiming order across parallel entries.
|
|
546
|
+
expect(b?.state).not.toBe('before')
|
|
547
|
+
expect(svc?.state).not.toBe('after')
|
|
548
|
+
}
|
|
549
|
+
})
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
describe('a skipped route says why, from the exact vantage that skipped it', () => {
|
|
553
|
+
const reason = "HTTPS backend - the API-server proxy speaks plain HTTP and can't verify TLS on this port. Test it directly."
|
|
554
|
+
const skipped = (target = 'shop:443'): RouteResult =>
|
|
555
|
+
route({ target, outcome: 'not-tested', confidence: undefined, evidence: reason, byVantage: undefined })
|
|
556
|
+
const mkSkipped = (skipProbe: Partial<ProbeResult>, runVantage: 'local' | 'in-cluster' = 'local'): Trace => {
|
|
557
|
+
const t = mk([pod('shop-1', true, '10.0.0.1')], [])
|
|
558
|
+
t.verdict = 'unknown'
|
|
559
|
+
t.runVantage = runVantage
|
|
560
|
+
t.coverage = { tested: 0, passed: 0, failed: 0, skipped: 1 }
|
|
561
|
+
t.routes = [skipped()]
|
|
562
|
+
t.downstream![0].probes = [p({ target: 'shop:443', port: 443, skipped: true, reason, ok: false, ...skipProbe })]
|
|
563
|
+
return t
|
|
564
|
+
}
|
|
565
|
+
const proxySkip = { vantage: 'local' as const, path: 'apiserver' as const }
|
|
566
|
+
|
|
567
|
+
it('surfaces the skip reason under the origin whose dial was skipped', () => {
|
|
568
|
+
const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
|
|
569
|
+
expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('keeps the proxy reason under the proxy even when the run came from inside the cluster', () => {
|
|
573
|
+
const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip, 'in-cluster'), 'apiserver', skipped()))
|
|
574
|
+
expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('never charges a vantage whose own dial was not the one skipped', () => {
|
|
578
|
+
const t = mkSkipped(proxySkip)
|
|
579
|
+
for (const id of ['local', 'incluster']) {
|
|
580
|
+
const texts = buildSidebar(undefined, ctx(t, id, skipped())).path.evidence.map((e) => e.text).join('\n')
|
|
581
|
+
expect(texts).not.toContain('speaks plain HTTP')
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it("never borrows another port's reason", () => {
|
|
586
|
+
const t = mkSkipped({ ...proxySkip, port: 80, target: 'shop:80' })
|
|
587
|
+
const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped('shop:443'))).path.evidence.map((e) => e.text).join('\n')
|
|
588
|
+
expect(texts).not.toContain('API-server proxy')
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('a portless skip speaks for every port', () => {
|
|
592
|
+
const t = mkSkipped({ ...proxySkip, port: undefined })
|
|
593
|
+
const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped())).path.evidence.map((e) => e.text).join('\n')
|
|
594
|
+
expect(texts).toContain('API-server proxy speaks plain HTTP')
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('says out loud that dots are health, not results, when nothing anywhere was tested', () => {
|
|
598
|
+
const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
|
|
599
|
+
expect(s.path.body).toContain('cluster state, not a test result')
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
describe('an attempted in-cluster run that never started keeps its attempt', () => {
|
|
604
|
+
it('WHAT WE SAW carries the execution error, not "no test has been run"', () => {
|
|
605
|
+
const t = mk([pod('shop-1', true, '10.0.0.1')], [])
|
|
606
|
+
const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })
|
|
607
|
+
t.routes = [r]
|
|
608
|
+
const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled' })
|
|
609
|
+
const origin = origins.find((o) => o.id === 'incluster')!
|
|
610
|
+
const g = buildGraph({ trace: t, route: r, origin })
|
|
611
|
+
const s = buildSidebar(undefined, {
|
|
612
|
+
trace: t, route: r, origin, origins,
|
|
613
|
+
nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
|
|
614
|
+
nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
|
|
615
|
+
entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
|
|
616
|
+
})
|
|
617
|
+
const texts = s.path.evidence.map((e) => e.text).join('\n')
|
|
618
|
+
expect(texts).toContain('image could not be pulled')
|
|
619
|
+
expect(texts).not.toContain('no test has been run from here')
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('a vantage that cannot be used explains itself in WHAT WE SAW', () => {
|
|
624
|
+
it('the laptop with nothing to dial shows the structural reason, not "no test has been run"', () => {
|
|
625
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
626
|
+
t.verdict = 'unknown'
|
|
627
|
+
t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })]
|
|
628
|
+
t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
|
|
629
|
+
const s = buildSidebar(undefined, ctx(t, 'local', t.routes[0]))
|
|
630
|
+
const texts = s.path.evidence.map((e) => e.text).join('\n')
|
|
631
|
+
expect(texts).toMatch(/no entry point .* from your machine|no Ingress/i)
|
|
632
|
+
expect(texts).not.toContain('no test has been run from here')
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
describe('a proxy-only failure never claims the target answered', () => {
|
|
637
|
+
it('the body states the relayed dial failed and the real path stays untested', () => {
|
|
638
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', ok: false, tone: 'unhealthy' })])
|
|
639
|
+
const r = route({ outcome: 'unreachable', confidence: 'indirect' })
|
|
640
|
+
t.routes = [r]
|
|
641
|
+
const s = buildSidebar(undefined, ctx(t, 'apiserver', r))
|
|
642
|
+
expect(s.path.body).toMatch(/relayed dial failed/i)
|
|
643
|
+
expect(s.path.body).not.toMatch(/target answered/i)
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
describe('nothing to suggest means nothing shown', () => {
|
|
648
|
+
it('renders no next-step block at all when there is no stronger test', () => {
|
|
649
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({})])
|
|
650
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster'))
|
|
651
|
+
expect(s.path.next.header).toBe('')
|
|
652
|
+
expect(s.path.next.body).toBe('')
|
|
653
|
+
expect(s.path.next.ctas).toHaveLength(0)
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
describe('a fresh failed attempt outranks leftover skip rows', () => {
|
|
658
|
+
it("WHAT WE SAW shows this run's execution error, not last run's skip", () => {
|
|
659
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
660
|
+
const r = route({ target: 'shop:443', outcome: 'not-tested', confidence: undefined, byVantage: undefined })
|
|
661
|
+
t.routes = [r]
|
|
662
|
+
t.downstream![1].probes = [
|
|
663
|
+
p({ target: '10.0.0.1:8080', port: 8080, vantage: 'in-cluster', path: 'data', source: 'probe-job', skipped: true, reason: 'stale skip from the previous run', ok: false }),
|
|
664
|
+
]
|
|
665
|
+
const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled', route: r })
|
|
666
|
+
const origin = origins.find((o) => o.id === 'incluster')!
|
|
667
|
+
const g = buildGraph({ trace: t, route: r, origin })
|
|
668
|
+
const s = buildSidebar(undefined, {
|
|
669
|
+
trace: t, route: r, origin, origins,
|
|
670
|
+
nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
|
|
671
|
+
nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
|
|
672
|
+
entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
|
|
673
|
+
})
|
|
674
|
+
const texts = s.path.evidence.map((e) => e.text).join('\n')
|
|
675
|
+
expect(texts).toContain('image could not be pulled')
|
|
676
|
+
expect(texts).not.toContain('stale skip')
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
describe('a result with no evidence string still reads as a result', () => {
|
|
681
|
+
it('never says "no test has been run" beside an outcome that ran', () => {
|
|
682
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', path: 'data', ok: true })])
|
|
683
|
+
const r = route({ route: 'redis', target: 'redis:6379', outcome: 'reached', confidence: 'real', evidence: undefined })
|
|
684
|
+
t.routes = [r]
|
|
685
|
+
const texts = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence.map((e) => e.text).join('\n')
|
|
686
|
+
expect(texts).not.toContain('no test has been run from here')
|
|
687
|
+
expect(texts.length).toBeGreaterThan(0)
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
describe('selecting a context entry opens it', () => {
|
|
692
|
+
// The entry-problem row exists to answer "show me the cause". Landing on a
|
|
693
|
+
// still-collapsed section makes the reader spend a second click on the very
|
|
694
|
+
// thing they asked for - and it must hold for EVERY declared entry kind.
|
|
695
|
+
const withEntries = (): Trace => {
|
|
696
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
697
|
+
t.upstreams = [
|
|
698
|
+
{ resource: { kind: 'Ingress', name: 'shop', namespace: 'store' }, edge: 'ingress->service', findings: [], config: { addresses: ['34.0.0.1'] } },
|
|
699
|
+
{ resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, edge: 'httproute->service', findings: [] },
|
|
700
|
+
]
|
|
701
|
+
return t
|
|
702
|
+
}
|
|
703
|
+
for (const kind of ['Ingress', 'HTTPRoute']) {
|
|
704
|
+
it(`expands the selected ${kind}, and leaves its sibling collapsed`, () => {
|
|
705
|
+
const t = withEntries()
|
|
706
|
+
const c = ctx(t, 'apiserver')
|
|
707
|
+
const id = `n:${kind}/store/shop`
|
|
708
|
+
const s = buildSidebar(id, c)
|
|
709
|
+
const hops = s.context?.hops ?? []
|
|
710
|
+
expect(hops.length).toBeGreaterThan(0)
|
|
711
|
+
const picked = hops.find((h) => h.id === id)
|
|
712
|
+
expect(picked, `${kind} should be a context hop`).toBeTruthy()
|
|
713
|
+
expect(picked!.expanded).toBe(true)
|
|
714
|
+
expect(hops.filter((h) => h.id !== id).every((h) => !h.expanded)).toBe(true)
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
it('leaves every context hop collapsed when nothing is selected', () => {
|
|
718
|
+
const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
|
|
719
|
+
expect((s.context?.hops ?? []).every((h) => !h.expanded)).toBe(true)
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
describe('a coverage statement is not a fault', () => {
|
|
724
|
+
it('the verdict keeps it on the wire but marks it coverage-class', () => {
|
|
725
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
726
|
+
t.diagnosis = { class: 'coverage', summary: "reachable via API server - the real-traffic path wasn't confirmed from here" } as never
|
|
727
|
+
// buildVerdict still surfaces it for consumers that want the sentence...
|
|
728
|
+
const v = buildVerdict(t, route({ outcome: 'reached', confidence: 'indirect' }), { originId: 'apiserver', originName: 'API-server proxy' })
|
|
729
|
+
expect(v.problem).toBeTruthy()
|
|
730
|
+
// ...and the class is what the header uses to keep it OUT of the problem list.
|
|
731
|
+
expect(t.diagnosis!.class).toBe('coverage')
|
|
732
|
+
})
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
describe('one observation is stated once', () => {
|
|
736
|
+
it('a localization fact does not repeat the route evidence it came from', () => {
|
|
737
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
|
|
738
|
+
const r = route({
|
|
739
|
+
outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached',
|
|
740
|
+
localization: [{ layer: 'http', ok: true, detail: 'HTTP 404 · reached' }],
|
|
741
|
+
} as never)
|
|
742
|
+
t.routes = [r]
|
|
743
|
+
const texts = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.evidence.map((e) => e.text)
|
|
744
|
+
const plain = texts.filter((x) => x.toLowerCase().startsWith('http 404 · reached'))
|
|
745
|
+
expect(plain, `saw: ${JSON.stringify(texts)}`).toHaveLength(1)
|
|
746
|
+
// and the surviving line is the MORE specific one
|
|
747
|
+
expect(plain[0]).toContain('checked directly')
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
describe('an answer is interpretable', () => {
|
|
752
|
+
const reached = (evidence: string): RouteResult =>
|
|
753
|
+
route({ outcome: 'reached', confidence: 'real', evidence, inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } } as never)
|
|
754
|
+
const body = (evidence: string) => {
|
|
755
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached', detail: evidence })])
|
|
756
|
+
const r = reached(evidence)
|
|
757
|
+
t.routes = [r]
|
|
758
|
+
return buildSidebar(undefined, ctx(t, 'incluster', r)).path
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
it('a 404 says the PATH works and names what would verify a route', () => {
|
|
762
|
+
const b = body('HTTP 404 · reached').body
|
|
763
|
+
expect(b).toContain('path works')
|
|
764
|
+
expect(b).toContain('serves no route')
|
|
765
|
+
expect(b).toContain('re-run with a path your app serves')
|
|
766
|
+
})
|
|
767
|
+
it('auth, redirect and app-error answers are each read correctly', () => {
|
|
768
|
+
expect(body('HTTP 401 · reached').body).toContain('credentials')
|
|
769
|
+
expect(body('HTTP 308 · reached, redirect').body).toMatch(/redirect .*Radar does not follow/)
|
|
770
|
+
expect(body('HTTP 503 · reached, server error').body).toContain('application health')
|
|
771
|
+
})
|
|
772
|
+
it('a transport-only reach never claims something was asked', () => {
|
|
773
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
|
|
774
|
+
const r = route({ outcome: 'reached', confidence: 'real', evidence: '', inClusterRequest: { protocol: 'tcp' } } as never)
|
|
775
|
+
t.routes = [r]
|
|
776
|
+
expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.body).toContain('nothing was asked of the application')
|
|
777
|
+
})
|
|
778
|
+
it('shows WHAT was requested, so a status code can be read at all', () => {
|
|
779
|
+
expect(body('HTTP 404 · reached').scope.some((s) => s.k === 'REQUEST' && s.v === 'GET /')).toBe(true)
|
|
780
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
|
|
781
|
+
const r = route({ outcome: 'reached', confidence: 'real', inClusterRequest: { protocol: 'tcp' } } as never)
|
|
782
|
+
t.routes = [r]
|
|
783
|
+
expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.scope.some((s) => s.k === 'REQUEST' && s.v === 'TCP connect')).toBe(true)
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
describe('a relayed answer explains the relay AND the answer', () => {
|
|
788
|
+
it('keeps the relay caveat and still reads the status', () => {
|
|
789
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
|
|
790
|
+
const r = route({ outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached', inClusterRequest: { protocol: 'http', path: '/' } } as never)
|
|
791
|
+
t.routes = [r]
|
|
792
|
+
const b = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.body
|
|
793
|
+
expect(b).toContain('relayed')
|
|
794
|
+
expect(b).toContain('serves no route')
|
|
795
|
+
// ...but never claims the real path works, because a relay cannot show that
|
|
796
|
+
expect(b).not.toContain('The path works')
|
|
797
|
+
})
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
// An answer is not the same as an answer that got through. A gateway saying it
|
|
801
|
+
// could not reach its upstream, and a handshake that never verified, are both
|
|
802
|
+
// answers - and both mean the opposite of "the path works".
|
|
803
|
+
describe('an answer that reports a break never reads as a success', () => {
|
|
804
|
+
const bodyFor = (r: Partial<RouteResult>) => {
|
|
805
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached' } as never)])
|
|
806
|
+
const full = route({ outcome: 'server-error', confidence: 'real', inClusterRequest: { protocol: 'http', path: '/' }, ...r } as never)
|
|
807
|
+
t.routes = [full]
|
|
808
|
+
return buildSidebar(undefined, ctx(t, 'incluster', full)).path.body
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
for (const code of [502, 504]) {
|
|
812
|
+
it(`does not claim the path works on HTTP ${code}`, () => {
|
|
813
|
+
const b = bodyFor({ evidence: `HTTP ${code}`, failedLayer: 'upstream' } as never)
|
|
814
|
+
expect(b).not.toContain('The path works')
|
|
815
|
+
expect(b).toContain('could not reach the backend')
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
it('explains a certificate failure, which carries no status code at all', () => {
|
|
820
|
+
const b = bodyFor({ evidence: 'x509: certificate has expired', failedLayer: 'tls' } as never)
|
|
821
|
+
expect(b).not.toContain('The path works')
|
|
822
|
+
expect(b).toContain('certificate problem')
|
|
823
|
+
// The generic fallthrough would have said this instead, explaining nothing.
|
|
824
|
+
expect(b).not.toContain('not with what was asked for')
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it('still credits the path when the APP itself answered', () => {
|
|
828
|
+
expect(bodyFor({ evidence: 'HTTP 404' } as never)).toContain('The path works')
|
|
829
|
+
expect(bodyFor({ evidence: 'HTTP 500' } as never)).toContain('The path works')
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
describe('every state explains itself, not the nearest generic sentence', () => {
|
|
834
|
+
const withRoute = (r: RouteResult, probes = [p({})]) => {
|
|
835
|
+
const t = mk([pod('a', true, '10.0.0.1')], probes)
|
|
836
|
+
t.routes = [r]
|
|
837
|
+
return buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
it('a deliberately dormant path is not described as an answer', () => {
|
|
841
|
+
const b = withRoute(route({ outcome: 'unreachable', benign: true, evidence: 'no running backends (scaled to 0)' } as never))
|
|
842
|
+
expect(b).toContain('deliberate')
|
|
843
|
+
expect(b).not.toContain('not with what was asked for')
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('a kept-informational run says it ran AND why it does not decide', () => {
|
|
847
|
+
const t = mk([pod('a', true, '10.0.0.1')], [
|
|
848
|
+
p({ target: 'shop:80', port: 80, skipped: true, skipClass: 'informational', reason: 'the throwaway Pod was denied by mTLS', ok: false, source: 'probe-job' }),
|
|
849
|
+
])
|
|
850
|
+
const r = route({ target: 'shop:80', outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
|
|
851
|
+
t.routes = [r]
|
|
852
|
+
const b = buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
|
|
853
|
+
expect(b + JSON.stringify(buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence)).toContain('denied by mTLS')
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('a verified answer is not described as a partial one', () => {
|
|
857
|
+
const b = withRoute(route({ outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · verified' } as never))
|
|
858
|
+
expect(b).toContain('A real request went through')
|
|
859
|
+
})
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
describe('the action sits with its explanation', () => {
|
|
864
|
+
it('an untested vantage offers the run, and does not repeat the sentence above it', () => {
|
|
865
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
866
|
+
const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined, inClusterRequest: { protocol: 'http', path: '/' } } as never)
|
|
867
|
+
t.routes = [r]
|
|
868
|
+
const s = buildSidebar(undefined, ctx(t, 'incluster', r)).path
|
|
869
|
+
expect(s.body).toContain('Nothing has been tested from here')
|
|
870
|
+
// the action is present...
|
|
871
|
+
expect(s.next.ctas.some((c) => c.action === 'run-in-cluster')).toBe(true)
|
|
872
|
+
// ...and the block does not say "nothing has been tested" a second time
|
|
873
|
+
expect(s.next.body).toBe('')
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it('WHAT WE SAW disappears rather than echoing "no test has been run"', () => {
|
|
877
|
+
const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
878
|
+
const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
|
|
879
|
+
t.routes = [r]
|
|
880
|
+
const ev = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence
|
|
881
|
+
expect(ev).toHaveLength(0)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('a vantage that CANNOT be used still says why, instead of going silent', () => {
|
|
885
|
+
const t = mk([pod('a', true, '10.0.0.1')], [])
|
|
886
|
+
t.verdict = 'unknown'
|
|
887
|
+
t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)]
|
|
888
|
+
t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
|
|
889
|
+
const ev = buildSidebar(undefined, ctx(t, 'local', t.routes[0])).path.evidence
|
|
890
|
+
expect(ev.map((e) => e.text).join(' ')).toMatch(/no entry point|Ingress/i)
|
|
891
|
+
})
|
|
892
|
+
})
|