feat: add NSX and Avi as optional, independent products - #3
Open
warroyo wants to merge 7 commits into
Open
Conversation
The matrix publishes NSX and Avi against the components already in scope, but neither was modelled, so anyone running them went back to the interop site by hand for exactly the pairs this tool exists to answer. Both are optional, and independently so. Neither appears in a solved stack unless it is pinned or named in `stack --with` / the MCP `include` array, and all four combinations are real deployments: neither, NSX alone, Avi alone (a Supervisor on a distributed switch), or both. Asking for one never brings in the other — Avi does not require NSX. A stack that says nothing about NSX is a complete answer about the five core components, not a claim the deployment has none, so `omitted` says so on every output path. Avi maps to upstream product 1795 "Avi Load Balancer", not the legacy 789 "NSX Advanced Load Balancer": 789 stops at 30.2.4, publishes no ESX data, and its newest vCenter coverage is below this tool's 8.0U3 floor. ESX x Avi is published and deliberately not enforced. Three of 2109 cells say yes, all Avi 32.1.1 against ESX 9.1.x; service engines are placed through vCenter, so vCenter is the pair that decides. `stack` now reports published non-dependency pairs it does not enforce, which it never did — it could hand back a stack containing an ESX and an Avi upstream marks NOT SUPPORTED without a word, while `check` on the same stack said so. One solve context is now built per request and threaded into every solve site. Three of the five in the stack map used to construct their own StackOptions and drop the include set, so `lit`, the node narrowing, the provenance badges and the ESX host annotation all described a stack without the optional layers the reader had opened while the recommendation and the edges described one with them: the map came back fully lit and drawn with a fraction of its connections, and clicking a lit node returned no stack. ViableOptions no longer reports alternatives for a product the same answer calls omitted, which also removes a silent coupling where `--with avi` and `--with nsx` returned different NSX alternatives for a stack containing neither. Refresh goes from 11 upstream requests to 22 and the cache from 3.2MB to 5.5MB; seven of the twenty-one pairs have no upstream data, up from three of ten. The static build ships one bundle per set of open optional layers, fetched on demand, rather than one file carrying all four. Opening a layer changes the whole answer, so the states cannot share storage; splitting them keeps the first load at 89K gzipped instead of 448K, since most readers open neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
One `/api/stackmap?with=nsx,avi` request cost 43 seconds of CPU. The request is 82 bytes, unauthenticated, uncached and rate-limited by nothing, so a `serve` on a routable address was a denial of service anyone could trigger by holding down refresh. Two causes, both fixed here. NSX and Avi constrained more than they need to. They are now enforced against each other, the Supervisor and vCenter, and nothing else; the ESX pairs are still published and still reported, but no longer enforced. vCenter and ESX move together and both optional products are already constrained against vCenter, so the host pair excluded nothing the vCenter pair did not — it only cost search to discover that. ESX x Avi was already informational for a different reason (three of 2109 cells say yes); ESX x NSX now joins it. The rest was asking the wrong question. ViableOptions calls the solver once per release of every product — a few hundred times per request — and only wants a yes or no, but it called Stacks, which assigns products in a fixed order so that the first complete assignment it finds is the *newest* one. That ordering is the answer for a recommendation and worthless for an existence check, and it is paid for on exactly the branches that do not matter: a yes returns on first success, a no has to exhaust the tree, and most of those answers are no. With Avi in the solve the fixed order assigned it fifth despite it being far and away the most constrained product, so every rejection was discovered at depth five rather than at the root. StackExists answers the same question with a most-constrained-first search, recomputed at each step, that gives up the moment a product has no candidates. The map's other existence checks — edge drawing, host narrowing, and the per-node provenance probe — use it too. Stacks is untouched, so which stack is recommended and the newest-first guarantee behind it are exactly as they were. TestStackExistsAgreesWithStacks checks the two never disagree across 208 pin/option combinations. with= 0.08s -> 0.09s with=nsx 0.13s -> 0.23s with=avi 1.78s -> 0.10s with=nsx,avi 43.06s -> 0.16s `vkstack static`, which is the same solves 260 times over, goes from 2m19s to 40s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
The popover carrying a node's builds, provenance and upstream notes had to park itself at the edge of the map frame: hovering traces a connection through the layers, and a popover floating over the node landed squarely on the lines it was opened to explain. Correct, but it left the details a long way from whatever the pointer was on, and further the taller the map got. A rail outside the frame cannot cover the drawing at all, so the constraint goes away. It also has a column to itself, which fixes two things the popover could only do grudgingly: the release list is no longer capped at sixteen lines, and the copy button is always there rather than appearing once a shift-click has locked the popover open. Content building moves into peekParts so both renderers share it and cannot drift about what a node says. Below 62rem there is no column to spare and the floating popover takes over unchanged; the media query and the JS watch the same breakpoint. The rail ignores the lock, which exists so a floating popover survives the pointer leaving — honouring it there would freeze the rail on one node while the reader hovered others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
… publishes Four changes that turned out to be one argument: say what the matrix says, at the granularity it says it, and stop letting incidental structure decide the answer. Report only the dependency chain. `stack` printed a footer naming published pairs it does not enforce, which fired on 63% of stacks — almost all of them vCenter x VKr, a pair that decides nothing because VKr is provisioned by VKS. A caveat that common is noise, and it invited the reading it was meant to prevent. `check` and `compat` still report those pairs, because there the caller named the combination and asked what is known about it. Name the constraint set for what it is. model.IsDependency became model.Constrains, and PairVerdict.Dependency became Constrains, because the flag answers "may this pair decide a stack", not "is this relationship real". ESX x NSX is where those diverge: NSX genuinely prepares the hosts as transport nodes, and it is still not enforced, because its published grid is nearly as permissive as the vCenter one and the two move together. The old name asserted the dependency did not exist. The JSON key stays "dependency" so callers do not break. Show patch releases by default. The definition of a patch is upstream's, and verified against it: the matrix endpoint takes an isHidePatch flag, and setting it drops exactly the releases typed "Patch" — "Maint", "Maintenance", "Unknown" and untyped releases all survive. The default is not upstream's. Compatibility is published per release and the patch letter routinely decides it: vCenter 8.0U3 takes Supervisor 1.26-1.28, 8.0U3k takes 1.31-1.33. A solver that cannot see 8.0U3k cannot find the stack that works. IncludePatches inverts to HidePatches so the zero value shows them; --patches survives as a deprecated no-op. Let the platform decide which stack is newest. The recommendation is the first complete assignment the search finds, so the fill order silently decides which product's newness the stack inherits. With Supervisor first, pinning VKr 1.36 returned Supervisor 1.33.9 on the vsc0 train — whose only vCenter is 8.0U3k — and announced a vSphere 8 stack while a 9.1 one carrying the same VKr went unreported. solveOrder stays most-constrained-first for StackExists, where only the yes or no matters; Stacks now uses recommendOrder, which fills vCenter first. On the map, a grouped node no longer hands over its newest member blindly. Upstream's newest build in a line is regularly its most restrictive, because the grid has not been backfilled: NSX 9.1.0.0 reaches VKS 3.7 while 9.1.0.0200 stops at 3.6, and Avi 32.1.2 reaches no stack at all while 32.1.1 does. setPin picks the build that reaches furthest and says so when that is not the newest. Node ids now come from one nodeID function — they were spelled out at two call sites, and a layer once fell out of the picture entirely because the two stopped matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
The existing tests build small synthetic graphs. They prove the solver obeys the rules it is given, and say nothing about how those rules behave on upstream's actual shape — which is where every problem this suite was written for came from. Six invariants, run against a refreshed cache and skipped unless VKSTACK_TEST_CACHE points at one, so `go test ./...` stays hermetic and offline: every constraining pair in a solved stack is a published yes; pins survive into the answer; the five required products are always present; opting into one optional product never drags in the other; IsPatch matches upstream's own isHidePatch definition and fails on a release type nobody has classified; and the recommendation sits on the newest vCenter that can carry the pin. Two are deliberately written against the property rather than the mechanism, so they cannot pass by construction. The platform check finds the newest workable vCenter with StackExists per release instead of re-deriving the fill order. The per-release check looks for members of one version line that disagree about a peer — it finds 11,363 such pairs — and fails if that disagreement stops being visible, which is what a rollup to the line would look like. What is deliberately not asserted: that no stack contains a pair upstream marks NOT SUPPORTED. Pairs outside the constraint set are allowed to say no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
… unenforced Adding NSX and Avi moved the counts every surface quotes in its own help text, and nothing checks those strings, so they still described the five-product model: `refresh` and `check` promised ten pairs with three unpublished, `vkstack_products` the same ten, `vkstack_model` and `explain` five products. It is 7 products, 21 pairs, 14 published and 7 not — `explain` has been drawing NSX and Avi for two commits while its own summary line denied they existed. One of these was wrong rather than merely stale. `describe`'s enforcedPairs listed vCenter × VKS, vCenter × VKr and ESX × Avi as published-but-not-enforced and omitted ESX × NSX, which left the constraint set it publishes disagreeing with model.Constrains since the pair was narrowed. An agent reading describe to decide what a pair proves would have concluded the host pair decides an NSX stack. It now names all four, says ten of the 21 constrain, and gives the reason the host pairs do not: vCenter and ESX move together and both optional products are already constrained against vCenter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds NSX (upstream product
912) and Avi Load Balancer (1795) alongside the five components already in scope. The matrix publishes both against vCenter, ESX and the Supervisor; until now anyone running them went back to the interop site by hand for exactly the pairs this tool exists to answer.Optional, and independently so
Neither appears in a solved stack unless it is pinned or asked for. All four combinations are real deployments:
Avi does not require NSX.
nsx ↔ aviisPrimary, but that means "constrains when both are present", not "required" —consistent()only iterates assigned products, so an Avi-only stack never consults the pair. A fixture release compatible with no NSX locks this in: it solves alone and fails only once NSX is opted into.A stack that says nothing about NSX is a complete answer about the five core components, not a claim the deployment has none.
omittedsays so on every output path, and--listdrops the column rather than printing-.Upstream decisions
Avi maps to
1795"Avi Load Balancer", not the legacy789"NSX Advanced Load Balancer". 789 stops at 30.2.4, publishes no ESX data, and its newest vCenter coverage is below the 8.0U3 floor.ESX × Avi is published and deliberately not enforced. Three of 2109 cells say yes, all Avi 32.1.1 against ESX 9.1.x. Service engines are placed through vCenter, so vCenter is the pair that decides; enforcing this would rule out Avi deployments that plainly work.
That exposed a real gap:
stacknever reported published non-dependency pairs at all, so it could hand back a stack containing an ESX and an Avi upstream marks NOT SUPPORTED without a word, whilecheckon the same stack said so. It now reports them, withnotEnforcedin JSON.One solve context per request
Three of the five solve sites in the stack map built their own
StackOptionsand dropped the include set.lit, the node narrowing, the provenance badges and the ESX host annotation described a stack without the optional layers the reader had opened; the recommendation and the edges described one with them. The map came back fully lit and drawn with a fraction of its connections, and clicking a lit node returned no stack.Now one probe is built per request and threaded everywhere.
litmoves as it should — for vCenter 9.1.0.0300:28 → 32 (nsx) → 12 (avi) → 15 (both), matching edges81/104/19/32.ViableOptionsalso no longer reports alternatives for a product the same answer calls omitted, removing a silent coupling where--with aviand--with nsxreturned different NSX alternatives for a stack containing neither.Web map
NSX and Avi are collapsed rows between vCenter and the Supervisor, each with its own toggle and URL state — opening one never opens the other. Dead ends now carry the solver's reason and a way out (
{"reason": "No Supervisor release works with the selected Avi.", "closeLayer": "avi"}). A pin on an optional layer refetches instead of rendering a selection wired to nothing, and a?with=that resolves to nothing no longer suppresses the default pin.Costs
refreshrequestsstaticbuildThe static build ships one bundle per set of open optional layers, fetched on demand. Opening a layer changes the whole answer, so the four states cannot share storage — a single file carrying all four was 448 K gzipped and 8.4 MB parsed, for states most readers never enter.
Verification
gofmt,go vet,go test ./...,dash -n install.shclean;docs/model.mdregenerates to a no-op.TestCoverageMatchesUpstreampasses against a live cache, confirming the four new gaps (NSX and Avi against VKS and VKr).lithashes match the live server byte for byte.TestOptionalProductsAreIndependentlyOptIn,TestAviSolvesWithoutNSX,TestNSXAviPairOnlyAppliesWhenBothAreChosen,TestViableOptionsFollowsInclude,TestStackMapLitFollowsOpenLayers,TestStackMapDeadEndExplainsItself.Not verified: the static build was checked by resolving every lookup the client can make and by serving the files, not by clicking through it in a browser. Worth one manual pass over a
vkstack staticoutput before publishing.Known, not addressed here
?with=nsx,avicosts ~7 s of CPU vs ~66 ms without, unauthenticated and uncached.blockingProductcannot distinguish "no releases in cache" from "all releases rejected".789and1795overlap on four identical version strings with different data;--avi 30.2.4silently picks a side.🤖 Generated with Claude Code
https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV