@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,214 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { traceFingerprint, staticPollUnreliable } from './traceFingerprint'
|
|
3
|
+
import type { Trace, Hop } from './types'
|
|
4
|
+
|
|
5
|
+
// The load-bearing invariant: a ?probe=true trace (or the in-cluster merged
|
|
6
|
+
// trace) and a ?probe=false trace built from the SAME cluster state must
|
|
7
|
+
// fingerprint IDENTICALLY - the probe response is the staleness baseline, and
|
|
8
|
+
// each static poll is compared against it. Anything probing mutates (probes,
|
|
9
|
+
// verdict, routes, probe:* findings, netpol:would-deny, the targetPort
|
|
10
|
+
// advisory's severity) must therefore be invisible to the fingerprint, while
|
|
11
|
+
// every real static-state change must move it.
|
|
12
|
+
|
|
13
|
+
const svcHop = (over: Partial<Hop> = {}): Hop => ({
|
|
14
|
+
resource: { kind: 'Service', namespace: 'ns', name: 'web' },
|
|
15
|
+
edge: 'subject',
|
|
16
|
+
findings: [],
|
|
17
|
+
config: { serviceType: 'ClusterIP', ports: [{ port: 80, targetPort: '8080' }] },
|
|
18
|
+
...over,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const podsHop = (over: Partial<Hop> = {}): Hop => ({
|
|
22
|
+
resource: { kind: 'Pods', namespace: 'ns', name: '' },
|
|
23
|
+
edge: 'Service->Pods',
|
|
24
|
+
findings: [],
|
|
25
|
+
meta: { ready: 2, selected: 3 },
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const staticTrace = (): Trace => ({
|
|
30
|
+
subject: { kind: 'Service', namespace: 'ns', name: 'web' },
|
|
31
|
+
upstreams: [],
|
|
32
|
+
downstream: [
|
|
33
|
+
svcHop({
|
|
34
|
+
findings: [
|
|
35
|
+
{ code: 'svc:targetport-no-listener', severity: 'warning', message: 'targetPort matches no declared containerPort' },
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
podsHop({
|
|
39
|
+
findings: [
|
|
40
|
+
{ code: 'netpol:would-deny', severity: 'warning', message: 'a NetworkPolicy would deny this' },
|
|
41
|
+
{ code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'back-off restarting' },
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
verdict: 'degraded',
|
|
46
|
+
brokenAt: -1,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// The probed twin of the SAME cluster state: probes attached, verdict revised,
|
|
50
|
+
// routes built from probes, findings reconciled (targetPort downgraded to
|
|
51
|
+
// info, netpol prediction removed after a clean divergence, probe:* finding
|
|
52
|
+
// added) and re-sorted.
|
|
53
|
+
const probedTwin = (): Trace => {
|
|
54
|
+
const t = staticTrace()
|
|
55
|
+
t.downstream[0].probes = [
|
|
56
|
+
{ layer: 'tcp', target: 'web:80', vantage: 'local', path: 'apiserver', ok: true, port: 80 },
|
|
57
|
+
{ layer: 'http', target: 'web:80', vantage: 'local', path: 'apiserver', ok: true, tone: 'healthy', port: 80 },
|
|
58
|
+
]
|
|
59
|
+
t.downstream[0].findings = [
|
|
60
|
+
{ code: 'svc:targetport-no-listener', severity: 'info', message: 'a live probe reached it - nothing to fix' },
|
|
61
|
+
]
|
|
62
|
+
t.downstream[1].probes = [{ layer: 'tcp', target: '10.0.0.1:8080', vantage: 'in-cluster', path: 'data', ok: false, error: 'refused' }]
|
|
63
|
+
t.downstream[1].findings = [
|
|
64
|
+
{ code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'back-off restarting' },
|
|
65
|
+
{ code: 'probe:data-path-only-broken', severity: 'warning', message: 'data path dropped while the proxy reached it' },
|
|
66
|
+
]
|
|
67
|
+
t.verdict = 'broken'
|
|
68
|
+
t.brokenAt = 1
|
|
69
|
+
t.reason = 'the entry is unreachable'
|
|
70
|
+
t.headline = '1 of 1 routes unreachable'
|
|
71
|
+
t.coverage = { tested: 1, passed: 0, failed: 1, skipped: 0 }
|
|
72
|
+
t.routes = [{ route: 'web', target: 'web:80', outcome: 'unreachable', confidence: 'real', evidence: 'refused' }]
|
|
73
|
+
return t
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('traceFingerprint - probe-invariance (same state, two wire shapes)', () => {
|
|
77
|
+
it('a probed trace fingerprints identically to the static trace of the same cluster state', () => {
|
|
78
|
+
expect(traceFingerprint(probedTwin())).toBe(traceFingerprint(staticTrace()))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is invariant to finding ORDER (probing re-sorts findings by severity)', () => {
|
|
82
|
+
const a = staticTrace()
|
|
83
|
+
const b = staticTrace()
|
|
84
|
+
b.downstream[1].findings = [...b.downstream[1].findings].reverse()
|
|
85
|
+
expect(traceFingerprint(a)).toBe(traceFingerprint(b))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('ignores route outcomes - two probe runs with different results over the same state match', () => {
|
|
89
|
+
const a = probedTwin()
|
|
90
|
+
const b = probedTwin()
|
|
91
|
+
b.routes = [{ route: 'web', target: 'web:80', outcome: 'verified', confidence: 'real' }]
|
|
92
|
+
b.verdict = 'healthy'
|
|
93
|
+
expect(traceFingerprint(a)).toBe(traceFingerprint(b))
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('traceFingerprint - real cluster changes move it', () => {
|
|
98
|
+
it('changes when endpoint readiness changes', () => {
|
|
99
|
+
const changed = staticTrace()
|
|
100
|
+
changed.downstream[1].meta = { ready: 0, selected: 3 }
|
|
101
|
+
expect(traceFingerprint(changed)).not.toBe(traceFingerprint(staticTrace()))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('changes when a finding appears or escalates', () => {
|
|
105
|
+
const appeared = staticTrace()
|
|
106
|
+
appeared.downstream[0].findings.push({ code: 'svc:no-ready-endpoints', severity: 'critical', message: '0 ready' })
|
|
107
|
+
expect(traceFingerprint(appeared)).not.toBe(traceFingerprint(staticTrace()))
|
|
108
|
+
|
|
109
|
+
const escalated = staticTrace()
|
|
110
|
+
escalated.downstream[1].findings = [{ code: 'problem:CrashLoopBackOff', severity: 'warning', message: 'back-off' }]
|
|
111
|
+
expect(traceFingerprint(escalated)).not.toBe(traceFingerprint(staticTrace()))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('changes when a hop is added or its identity changes', () => {
|
|
115
|
+
const added = staticTrace()
|
|
116
|
+
added.upstreams.push({ resource: { kind: 'Ingress', namespace: 'ns', name: 'edge' }, edge: 'Ingress->Service', findings: [] })
|
|
117
|
+
expect(traceFingerprint(added)).not.toBe(traceFingerprint(staticTrace()))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('changes when the declared route config changes (port mapping / ingress rule)', () => {
|
|
121
|
+
const remapped = staticTrace()
|
|
122
|
+
remapped.downstream[0].config = { serviceType: 'ClusterIP', ports: [{ port: 80, targetPort: '9090' }] }
|
|
123
|
+
expect(traceFingerprint(remapped)).not.toBe(traceFingerprint(staticTrace()))
|
|
124
|
+
|
|
125
|
+
const ruled = staticTrace()
|
|
126
|
+
ruled.downstream[0].config = {
|
|
127
|
+
...ruled.downstream[0].config,
|
|
128
|
+
rules: [{ hosts: ['shop.example.com'], paths: ['/api'], backends: [{ kind: 'Service', name: 'web', port: '80' }] }],
|
|
129
|
+
}
|
|
130
|
+
expect(traceFingerprint(ruled)).not.toBe(traceFingerprint(staticTrace()))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('changes when the Service selector flips even though ready/selected counts stay equal', () => {
|
|
134
|
+
// app=a→app=b with the SAME endpoint counts: without the selector in the
|
|
135
|
+
// fingerprint the stale snapshot would keep vouching for the old backend set.
|
|
136
|
+
const base = staticTrace()
|
|
137
|
+
base.downstream[0].config = { ...base.downstream[0].config, selector: { app: 'a' } }
|
|
138
|
+
const flipped = staticTrace()
|
|
139
|
+
flipped.downstream[0].config = { ...flipped.downstream[0].config, selector: { app: 'b' } }
|
|
140
|
+
expect(traceFingerprint(flipped)).not.toBe(traceFingerprint(base))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('is invariant to selector KEY ORDER (deterministic serialization)', () => {
|
|
144
|
+
const a = staticTrace()
|
|
145
|
+
a.downstream[0].config = { ...a.downstream[0].config, selector: { app: 'x', tier: 'web' } }
|
|
146
|
+
const b = staticTrace()
|
|
147
|
+
b.downstream[0].config = { ...b.downstream[0].config, selector: { tier: 'web', app: 'x' } }
|
|
148
|
+
expect(traceFingerprint(a)).toBe(traceFingerprint(b))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('changes when clusterIP changes (Service recreated with a new VIP)', () => {
|
|
152
|
+
const base = staticTrace()
|
|
153
|
+
base.downstream[0].config = { ...base.downstream[0].config, clusterIP: '10.0.0.1' }
|
|
154
|
+
const changed = staticTrace()
|
|
155
|
+
changed.downstream[0].config = { ...changed.downstream[0].config, clusterIP: '10.0.0.2' }
|
|
156
|
+
expect(traceFingerprint(changed)).not.toBe(traceFingerprint(base))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// KNOWN BLIND SPOT (documented in traceFingerprint.ts header): netpol:would-deny
|
|
160
|
+
// is necessarily excluded because probing rewrites/removes it, so a NetworkPolicy
|
|
161
|
+
// applied AFTER a probe snapshot does NOT move the fingerprint. The deferred
|
|
162
|
+
// server-side static-state version/etag will close this gap.
|
|
163
|
+
it.skip('does NOT detect a NetworkPolicy added after the snapshot (accepted client-side limitation)', () => {
|
|
164
|
+
const base = staticTrace()
|
|
165
|
+
const withNetpol = staticTrace()
|
|
166
|
+
withNetpol.downstream[1].findings = [
|
|
167
|
+
...withNetpol.downstream[1].findings,
|
|
168
|
+
{ code: 'netpol:would-deny', severity: 'critical', message: 'a NetworkPolicy would deny this' },
|
|
169
|
+
]
|
|
170
|
+
// Intentionally identical - the client fingerprint can't see this change.
|
|
171
|
+
expect(traceFingerprint(withNetpol)).toBe(traceFingerprint(base))
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('staticPollUnreliable - a poll we couldn\'t fully build is not staleness evidence', () => {
|
|
176
|
+
it('a healthy fully-built poll is reliable', () => {
|
|
177
|
+
expect(staticPollUnreliable(staticTrace())).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('a budget-timeout PARTIAL poll (verdict unknown + investigate) is unreliable', () => {
|
|
181
|
+
const partial = staticTrace()
|
|
182
|
+
partial.verdict = 'unknown'
|
|
183
|
+
partial.unknownClass = 'investigate'
|
|
184
|
+
partial.reason = 'diagnosis timed out - partial result; try again or run in-cluster'
|
|
185
|
+
expect(staticPollUnreliable(partial)).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('a by-design unknown (selectorless Service) stays reliable - it is a stable read', () => {
|
|
189
|
+
const byDesign = staticTrace()
|
|
190
|
+
byDesign.verdict = 'unknown'
|
|
191
|
+
byDesign.unknownClass = 'by-design'
|
|
192
|
+
expect(staticPollUnreliable(byDesign)).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('a transient pod-lister failure (endpointSource=unknown) is unreliable', () => {
|
|
196
|
+
const degraded = staticTrace()
|
|
197
|
+
degraded.downstream[1].meta = { endpointSource: 'unknown' }
|
|
198
|
+
expect(staticPollUnreliable(degraded)).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('load-bearing: a degraded poll DIFFERS in fingerprint yet must NOT invalidate a good snapshot', () => {
|
|
202
|
+
// The healthy snapshot baseline...
|
|
203
|
+
const snapshot = staticTrace()
|
|
204
|
+
const snapshotFp = traceFingerprint(snapshot)
|
|
205
|
+
// ...and a later transient-failure poll whose counts/source flipped.
|
|
206
|
+
const degradedPoll = staticTrace()
|
|
207
|
+
degradedPoll.downstream[1].meta = { endpointSource: 'unknown' }
|
|
208
|
+
// The fingerprint WOULD mismatch (proving the naive mask would false-fire)...
|
|
209
|
+
expect(traceFingerprint(degradedPoll)).not.toBe(snapshotFp)
|
|
210
|
+
// ...but the guard marks the poll unreliable, so the mask skips the comparison
|
|
211
|
+
// and keeps the snapshot.
|
|
212
|
+
expect(staticPollUnreliable(degradedPoll)).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Trace, Hop, HopConfig, Finding } from './types'
|
|
2
|
+
|
|
3
|
+
// traceFingerprint reduces a trace to a cheap, stable string over its
|
|
4
|
+
// PROBE-INVARIANT content: the static-derived cluster state the trace was built
|
|
5
|
+
// from. The invariant that makes it useful: a ?probe=true trace (including the
|
|
6
|
+
// in-cluster merged trace) and a ?probe=false trace built from the SAME cluster
|
|
7
|
+
// state produce IDENTICAL fingerprints. That lets the probe response ITSELF be
|
|
8
|
+
// the staleness baseline - it embeds the exact static content the probe ran
|
|
9
|
+
// against - and each later static poll is compared against it; a mismatch means
|
|
10
|
+
// the cluster changed under the snapshot.
|
|
11
|
+
//
|
|
12
|
+
// Included (identical across the two shapes, verified against internal/trace):
|
|
13
|
+
// - hop identity: kind / namespace / name / edge (hops are built by the
|
|
14
|
+
// static walk; probing never adds or removes one)
|
|
15
|
+
// - meta.ready / meta.selected (endpoint counts from the cache)
|
|
16
|
+
// - finding code:severity (sorted - probing re-sorts findings)
|
|
17
|
+
// - declared route identity from hop config: service ports, serviceType,
|
|
18
|
+
// hostnames, TLS hosts, ingress/route rules (host+path+backend), gateway
|
|
19
|
+
// listeners
|
|
20
|
+
// - config IDENTITY the static walk sets but probing never touches: the
|
|
21
|
+
// Service selector, clusterIP, and declared addresses (LB/ExternalName).
|
|
22
|
+
// A selector flip (app=a→app=b) can leave ready/selected counts EQUAL, so
|
|
23
|
+
// without the selector the fingerprint would miss a real backend-set change
|
|
24
|
+
// and a stale snapshot would keep vouching for the old backend set.
|
|
25
|
+
//
|
|
26
|
+
// Excluded, because probing mutates them and a same-state pair would diverge:
|
|
27
|
+
// - probes (only ?probe=true carries them) and verdict / brokenAt / reason /
|
|
28
|
+
// headline / coverage / diagnosis (reviseVerdictWithProbes + the coverage
|
|
29
|
+
// collapse rewrite them from probe outcomes)
|
|
30
|
+
// - routes / notTested: routeFromProbes only emits a route once a probe ran,
|
|
31
|
+
// so a static trace carries a different (usually empty) route list than a
|
|
32
|
+
// probed one - the declared config rules above carry route identity instead
|
|
33
|
+
// - probe:* findings (attachPathDivergenceFindings adds them only when
|
|
34
|
+
// probes attach)
|
|
35
|
+
// - netpol:would-deny (reconcilePolicyFindings / reconcileInClusterPolicy
|
|
36
|
+
// rewrite its severity and can remove it outright once live traffic
|
|
37
|
+
// settles the prediction). KNOWN BLIND SPOT: because this finding is
|
|
38
|
+
// necessarily excluded (probing rewrites/removes it), a NetworkPolicy
|
|
39
|
+
// APPLIED AFTER a probe snapshot won't move the fingerprint and so won't
|
|
40
|
+
// invalidate the snapshot - an accepted limitation of the client-side
|
|
41
|
+
// fingerprint. The deferred server-side static-state version/etag will
|
|
42
|
+
// resolve it (the server sees the policy add directly).
|
|
43
|
+
// - the SEVERITY of svc:targetport-no-listener (reconcileTargetPortAdvisory
|
|
44
|
+
// downgrades warning → info when a live probe reaches the suspect port);
|
|
45
|
+
// its presence is stable, so the code still counts
|
|
46
|
+
// - finding messages (free-form text can embed volatile detail)
|
|
47
|
+
// - pod rosters (config.pods/podNames/podIPs/containerPorts) - name churn
|
|
48
|
+
// from ordinary pod replacement isn't a state change the counts and
|
|
49
|
+
// findings don't already capture
|
|
50
|
+
|
|
51
|
+
const findingsFp = (findings: Finding[] | undefined): string =>
|
|
52
|
+
(findings ?? [])
|
|
53
|
+
.filter((f) => !(f.code ?? '').startsWith('probe:') && f.code !== 'netpol:would-deny')
|
|
54
|
+
.map((f) => (f.code === 'svc:targetport-no-listener' ? f.code : `${f.code}:${f.severity}`))
|
|
55
|
+
.sort()
|
|
56
|
+
.join('+')
|
|
57
|
+
|
|
58
|
+
const configFp = (c: HopConfig | undefined): string => {
|
|
59
|
+
if (!c) return ''
|
|
60
|
+
// name + appProtocol decide HTTP-vs-HTTPS-vs-TCP for the in-cluster request,
|
|
61
|
+
// so an edit to them MUST invalidate the trace: consent renders from the
|
|
62
|
+
// displayed trace while the server re-derives fresh state at run time, and a
|
|
63
|
+
// fingerprint blind to these fields let consent describe different traffic
|
|
64
|
+
// than the Job then sent.
|
|
65
|
+
const ports = (c.ports ?? []).map((p) => `${p.port}>${p.targetPort ?? ''}/${p.protocol ?? ''}/${p.name ?? ''}/${p.appProtocol ?? ''}`).join(',')
|
|
66
|
+
const rules = (c.rules ?? [])
|
|
67
|
+
.map((r) =>
|
|
68
|
+
[
|
|
69
|
+
(r.hosts ?? []).join(','),
|
|
70
|
+
(r.paths ?? []).join(','),
|
|
71
|
+
(r.backends ?? []).map((b) => `${b.kind}/${b.namespace ?? ''}/${b.name}:${b.port ?? ''}`).join(','),
|
|
72
|
+
].join('~'),
|
|
73
|
+
)
|
|
74
|
+
.join(';')
|
|
75
|
+
const listeners = (c.listeners ?? []).map((l) => `${l.name ?? ''}/${l.port}/${l.protocol ?? ''}/${l.hostname ?? ''}`).join(',')
|
|
76
|
+
const selector = Object.keys(c.selector ?? {})
|
|
77
|
+
.sort()
|
|
78
|
+
.map((k) => `${k}=${c.selector![k]}`)
|
|
79
|
+
.join(',')
|
|
80
|
+
const addresses = (c.addresses ?? []).join(',')
|
|
81
|
+
return [c.serviceType ?? '', ports, (c.hostnames ?? []).join(','), (c.tlsHosts ?? []).join(','), rules, listeners, c.clusterIP ?? '', selector, addresses].join('^')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hopFp = (h: Hop): string =>
|
|
85
|
+
[
|
|
86
|
+
h.resource.kind,
|
|
87
|
+
h.resource.namespace ?? '',
|
|
88
|
+
h.resource.name,
|
|
89
|
+
h.edge,
|
|
90
|
+
String(h.meta?.ready ?? ''),
|
|
91
|
+
String(h.meta?.selected ?? ''),
|
|
92
|
+
findingsFp(h.findings),
|
|
93
|
+
configFp(h.config),
|
|
94
|
+
].join('|')
|
|
95
|
+
|
|
96
|
+
export function traceFingerprint(t: Trace): string {
|
|
97
|
+
return [(t.downstream ?? []).map(hopFp).join(';'), (t.upstreams ?? []).map(hopFp).join(';')].join('#')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// staticPollUnreliable reports whether a fresh static poll is too degraded to
|
|
101
|
+
// serve as staleness evidence against a probe snapshot. BuildTraceWithOptions
|
|
102
|
+
// returns HTTP 200 even when it could NOT fully build the trace:
|
|
103
|
+
// - a total-budget timeout mid-walk yields a PARTIAL trace (fewer hops) with
|
|
104
|
+
// verdict 'unknown' + unknownClass 'investigate' (internal/trace/trace.go)
|
|
105
|
+
// - a transient pod-lister / RBAC read failure stamps a hop
|
|
106
|
+
// meta.endpointSource === 'unknown' and untrustworthy ready/selected counts
|
|
107
|
+
// (internal/trace/entries.go)
|
|
108
|
+
// Both shift the fingerprint spuriously, so a mismatch against such a poll is
|
|
109
|
+
// NOT evidence the cluster changed - the staleness mask must SKIP the comparison
|
|
110
|
+
// and keep the snapshot. A poll we couldn't fully build proves nothing.
|
|
111
|
+
//
|
|
112
|
+
// A 'by-design' unknown (selectorless Service) is a STABLE read and stays
|
|
113
|
+
// comparable; only the 'investigate' class means we tried and couldn't read
|
|
114
|
+
// state. Trace.truncated is a DETERMINISTIC fan-out cap (large Gateway/Ingress),
|
|
115
|
+
// not a transient degradation - it fingerprints consistently poll-to-poll, so it
|
|
116
|
+
// is deliberately NOT treated as unreliable (doing so would permanently disable
|
|
117
|
+
// change detection for a truncated subject).
|
|
118
|
+
export function staticPollUnreliable(t: Trace): boolean {
|
|
119
|
+
if (t.verdict === 'unknown' && t.unknownClass === 'investigate') return true
|
|
120
|
+
const hops = [...(t.downstream ?? []), ...(t.upstreams ?? [])]
|
|
121
|
+
return hops.some((h) => h.meta?.endpointSource === 'unknown')
|
|
122
|
+
}
|