Skip to content

feat: add NSX and Avi as optional, independent products - #3

Open
warroyo wants to merge 7 commits into
mainfrom
feat/nsx-avi-optional-products
Open

feat: add NSX and Avi as optional, independent products#3
warroyo wants to merge 7 commits into
mainfrom
feat/nsx-avi-optional-products

Conversation

@warroyo

@warroyo warroyo commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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:

vkstack stack vcenter 9.1.0.0300              # neither — byte-identical to before
vkstack stack vcenter 9.1.0.0300 --with nsx   # NSX, no Avi
vkstack stack vcenter 9.1.0.0300 --with avi   # Avi on VDS, no NSX
vkstack stack vcenter 9.1.0.0300 --with nsx,avi
vkstack stack --avi 32.1.2                    # a pin is its own opt-in

Avi does not require NSX. nsx ↔ avi is Primary, 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. omitted says so on every output path, and --list drops the column rather than printing -.

Upstream decisions

Avi maps to 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 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: stack never 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, while check on the same stack said so. It now reports them, with notEnforced in JSON.

One solve context per request

Three of the five solve sites in the stack map built their own StackOptions and 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. lit moves as it should — for vCenter 9.1.0.0300: 28 → 32 (nsx) → 12 (avi) → 15 (both), matching edges 81/104/19/32.

ViableOptions also no longer reports alternatives for a product the same answer calls omitted, removing a silent coupling where --with avi and --with nsx returned 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

before after
refresh requests 11 22
cache 3.2 MB 5.5 MB
unpublished pairs 3 of 10 7 of 21
static first load (gzipped) 44 K 89 K
static build 1.6 s 2 m 19 s

The 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.sh clean; docs/model.md regenerates to a no-op.
  • TestCoverageMatchesUpstream passes against a live cache, confirming the four new gaps (NSX and Avi against VKS and VKr).
  • All 256 static lookups resolve, and the static lit hashes match the live server byte for byte.
  • New tests: 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 static output before publishing.

Known, not addressed here

  • ?with=nsx,avi costs ~7 s of CPU vs ~66 ms without, unauthenticated and uncached.
  • blockingProduct cannot distinguish "no releases in cache" from "all releases rejected".
  • Upstream products 789 and 1795 overlap on four identical version strings with different data; --avi 30.2.4 silently picks a side.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CbeArtjbnSDhbwzo1VBsRV

warroyo and others added 7 commits August 10, 2026 11:39
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant