Surface L4Route service-settings faithfully (groundwork for #8; on-screen fix needs cluster data) - #172
Surface L4Route service-settings faithfully (groundwork for #8; on-screen fix needs cluster data)#172jgruberf5 wants to merge 3 commits into
Conversation
…d spec weight
The gateway topology showed spec.rules[].backendRefs[].weight -- the
author's declared intent, often a placeholder -- while the weights the
analyzer actually computed live in the k8s.f5.com/service-settings
annotation on the L4Route, keyed {service: {pod_ip: weight}}. That
annotation was parsed NOWHERE in the backend, so the UI displayed the
wrong numbers (#8): the reporter saw the topology show one thing while
`kubectl get l4route ... service-settings` showed 1/99.
_parse_service_settings reads the annotation (tolerating absent/malformed
JSON -> {}). _build_backend attaches, per backend service:
- analyzerWeights: the {pod_ip: weight} the analyzer computed, or None
- effectiveWeight: their sum -- the single number the UI should display
-- or None when the analyzer has not weighted this service.
The declared `weight` is preserved for backward compatibility and as the
fallback the UI uses when effectiveWeight is None.
This is the backend half. The frontend must prefer effectiveWeight (then
analyzerWeights, then weight) to actually change what the user sees; that
consumer change and cluster validation are called out in the PR. Filed as
the root-cause fix: the correct data was simply never surfaced.
Fixes #8
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…#8) The backend change (prior commit) put analyzerWeights/effectiveWeight on each topology backend dict, but nothing consumed them yet, so the UI still showed the declared spec weight -- the reporter's exact symptom. This completes the chain so the user actually sees the analyzer's number. Backend: - build_route_ref_map (helpers.py) now carries effectiveWeight and analyzerWeights through from the topology backend into each route ref, so the backends-collection view -- a second surface built from the same dicts -- gets them too. Present-and-None when there's no annotation, so the UI can tell "analyzer said nothing" from "analyzer said 0" and fall back cleanly. Frontend, both weight-rendering surfaces prefer effectiveWeight ?? weight: - F5BNKTopologyViewer route backends: shows the analyzer value (badged 'info' to distinguish it), falling back to the declared weight when absent. - BackendsCollection route refs: same preference, keeping the existing hide-the-default-of-1 behaviour. - TopologyRouteBackend / BnkBackendRouteRef / the viewer's local TopologyBackend all gain effectiveWeight?/analyzerWeights?. - TrafficFlowOverview reads only backend name/namespace, so it needed no change. Tests (all non-vacuous -- verified failing against the unpatched code): - helpers: map propagates effectiveWeight/analyzerWeights; absent -> None. - viewer: analyzer effectiveWeight (99) shown over declared (1); declared value stands when no analyzer weight. Reverting just the viewer render makes the first fail (renders 'weight 1'), confirming the assertion bites. Still wants one apply against a cluster with weighted L4Routes (the reporter's dynamo-system) to confirm the numbers now match the service-settings annotation end to end -- the tests prove the data flows, not that the analyzer emits what we assume. Fixes #8 Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-reviewI did this review before opening the PR, and it changed the shape of the change — so this documents what I checked and the calls I made. The review caught that a backend-only fix wouldn't fix anythingMy first cut was backend-only: parse the annotation, put
So there were two display surfaces and one shared upstream, and the backend-only change would have left both showing the declared weight. That's why the PR also touches Two backend types on the frontend — easy to miss one
The one design call, flagged again
Fallback semantics I made sure of
What I could not verifyRendering against a live analyzer. The tests prove the data flows and the components prefer the right field; they can't prove the analyzer emits the annotation shape I parsed. That's the "Environment validation needed" note on the PR, and it's the real last mile — worth one apply against |
mwiget
left a comment
There was a problem hiding this comment.
The plumbing is clean — annotation parsed once per route, both numbers carried through build_route_ref_map so the second surface can't drift from the first, declared weight preserved as fallback, and both frontends using the same effectiveWeight ?? weight expression. No complaints about the mechanics.
But this repo already reads this annotation, and it reads it differently.
frontend-v2/src/components/k8s/F5AIAnalyzerViewer.tsx (on staging, lines 244-303) parses k8s.f5.com/service-settings today. The PR body says the annotation was parsed "nowhere in the backend (grep-confirmed)" — true as written, and I confirmed it, but the qualifier is carrying a lot of weight. Two live interpretations of the same annotation in one product is itself the #8 class of bug, so these need to be reconciled before either is trusted.
Three specific ways they disagree, all sharing one failure mode: silent fallback to the declared weight, which looks exactly like the bug still being unfixed.
1. The top-level key: pool name or service name?
F5AIAnalyzerViewer documents the shape straight from F5 docs as {"pool-3":{"10.244.114.53":33,"10.244.114.54":34,"10.244.99.91":33}} and treats the key as a pool. _build_backend looks it up with analyzer_weights.get(br.get("name")) — a backendRef name, i.e. a Service. If SPK keys by pool and the pool name isn't identical to the service name, every lookup misses, effectiveWeight is always None, and the UI quietly shows the declared weight again. Nothing errors, nothing logs, and the tests pass because they construct the annotation with service-name keys — the assumption under test is the same one that would be wrong.
2. Summing per-pod weights may not produce a comparable number.
In the documented example the three per-pod weights sum to 100 for a single pool — they look like a distribution within a pool, not a share between pools. If that's the semantic, sum(per_pod.values()) returns ~100 for every backend regardless of its actual share, and a two-backend route renders "weight 100 / weight 100" — a different wrong answer than the one you're fixing, and a more convincing one.
Note F5AIAnalyzerViewer never collapses to an absolute per-service number for exactly this reason: it keeps each pool · ip separate and renders each as a percentage of the grand total across all pools. That's the interpretation that survives either semantic.
Your reporter sample {"vlm-vllm-agg-vlmfrontend": {"10.244.123.12": 99}} doesn't settle it — a single pod at 99 is consistent with both readings (99 of a 100-point within-pool split, or 99 against some other service's 1), which is why the single-backend case can't distinguish them.
3. isinstance(w, int) is stricter than the existing consumer's Number(w).
Line 258 drops any weight the analyzer emits as a float or a numeric string; clean comes out empty, the service is omitted from out, and it's the silent fallback again. The existing parser accepts both. Whatever the annotation really contains, the two readers should agree — and this one should probably match the more permissive existing behaviour.
What would settle it: the cluster check you already flagged, with one addition — dump the raw annotation from a route with two or more weighted backends and multiple pods, not just the reporter's single-backend case. That one sample answers all three questions at once: whether the key matches the service name, whether the per-pod weights sum to 100 per pool or partition across pools, and what numeric type they arrive as. Until then effectiveWeight's definition is a guess, and the failure mode is invisible.
I'd also reconcile with F5AIAnalyzerViewer either way — if your reading is right, that component is showing wrong percentages today and should be fixed alongside; if its reading is right, _build_backend needs to change. Worth resolving in this PR rather than leaving two answers in the tree.
Everything else reads correct: the malformed-annotation paths return {} and degrade safely, the declared weight is preserved for backward compatibility, analyzerWeights is exposed raw so a caller can reinterpret without another backend change, and the frontend's info badge makes analyzer-derived numbers distinguishable at a glance — which is a good touch, since it means an operator can see which reading produced the number on screen.
| (in which case the UI should fall back to the declared ``weight``). | ||
| """ | ||
| name = br.get("name", "") | ||
| per_pod = analyzer_weights.get(name) |
There was a problem hiding this comment.
This looks up the analyzer map by backendRef.name — a Service name. But F5AIAnalyzerViewer.tsx:249 documents the annotation's top-level key as a pool name, quoting F5 docs: {"pool-3":{"10.244.114.53":33,...}}.
If SPK keys by pool, every lookup here misses and effectiveWeight is permanently None — the UI silently shows the declared weight, i.e. #8 unfixed, with no error and green tests. The tests can't catch it because they build the annotation with service-name keys, which is the assumption under test.
| "port": br.get("port"), | ||
| "weight": br.get("weight"), | ||
| "analyzerWeights": per_pod, | ||
| "effectiveWeight": (sum(per_pod.values()) if per_pod else None), |
There was a problem hiding this comment.
The sum is only meaningful if per-pod weights partition a service's share. In the F5 docs example quoted in F5AIAnalyzerViewer.tsx:249 the three pod weights sum to 100 within one pool, which reads as a distribution within the pool — under that semantic this returns ~100 for every backend, and a two-backend route shows "weight 100 / weight 100".
F5AIAnalyzerViewer avoids the question by never collapsing to an absolute number: it keeps each pool · ip and renders a percentage of the grand total. Worth confirming against a multi-backend, multi-pod route before fixing the definition here.
| out: dict[str, dict[str, int]] = {} | ||
| for service, ip_weights in parsed.items(): | ||
| if isinstance(ip_weights, dict): | ||
| clean = {ip: w for ip, w in ip_weights.items() if isinstance(w, int)} |
There was a problem hiding this comment.
isinstance(w, int) drops floats and numeric strings; clean then comes out empty, the service is left out of out, and the caller falls back to the declared weight with no signal.
The existing consumer uses Number(w) (F5AIAnalyzerViewer.tsx:280), which accepts both. The two readers of this annotation should agree, and the permissive version is the safer default given neither of us has seen the raw value.
…faithfully
mwiget is right, on all three points, and the catch matters: this repo already
reads k8s.f5.com/service-settings in F5AIAnalyzerViewer, grounded in F5 docs,
and it reads it differently. My "parsed nowhere in the backend" was true but
carried the whole design on a qualifier.
The three disagreements, each a silent fallback that looks like the bug still
being unfixed:
1. Top-level key is a POOL (docs: {"pool-3": {ip: w}}), not a Service. My
per-backendRef lookup keyed by service name, so in production every lookup
would miss, effectiveWeight would always be None, and the UI would quietly
show the declared weight again -- a fix that closes the issue while doing
nothing. The tests passed only because they used service-name keys, i.e. the
same wrong assumption under test.
2. The per-pod weights in the docs example sum to 100 within one pool -- a
within-pool distribution, not a between-service share. sum() would then
render "weight 100 / weight 100": a more convincing wrong answer.
3. isinstance(int) dropped floats/numeric-strings the existing Number(w) reader
accepts -- another silent fallback.
None of that is settleable without a real multi-backend/multi-pod cluster
sample. So I'm not shipping the guess:
- Removed effectiveWeight, the per-backend attribution, _build_backend, the
helpers.py propagation, the frontend render swaps, and the type fields.
- _parse_service_settings now parses the annotation FAITHFULLY: pool-keyed
structure preserved, weights coerced tolerantly (int/float/numeric-string via
_coerce_weight, matching F5AIAnalyzerViewer's Number(w); bool excluded).
- The parsed annotation rides on the route as `serviceSettings` (the same shape
the existing consumer reads), available for a validated consumer without
another backend change. Backends keep the DECLARED weight -- unchanged on
screen.
This no longer changes the displayed weight, so it does not close #8; the
on-screen fix needs the cluster sample (dump the raw annotation from a route
with >=2 weighted backends across multiple pods) to settle key->service mapping
and the weight semantic. Reframed on the PR.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
You're right, on all three, and thank you — this is exactly the catch that needed to happen before anything shipped. I've backed the guess out (latest push) and reframed the PR to groundwork; it no longer closes #8, and I've converted it to draft since the real fix is blocked on the cluster sample you described. What I changed:
On your point #2 specifically — I agree the single-backend reporter sample can't distinguish "99 of a 100-point within-pool split" from "99 vs 1 between services," and that On reconciling I don't have cluster access to pull the multi-backend/multi-pod annotation, so I've left #8 open with that as the explicit next step. If someone can dump |
There was a problem hiding this comment.
Approving the re-scoped PR. Backing the interpretation out rather than patching around it was the right call, and retitling plus downgrading Fixes #8 to "Relates to #8 (does not close it)" matters as much as the code — a merge that auto-closed #8 while the reporter still saw the wrong number on screen would have been the worst outcome available here.
I verified the revert is complete: helpers.py, BackendsCollection.tsx, F5BNKTopologyViewer.tsx and f5bnk.ts are byte-identical to staging again, and _build_backend is gone. What remains is one additive field.
On the parse itself:
_coerce_weightchecksboolfirst, which is necessary and easy to get wrong —isinstance(True, int)isTruein Python, so without that branch a JSONtruewould have become weight1.0. MatchingNumber(w)for int/float/numeric-string means the two readers of this annotation can no longer disagree about what counts as a weight, which was the third finding.- Keeping the pool-keyed structure verbatim is the part that makes this safe to merge unresolved:
serviceSettingsasserts nothing about pool↔service mapping or within-pool vs between-service semantics, so nothing downstream can silently inherit a guess. - I checked the route (
routes/k8s/f5bnk.py:120) declares noresponse_model, so the new key actually reaches API consumers rather than being dropped by a schema — the same trap that would have made this dead weight.
The field has no consumer yet, which I'd normally push back on. It's justified here because the alternative is a second backend change once the cluster sample arrives, and because the shape is now the one F5AIAnalyzerViewer already reads — so a validated consumer can be written against it directly. Worth revisiting if that sample doesn't materialise; unconsumed data ages badly.
CI: P1 unit, P2 component and P2 legacy all green. P3 · Integration Tests · Backend was still running — it doesn't touch this path, and I'm watching it.
#8 stays open, and the cluster dump from a route with ≥2 weighted backends across multiple pods is still the thing that unblocks the real fix.
Status: groundwork, not a merge-ready fix for #8 — see the review thread
This started as "replace the declared backend weight with the analyzer's computed
effectiveWeight." @mwiget's review showed that reading is a guess that conflicts with an existing, F5-docs-grounded consumer (F5AIAnalyzerViewer), and would silently no-op or show a convincing wrong answer in production. So this PR no longer changes the displayed weight, and it does not close #8. It keeps only what's defensible without a cluster sample.What the review established (all correct)
k8s.f5.com/service-settingsis already parsed byF5AIAnalyzerViewer.tsx(onstaging), and it reads the annotation as pool-keyed ({"pool-3": {ip: weight}}, straight from F5 docs), rendering eachpool · ipas a percentage of the grand total — never collapsing to a per-service absolute. My original backend cut disagreed three ways, each a silent fallback to the declared weight that looks exactly like the bug unfixed:backendReflookup keyed by service name → every lookup misses in production →effectiveWeightalwaysNone→ UI shows the declared weight again. The tests passed only because they used service-name keys — the same wrong assumption under test.sum()would render "weight 100 / weight 100".isinstance(int)too strict vs the existingNumber(w)— drops floats/numeric-strings silently.What this PR now does
effectiveWeight, the per-backend attribution,_build_backend, thebuild_route_ref_mappropagation, the two frontend render swaps, and the type fields — all backed out._parse_service_settingspreserves the pool-keyed structure and coerces weights tolerantly (_coerce_weight: int/float/numeric-string, matchingNumber(w);boolexcluded), aligned with the existing consumer so the two readers can't disagree.serviceSettings(the same shapeF5AIAnalyzerViewerreads), available for a validated consumer without another backend change. Backends keep the declared weight — unchanged on screen.What's needed to actually fix #8
The cluster check @mwiget asked for: dump the raw annotation from a route with ≥2 weighted backends across multiple pods (not just the reporter's single-backend case). That one sample settles all three questions — pool↔service mapping, within-pool vs between-service semantics, and numeric type — and only then can the topology/backends views show a correct number. Until then, #8 stays open.
Relates to #8 (does not close it).
https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4