Conversation
… to be sorted alphabetically
…election from user selection state
…, remove forceRefresh
… to a version that no longer requires six
… Endpoint list
…ions The suite used capitalized Function and Param, making it the only PowerShell file in the repo not following the lowercase keyword style that PSScriptAnalyzerSettings.psd1 describes and every other script applies. It also carried the repo's only analyzer Error, from normalizing an Azure access token to a SecureString. Suppress it inline with the same justification already used in deploy.ps1, migrate.ps1 and update.ps1. The suite now reports no analyzer findings at any severity.
version.ps1 declared ValueFromPipelineByPropertyName on all four parameters without a process block, so piping several objects would have silently processed only the last. Nothing pipes to the script, and no comparable script in the repository declares pipeline binding -- update.ps1 has 81 parameters and migrate.ps1 41, both with none. deploy.ps1 is the only script that does, and it implements begin and process blocks to match. Removing the unused bindings leaves the parameter sets and every CI invocation unchanged. version.ps1 also called get-date -format in lowercase, where every other script uses Get-Date -Format, and carried a trailing space in its header banner. Separately, all five scripts declared $logPath and then referenced $logpath on the very next line when creating the log directory. PowerShell variable names are case-insensitive so the behaviour was correct, but the mismatch had been copied into every script. Corrected in deploy.ps1, migrate.ps1, update.ps1 and version.ps1; build.ps1 is handled separately. No behavioural change.
The lint job covered the UI, engine, Bicep templates and Dockerfiles, but no PowerShell file was ever analyzed, which is how the Pester suite drifted from the repo conventions and accumulated the only analyzer finding in the tree. The step is scoped to the four rules PSScriptAnalyzerSettings.psd1 configures. Running PSUseCorrectCasing alongside the full default rule set intermittently crashes the analyzer through a thread-safety defect in its command cache, which would fail the step on a clean tree roughly a third of the time. That is upstream PowerShell/PSScriptAnalyzer#1708, open since 2021 and present in both 1.24.0 and 1.25.0, so pinning an older version does not avoid it. Verified in a clean Ubuntu container: the step passes on the current tree, fails on reintroduced casing drift, and fails rather than passing vacuously when no scripts are found.
…package Add a wheel-tag layer to the native module gate in the build script. After pip install, each *.dist-info/WHEEL is parsed and a distribution is rejected unless one of its tags is `any` or a manylinux at or below the glibc ceiling derived from PIP_PLATFORM. This catches locally compiled wheels (bare linux_x86_64) and over-new ones (manylinux_2_28/2_34) by name, before the ELF symbol scan has to find them. The gate is now three layers: ABI naming, wheel tag, GLIBC_ symbols. The scan additionally records the highest glibc symbol required by any bundled module and reports it on every build rather than only on failure. Write build.json to the archive root, recording the build timestamp, app and python versions, pip platform, glibc ceiling versus observed, native module count and the resolved wheel tags per package. Air-gapped clouds cannot share build logs, so the artifact has to be able to identify itself. Resolve the engine's Python version from the archive being deployed rather than the local checkout, and let configuration drift retarget LinuxFxVersion to match. An archive built for one Python version could previously be deployed onto a site configured for another with nothing detecting the mismatch; the bundled wheels are ABI-specific, so every native module silently becomes unimportable and the failure surfaces at startup rather than at deploy time. Gate WEBSITES_INCLUDE_CLOUD_CERTS on the cloud rather than the deployment shape, in the deployment modules, the staging slot modules and the update script's target settings map. The trust store is a property of the cloud; the run-from-package shape is not. The setting consequently now also reaches container deployments in sovereign clouds, which the previous nesting excluded. Add a hidden -RunFromPackage switch to the deployment and update scripts, threaded through to a forceRunFromPackage template parameter, so the internet-restricted deployment path can be exercised on clouds that would otherwise build on the server. The switch is marked DontShow and is intentionally undocumented. Document deployments that report success without changing the running application, covering how to confirm which package is actually mounted, package retention, and the expected virtual environment warning. Refs: GLIBC_2.33 import errors reported from IL6
…or emoji The info, warning, and gear icons are written as a base character followed by U+FE0F (variation selector-16). Those base code points have a neutral or ambiguous East Asian Width, so the terminal advances one cell while the font paints a two-cell emoji glyph. The icon overdrew the space that followed it and messages rendered with the text jammed against the icon. Pad all 48 U+FE0F sequences in update.ps1 and migrate.ps1 with a second space so the separator survives the overdraw. Icons that carry the Emoji_Presentation property are already measured as wide and keep a single space, so they are left alone. Also normalize the sole pair of curly quotes in the repository to straight quotes in deploy.ps1. PowerShell's tokenizer accepts U+201C and U+201D as string delimiters, so the debug flag lookup always resolved correctly; this is a cosmetic change that leaves the file entirely ASCII.
Pre-v4 Reservation auto-fulfillment appended a Virtual Network to a Block's association list without checking whether it was already present, so a network carrying several Reservation IDs for the same Block was recorded once per ID. Each surplus entry was counted again by the utilization arithmetic, the removal endpoint deletes only the first matching index, and re-adding was refused while any copy remained. Reconciliation now reuses an existing association rather than appending, but that prevents new duplicates without undoing the ones already written. The v4 baseline convergence step collapses them, keyed on the lowercased resource ID so copies differing only by Azure's inconsistent casing are caught as well. The first occurrence wins, preserving both the stored spelling and the list order, and 'active' is OR-merged so a duplicate whose first copy was inactive does not read inactive until the next reconciliation pass. The step writes only where it finds a duplicate, so it is a no-op on a clean database and safe to re-run, as the convergence module requires. Affected deployments will see Block and Space utilization fall once it runs. Each surplus entry was counting the same address space an additional time; no address space is released. Fixes #349
…to a Block
The existing-association guard in `create_block_net` compared the submitted
Azure resource ID against the stored IDs verbatim, and was the one comparison
in that handler the case-insensitivity sweep did not reach.
Azure returns resource IDs with inconsistent casing, and Azure IPAM stores
whichever casing the client supplied. A network already associated under
different casing therefore slipped past the guard and was caught instead by
the CIDR overlap check below, which does resolve IDs case-insensitively — so
the network was reported as overlapping its own stale entry, with the
misleading error "Block already contains network(s) and/or reservation(s)
within the CIDR range of target network". Deleting the entry from Cosmos DB
by hand was the only way to recover.
The comparison now lowercases both sides, matching every other ID comparison
in the handler, and the condition is reported as what it is.
Only POST /api/spaces/{space}/blocks/{block}/networks was affected; the
Azure IPAM interface associates networks through PUT .../networks, which
replaces the list rather than appending to it.
fixes #329
A Virtual Network or vWAN Hub reported `parent_space` as a single value alongside `parent_block` as a list, but a Block is identified only by the pair of Space and Block name, since Block names are unique within a Space rather than across a tenant. A network associated to Blocks in two Spaces returned both Block names against whichever Space happened to match first, and the second Space was unrecoverable from the response. Three consumers had each reconstructed the pairing independently, and each failed on that data. The Planner joins every Block name against the reported Space and skipped any Block it could not find, dropping the network from the view with nothing but a console warning. External Network validation gathered a Block's occupied prefixes through the same join, so a network whose reported Space differed was skipped and its address space read as free — client-side only, as `create_external_network` repeats the check and refused the request rather than writing an overlap. Block rename matched on Block name alone, so renaming a Block relabelled same-named Blocks in other Spaces. Both fields are replaced by `parent_containers`, a list of Space and Block pairs. It reuses the `CIDRContainer` model that `POST /api/tools/cidrCheck` already returns, which moves to the shared response models rather than being duplicated a second time. An unassociated network now reports an empty list instead of a pair of nulls, removing a null check from every consumer. The four comparisons that resolve a network to its Blocks were also still case-sensitive, so a stored ID whose casing differed from the one Azure returned made a managed network report no Space or Block at all. They now match the rest of the engine, and the Block list they search is built once per request rather than once per network, against a pre-lowered set. `/api/azure/*` is registered with `include_in_schema = False` and appears in no documentation, example or test, so no published contract changes. The Discover grids and the search bar keep their existing shape, through flattened projections derived in the Redux selectors.
Every Discover drill-down and every search bar selection navigates with a filter descriptor, which `toAgGridFilter` converts into an AG Grid filter model. That conversion never read the `operator` the caller set and hardcoded AG Grid's `contains`, so every text filter was a substring match. Drill-down therefore returned more than it was asked for. A Block named `Prod` returned the networks of `Production` alongside its own, and the same applies to a Space, a Virtual Network or a subnet whose name is a prefix of another. Nothing was mis-reported, but the row set silently included rows belonging to a sibling. The pre-AG Grid implementation merged the whole descriptor onto its own filter object, so `operator` was honoured without a mapping and none was ever written. The port rebuilt the descriptor field by field and dropped it. `operator` is now mapped to an AG Grid filter type, and drill-down asks for `equals`, since it wants the children of one named parent. The search bar continues to send `contains`, where a substring match is the intent; its `like` wording is display text for the dropdown and was never an operator. Exact matching needs an array-aware matcher for the Block column, whose filter value is its elements joined into one string -- `equals` against "BlockA, BlockB" could never match a network associated to both. The unused `arrayTextFilterComparator` is replaced by `arrayTextMatcher`, which compares each element and honours the filter option in use. Drill-down still identifies a parent by name alone, so a Block, Virtual Network or subnet sharing a name with one under a different parent still returns both. That needs the drill-down to carry a qualified parent identity and is left alone here.
`GET /api/spaces/{space}/blocks/{block}/available` filtered a network's
prefixes individually and kept the network if any one of them survived, then
overwrote the response with only the surviving prefixes. The association
handlers evaluate the whole network: `PUT` and `POST .../networks` collect
every prefix falling inside the Block and refuse the request if any of them
overlaps an External Network or an outstanding Reservation.
A network with several prefixes inside the Block, one of them overlapping,
was therefore offered as available with the overlapping range removed from
the response. Selecting it was refused with "Network list contains CIDR(s)
that overlap external networks", naming neither the network nor the range,
and the range in question had been stripped from the payload the caller was
shown. The same list is returned to automation without expansion, so an ID
that `PUT .../networks` will reject was handed back as associable.
A network is now excluded outright if any of its in-Block prefixes is
occupied, which is the same basis the association handlers use. Prefixes
outside the Block are still not consulted, so a network drawing address
space from more than one Block is unaffected.
Networks whose address space overlaps one already associated to the Block
are still offered, as the interface replaces the association list rather
than appending to it and a user may deselect one to select the other.
Networks whose in-Block prefixes overlap an External Network or an unfulfilled Reservation are withheld from the available list. That is correct, but silent: the caller learns nothing about the network it expected to see, nor which range stands in its way. Add an include_blocked flag to the available networks endpoint, which returns those networks alongside the rest, each carrying a blocked_by entry naming the External Network or Reservation occupying each prefix. The flag requires expand, as the unexpanded response is an array of resource IDs with nowhere to carry a reason. blocked_by lives on a new NetworkExpandBlocked model rather than on NetworkExpand, which is shared with the associated networks endpoint where an always empty blocked_by would be meaningless. The field is required rather than defaulted, so the response model union resolves a plain network to NetworkExpand and a reported one to the new model. Attribution compares every in-Block prefix against every occupant, so it is computed only for networks that are actually blocked, and only when the flag is set. The default response is unchanged.
Blocked networks are now returned and rendered, which is what makes the refusal explainable, but a Block carved up with External Networks can produce a long list of them and bury the networks that are actually available. Add a Showing Available / Showing All toggle to the grid menu, defaulted to hiding blocked networks and reporting how many are hidden, so the list stays quiet day to day without the blocked ones becoming invisible again. A blocked network which is still associated is never hidden. It is a state the user has to act on, and the row would otherwise be submitted on save while not being visible.
Replacing the networks on a Block reported that the list contained a conflict without saying which entry caused it, leaving the caller to find it by inspection across a list that can run to dozens of networks. The three overlap checks tested the merged CIDR set, which had already discarded the attribution, so each now compares per network and collects the offenders. The raise conditions are unchanged: a merged set intersects only when one of its members does, and the existing ordering still decides which error wins. This brings the handler into line with itself, as its invalid ID and outside Block CIDR errors already name the entries at fault.
Save was offered whenever the selection differed from what the Block holds, even when the selection contained a network the engine will not accept. Because networks are replaced as a whole, that refusal also took down any unrelated change made in the same edit. Two kinds of network cannot be associated: one whose prefixes overlap an External Network or an unfulfilled Reservation, and one which no longer resolves in Azure. Both open already selected, as both are associated to the Block, and the second explained nothing about itself. Disable Save while either is selected and name it in the button tooltip, and give an unresolved network the same row tooltip a blocked one already had. Report the number of each beside the selection count, so an anomaly is apparent without scrolling the grid to find it. These are facts about the Block rather than about the current view, and deliberately do not track how many rows the filter happens to be hiding.
The eligibility rules described the overlap checks without saying that they disqualify an entire network, which is the behaviour that surprises people: a virtual network with several prefixes in the Block is refused outright when any one of them collides. State that rule plainly, document the blocked networks the grid now shows and the toggle which hides them, and note that Save is refused while an ineligible network is selected. Also record the side effect of an External Network, which withdraws the Azure address space it covers from association in that Block. Closes #200
The no-rows overlay was declared with React.useCallback inside each consumer, producing a component type whose identity changed whenever its dependencies did. React responded by unmounting and remounting the overlay rather than reconciling it, and ESLint reported all nine as nested component definitions. An overlay is static content and does not need to be a component type. The grids now accept a rendered element, consumers build it with useMemo, and a dependency change yields a new element of the same type rather than a new type. Reactivity through OverlayContext is unchanged, as the reference still changes when the dependencies do. The admin type header was a separate case, closing over nothing at all, and moves to module scope alongside the other cell components. This clears the last of the ESLint warnings, so the lint gate now runs clean rather than against a baseline of ten.
Three AG Grid defaults worked against the row states the association grid now renders. Tooltips are not shown until an element has been hovered for two full seconds, which reads as no tooltip at all rather than a slow one, so a cell carrying an explanation appeared to carry nothing. The delay is lowered to 500ms. The tooltip element sets white-space to normal, collapsing newlines, so a message describing several ranges ran them into a single line. It is overridden to pre-line, which honours the newlines while still wrapping genuinely long text. A checkbox on a row excluded by isRowSelectable is rendered disabled rather than omitted, and a disabled checkbox still takes focus, so clicking a row that can never be selected left a focus ring on a control with no effect. Those checkboxes are now hidden instead, which only affects grids supplying isRowSelectable.
The grid tints rows which are stale or blocked and reports a count of each, but nothing connected the two, so the colours had to be learned rather than read. Add a legend beside the counts, each entry carrying a swatch in the colour of the rows it describes. The tints and the selected row colour are declared once and consumed by both the grid styling and the legend, so they cannot drift apart; selected reproduces AG Grid's own accent mix over the grid background rather than approximating it. Reaching the refusal reason no longer depends on finding the name column, as the tooltip is attached to every column, and each overlapping range is listed on its own line rather than run together. The blocked count reports how many of those networks the filter is actually hiding, which is lower than the total whenever one of them is still associated. The grid menu returns to the wording used for settled Reservations, the count having moved to the toolbar.
The blocked and stale network sections were written against the behaviour as designed, and testing moved it: the reason is now shown from any cell rather than the name column, each overlapping range is listed on its own line, blocked rows render no checkbox at all, and the blocked count moved out of the grid menu. Also document include_blocked alongside expand for direct API consumers, including the blocked_by shape it adds and the 400 returned when it is used without expansion. Remove the screenshot checklist, which had fallen out of date with the interface it described.
Block names stopped accepting slashes when the character was dropped from the name pattern, but the messages raised on rejection still list slashes as permitted, for Blocks and for External Networks, Subnets and Endpoints alike. Following that advice produces a name the API cannot address: a slash splits the request URL into extra path segments, so every route for the resource falls through to the /api catch-all and answers "Invalid API path.", leaving it unreadable, unrenamable and undeletable. Percent-encoding offers no escape, as the path is decoded before it is routed. Several messages also quote limits their patterns have outgrown, promising 32 characters where the regex allows 64, and 64 for descriptions where it allows 128. Each message now states the rule it is raised for. Validation behaviour is unchanged.
A drill-down filtered on the parent's name alone, but only a Space name is unique on its own. Drilling into a Block returned the networks of every Block sharing that name, across every Space, and drilling into a vNet returned the subnets of every vNet sharing that name. Each drill-down now filters on every column needed to identify its parent: a Block by its Space and name, a vNet by its name, resource group and subscription. The Space a network belongs to is shown on the network grids so that filter stays visible and editable, as the search bar's does. The icon deciding whether a row has children matched on name too, so a Subnet called "default" offered a drill-down into another Subnet's endpoints, and it now matches on identity. Two faults surfaced while testing this. A Block's identity, which pairs its name with its Space, was only stamped on fetch, so creating or renaming one left it stale. Associating a network recorded it on the Block but not on the network, whose parent containers the grids read, leaving the network unassigned until the next refresh; it is now mirrored the way a rename already is, without another call to the API. Networks in Blocks across several Spaces can still match a Space from one Block and a name from another. Pairing them needs a blended column serving only this filter, so the extra rows are accepted and noted in the config.
The OpenAPI specification requires that a header parameter named `Authorization` be ignored, so the per-endpoint token box rendered by the Swagger UI silently discarded whatever was entered and every "Try it out" call failed with a 401. Declare the token as an HTTPBearer security scheme so the global Authorize button attaches it. The scheme is wired as a sub-dependency of `validate_token` rather than as a separate router dependency, so an endpoint cannot advertise authentication without also enforcing it. Replace the 76 `Header(None, ...)` parameters with `Depends(get_authorization)`, which keeps the header available to the route bodies without emitting the parameter OpenAPI discards. Parameter order is preserved so the existing positional calls between route functions still bind correctly. Also document the 401 response, previously reported as "Undocumented", and remove the long-dead commented-out IPAMToken class. Behaviour is unchanged for existing UI and API clients: status codes and response bodies are identical for valid tokens, missing headers, malformed schemes, and expired tokens. Fixes #99
`validate_token` wrote the tenant ID onto `request.state` and `check_admin` wrote the admin flag, which `get_tenant_id` and `get_admin` then read back. Nothing in the dependency graph expressed that ordering, so a route that used either accessor without also declaring the auth chain raised AttributeError and returned a 500 instead of a 401. Return the values instead: `check_admin` now takes the validated payload and returns the flag, and the accessors depend on the function that produces what they need. FastAPI caches dependencies per request, so token validation and the Cosmos admin lookup still each run exactly once. No router changes are needed, as every call site already went through `Depends(get_admin)` or `Depends(get_tenant_id)`. The generated OpenAPI document is byte-identical.
Contributing was 77% development content: the Docker Compose environment and the container build instructions were filed under a page about CLAs and pull requests. Split it into three sections, with Contributing left as the process of getting a change accepted, Development as working on the code, and Conventions as the patterns to follow while doing so. Conventions records the places where the obvious approach is the wrong one: how admin restrictions are declared and documented, why the API token is a security scheme rather than a header parameter, and why synchronous HTTP clients are banned. The sidebar stays flat so docsify continues to generate sub-entries from each page's headings; a nested entry suppressed them for the parent page.
…entation
Admin restrictions were enforced by inline `if not is_admin` checks scattered
across the routers, and were invisible in the API documentation. Callers had no
way to tell which endpoints required admin without reading the source.
Replace the inline checks with two dependencies: `require_admin` for endpoints
that are admin-only, and `admin_expand` for endpoints where only the `expand`
parameter is restricted. A generator in the new app/openapi.py module walks each
route's dependency tree and annotates the schema, so the documentation is derived
from the enforcement and cannot drift from it. Parameter-level gates carry the
name of the parameter they guard via `@admin_flag_gate`, so there is no registry
to keep in sync.
Marking is best effort and never fatal. If route introspection breaks, the engine
still serves a valid schema and logs the number of marked operations.
Consolidates two wordings of the same 403 ("API restricted to admins." used 29
times, "This API is admin restricted." used 9 times) into one. Nothing consumes
the string; the UI displays it verbatim. Ownership checks are left inline, as
they are not admin restrictions.
Routes took the full Authorization header and every consumer immediately stripped the "Bearer " prefix from it, nineteen times across the routers and their helpers. The header value was never used for anything else. Depend on `get_token_auth_header` instead, which already existed and already parsed the header, and pass the token itself down to the helpers. This removes the duplicated parsing and deletes `get_authorization`, which only existed to hand the raw header to callers that did not want it. Error responses are unchanged: router-level dependencies resolve before route parameters, so token validation still reports the failure. The generated OpenAPI document is byte-identical.
The security scheme description was a single run-on line in the Swagger UI Authorize dialog. Swagger renders these as Markdown, so use blank lines for paragraphs rather than inline HTML. This also lets Swagger generate the link, which it marks target="_blank" rel="noopener noreferrer".
The Planner laid tiles out as fractions of a 12-column MUI Grid, which sizes
by row position and knows nothing about what it contains. Every viewport
where the fraction landed below the width of a CIDR truncated the mask to an
ellipsis, so 10.183.119.224/31 rendered as 10.183.119.224/... and the only
thing distinguishing one tile from the next was hidden.
The xxl={1} rule made it visible by packing twelve tiles per row at 1920px,
leaving 141px for text needing 150px. Earlier fixes walked the font clamp
down from 20px to 16px to 15px and added an ellipsis to absorb the overflow;
each one moved the breakpoint where the text stopped fitting rather than
removing it.
Lay the tiles out with CSS Grid auto-fill instead, declaring a minimum tile
width and letting the browser choose the column count. The constraint now
runs from the content outward, so no viewport can produce a column too
narrow for the longest possible CIDR. Verified across 320px to 3840px in
20px steps against 255.255.255.255/32 with no truncation.
The minimum is expressed in pixels because the font clamp is: a rem-based
minimum shrank with a reduced browser root font size while the px-clamped
text did not, reintroducing the same truncation at 14px and below.
Drop plannerTheme with it. It existed only to add the xxl breakpoint, and
what remained restated MUI's default values against a root theme that never
overrode them.
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.
Azure IPAM v4.0.0
This is a major release that delivers significant framework upgrades, a data grid migration, authentication modernization, comprehensive documentation overhaul, revamped examples, and numerous bug fixes.
Major Framework & Dependency Upgrades
forwardRefwrappers (ref-as-prop), removal ofPropTypes, explicitnullforuseRef()calls, and migration ofLoadingButtontoButton@azure/msal-browser4.x → 5.x,@azure/msal-react3.x → 5.x): Removed obsolete config, consolidated event types, fixed silent token timeout recovery (timed_outerror code), and prevented iframe fallback timeout loopsag-grid-community/ag-grid-react36.x): Complete migration to AG Grid including centralizedDataGridcomponent, custom styling, column state persistence, unified data loading overlays, and custom cell renderers (drill-down, info, progress)vite7.x → 8.x,@vitejs/plugin-react5.x → 6.x): Migrated to Vite 8 which replaces Rollup with Rolldown and esbuild with Oxc for bundling, transforms, and minification. Removedvite-plugin-eslint2(redundant with editor-based linting and incompatible with Vite 8)@eslint-react/eslint-pluginv5,eslint-plugin-react-hooksv7 — which now consolidates the React Compiler lint rules per the React Compiler 1.0 release), replacingeslint-plugin-react. Addeddist/ignore, fixedno-useless-assignmentviolations, and removed unusedeslint-plugin-jest. Resolved all remaining lint warnings as part of the React 19 modernization —useContext→use,<Context.Provider>→<Context>, ref naming conventions, stable list keys, hoisted static styled components, migration ofSnackbarUtilsto notistack's standaloneenqueueSnackbar, and moved "ref assigned during render" patterns intouseEffect@mui/material7.3.x → 9.4.x,@mui/icons-material7.3.x → 9.4.x): Major two-version jump. Migrated deprecated component props to the unifiedslots/slotPropsAPI (largely via@mui/codemod), moved deprecated system props intosx, replaced the removedUnstable_Grid2with the new defaultGrid(usingsize={{ xs: N }}), renamed removedOutline(no "d") icon exports to theirOutlinedcounterparts, and removed@mui/lab(no longer needed —LoadingButton'sloadingprop is now native toButton)react-router7.x → 8.x): Major version upgrade. The UI uses declarative-mode routing (BrowserRouter/Routes/Route) with all imports already sourced fromreact-router, so no application code changes were required. v8 raises the minimum runtime to Node.js 22.22.0 (and React 19.2.7+)momentreplaced withdayjsandlodashwithlodash-es; removedweb-vitalsand the React Testing Library packages (@testing-library/jest-dom,@testing-library/react,@testing-library/user-event), which the UI carried without a single test file ortestscript to use themEngine & Backend
msal,azure-common,azure-keyvault-secrets, andsix; addedazure-mgmt-resource-subscriptionsto address Azure SDK module separationpyproject.tomlwith Ruff linter configuration (pycodestyle, pyflakes, isort) and resolved every resulting violation across the engineX-IPAM-RES-IDtag, and reconciliation evaluates each independently. Fulfilling one appended the network to the Block's association list without first checking whether it was already there, so a network with two Reservation IDs was added twice and one with three, three times — the same network, with the same prefixes, recorded as though it were several. Every consequence followed from that list being walked as data:add_block_utilizationcounted the network's in-Block prefixes once per entry, so the Block and its Space reported more address space in use than was in use; the removal endpoint deletes the first matching index and returns, so an administrator had to remove the association once per copy, with nothing to explain why it kept reappearing; and re-adding was refused while any copy remained. Fulfillment now reuses an existing association and marks it active rather than appending. Because the reconciliation fix prevents new duplicates but cannot undo the ones already written, a convergence step collapses them during the upgrade; it is guarded to write only where it finds a duplicate, so it is a no-op on a clean database. Affected deployments will see Block and Space utilization fall once it runs — the earlier figure counted the same address space more than once, and no address space is being released (fixes Assignment of multiple reservations from an address block on a single VNET #349)IPNetwork(resv['cidr']) in IPSet(existing_block_cidrs)— a containment test, true only where the reserved range sits entirely inside space already accounted for. The opposite arrangement passed silently: a Reservation containing an already-associated network, or straddling its edge, is not contained by it, so the check found nothing, the Reservation was fulfilled, and its Virtual Network was associated alongside the one it overlaps —10.43.2.192/26against an existing10.43.2.192/28being the reported shape. That is the one state an IPAM exists to prevent, and once written it persisted, since nothing re-examines existing associations for overlap. The test is now an intersection of the two sets, which is symmetric and catches a partial overlap in either direction. The status raised on detection is renamed fromerrCIDRExiststoerrCIDROverlapto describe what is actually being reported; the interface renders both, so a Reservation already carrying the old value still displays correctly. Overlaps already recorded are not unwound automatically, as Azure IPAM cannot know which of the two associations is the one that should not be there —POST /api/tools/cidrCheckenumerates them along with the Space and Block each network belongs to (fixes Overlap in CIDR #376)nextAvailableVNetaborted its entire Block list search when the first Block could not satisfy the requested size undersmallest_cidr, returning a 500 instead of evaluating the remaining Blocks.max()was called on an empty candidate list, and the resultingValueErrorescaped the loop. The same missing guard innextAvailableSubnetand in single-Block Reservation creation replaced their intended error messages with an unhandled exception (fixes nextAvailableVNet fails when using multiple blocks and smallest_cidr option true if no IP range is available in first block #384)uvicornwas started with--reloadin both production init scripts. Under a read-only run-from-package mount its watcher tracked thousands of files that can never change, and its supervisor process stayed alive when the application crashed — so the container kept running while serving nothing, and health checks failed against an apparently healthy process. A startup failure now exits, so the platform restarts the application and the fault is visible.engine/Dockerfile.devretains the flag, where hot reload is intendedrequests, which verifies against the CA bundle shipped insidecertifirather than the operating system trust store.WEBSITES_INCLUDE_CLOUD_CERTSpopulates the OS store, so sovereign cloud roots were present but invisible to that code path — secret cloud (IL6) deployments rejected every token withCERTIFICATE_VERIFY_FAILEDwhile passing in every commercial cloud, wherecertificovers the endpoints. Every other outbound call already usedaiohttp, which reads the OS store; the JWKS fetch was the lone exception and now matches. The metrics heartbeat moved offrequestsas well, and a Ruffbanned-apirule now fails the build onimport requestsorimport httpx, since the defect is invisible to commercial-cloud testing and would otherwise return unnoticedkidfloored at once per five minutes. Concurrent refreshes collapse to a single fetch, and a failed refresh serves the last known good keys rather than failing closed. The floor also closes an amplification vector, where a flood of tokens bearing boguskidvalues previously produced one outbound fetch each on behalf of unauthenticated callersmv-expandof up to 1,024 rows per virtual network, two peering joins, one ARM call per virtual hub and two Cosmos DB queries — on every request — and a direct contributor to the Resource Graph throttling that surfaces as failed allocations. A prefix-only query now backs the thirteen call sites that need nothing further. Mostexpandresponses still use the detailed query because they return whole network objects, but the available-networks endpoint is an exception: its expanded response is modelled onNetworkExpand, whose six fields are exactly what the prefix-only query already returns, so every additional field the detailed query produced was discarded field-for-field. That endpoint backs the network association picker in the UI, which always requests expansion, so the most frequently exercised expanded path now costs one Resource Graph query rather than two. Nothing is cached: results are still read live on every request, so allocation decisions remain based on the current state of Azure. Verified against a live tenant of 158 networks as returning an identical network set, identical prefixes and an identical total address count, with utilization responses byte-for-byte unchangedHttpResponseErrorand re-raised as403 Access denied, so throttling, upstream outages and expired credentials all presented as authorization failures — sending users to audit permissions for a fault that had nothing to do with them. BecauseClientAuthenticationErrorsubclassesHttpResponseError, the Token has expired handlers in the three Resource Graph wrappers were unreachable. Failures are now classified at a single choke point: throttling returns 429 withRetry-After, upstream and unexpected statuses return 502 rather than being blamed on the caller, authentication failures return 401 — or 500 when the Azure IPAM service principal itself failed to authenticate — and a genuine Forbidden still returns 403. The HTTP exception handler now propagates response headers soRetry-Afterreaches the caller. Percent-style placeholders inlogurucalls, which silently dropped their arguments, were converted at the same timeDATA_FACTORYquery carrying the standard exclusion filter, which also removes a duplicated Resource Graph client and its per-call setupprivate_ipsarray, which is the shape Resource Graph returns. The SDK path used where Resource Graph coverage is incomplete — sovereign clouds such as IL6 — returns a singularprivate_ip, and those records raised aKeyErrorinstead of being returned. Both shapes are now handledHV_<hub>_<suffix>, which Azure IPAM substitutes back to the hub itself for display. The hub name was interpolated into a pattern without escaping, and Azure permits periods in hub names — so a hub namedmy.hubproduced a pattern in which the period matched any character, andmyXhubwould have matched too. The pattern was also case-sensitive against a Resource Graph identifier. The transit network is now matched as a lowercased substring, which is all the comparison ever needed and removes both problemsPOST /api/spaces/{space}/blocks/{block}/networksrejects a network already associated to the Block, but that was the one comparison in the handler the case-insensitivity sweep did not reach, so a stored ID differing only in casing slipped past it — and was caught instead by the overlap check below, which does resolve IDs case-insensitively and so found the network overlapping its own stale entry. The error was describing a network as overlapping itself. That check is now case-insensitive, and the condition is reported as what it is. The Azure IPAM interface was never affected, as it associates networks throughPUT .../networks, which replaces the list rather than appending to it (fixes vNets in Block becoming unassociated and cant be associated again #329)resourceGroups/,providers/,virtualNetworks/,subnets/andvirtualMachines/segments literally. Unlike the comparison bugs above these failed loudly rather than silently — a non-matching expression returned no match and the immediate.group(0)raised, so the whole Scale Set request became a500— but the trigger is the same casing inconsistency, and the same resource can be returned with different casing by different Azure APIs. This is the SDK-based Scale Set path, which runs only outside Azure Public; commercial deployments answer the same request from Resource Graph and were never affected. That makes it a sovereign and air-gapped cloud fix, which is also where Azure IPAM depends on the SDK paths most, because Resource Graph coverage there is incomplete. The segment matches are now case-insensitive, which is what the equivalent expression in the Reservation path already didsizeandusedon a Block, its networks and their subnets was duplicated across the four endpoints that report utilization — roughly thirty lines repeated four times, differing only in local variable names and in whether the running Space totals were accumulated alongside. That duplication is what allowed the Space utilization defect above to exist in exactly one of the four copies. The four copies collapse to a singleadd_block_utilizationhelper, with the Space totals now summed from each Block's own figures, which is arithmetically the same value since the Space totals were only ever the sum of their Blocks. Around seventy lines are removed. The behaviour is deliberately unchanged, including two long-standing quirks in the per-network figures that are documented and fixed separately, and equivalence was verified both against the original logic across every network shape — fully in-Block, partially in-Block, entirely outside the Block, virtual hubs, networks without subnets, unmatched associations and empty Blocks — and byte-for-byte against a live tenant across all twenty-eight utilization responsessizeandusedalongside the Azure networks they sit beside. An external network'susedis the address space assigned to its subnets, mirroring how a Virtual Network'susedis the space assigned to its subnets; an external subnet'susedis the number of endpoints defined within it, mirroring the consumed addresses reported for an Azure subnet. Endpoints are counted as they are, without the five addresses Azure reserves in every subnet, because an external network is not Azure and reserves nothing. The Block's ownusedis deliberately unchanged and still counts each external network's full range exactly once, since its subnets sit inside that range and counting both would double count. This required utilization variants of the external network and subnet models, so the utilization responses gain two fields at each level and lose nonesizecounted only the prefixes falling inside the Block whileusedcounted the subnets of every prefix the network owns, including those outside it. The two figures answered different questions and were presented as a ratio, so a network whose address space straddles a Block boundary could report more space used than it has — on the verification tenant one reportedsize 256againstused 384. Separately,usedwas reset inside the loop over in-Block prefixes, so a network with no prefixes inside the Block never had it reset at all and reported the entire network's subnet total against a size of zero.usedis now initialized once and counts only subnets that fall inside the Block, so both figures describe the same address space andusedcan no longer exceedsize. Block and Space totals are unaffected because they never consulted subnets, and each subnet still reports its ownsizeregardless of where it sits. The Azure IPAM interface never requested these figures, since it does not expand Spaces or Blocks, so this corrects the documented API for automation consumers rather than anything visible in the productexceptreturning an empty list, which conflated two unrelated situations — a network carrying no tag at all, which is the normal case for nearly every network, and a tag whose value could not be read. The Resource Graph query parses tag values as JSON so that an absent tag resolves to null, with the side effect that a value resembling JSON arrives as a list, object or number instead of text. Such a network was then treated as having no Reservation, so the Reservation waited to be fulfilled indefinitely and nothing was written to the log. The type check is now explicit, and reconciliation reports the network ID and the offending value once per run rather than once per comparison. Behaviour is otherwise unchanged and was verified identical across every input type the tag can produce: unreadable values are still skipped rather than guessed at, and no attempt is made to strip quotes or interpret JSON, since Resource Graph already removes surrounding double quotes and interpreting the rest would mean acting on a tag the user did not write as an IDx-ms-user-quota-resets-afterinstead of the standardRetry-After. The Azure SDK's retry policy therefore never retried it — POST sits outside its method allowlist, and theRetry-Aftershort circuit that would have overridden that never fires — so a throttled query failed on its first attempt even though 429 is in the SDK's own retryable set. The SDK's policy is now taught both facts rather than a second retry loop running alongside it, so it waits exactly the interval Resource Graph reported instead of backing off blindly, and a hand-picked backoff ceiling gives way to the SDK's own settings. One retry setting is deliberately overridden: Resource Graph quota is shared by every caller using the same credentials rather than allocated per user, and replenishes roughly every five seconds, so the SDK's default of three status retries could be spent in about fifteen seconds while a burst of requests was still draining. That allowance is raised to six, which bounds a throttled request at roughly thirty seconds of waiting rather than a failure. It is not raised further on purpose — beyond ten the total retry budget silently becomes the real limit, and a longer wait encourages users to reload the page, which adds load to the very quota the request is waiting onGET /api/spaces/{space}?utilization=truereturned 500 for any Space with an external network in one of its Blocks. External address space was accumulated onto thespacepath parameter — a string — rather than the Space document, raisingTypeError: string indices must be integers. The line was correct in the equivalent list handler, wherespaceis the loop variable, and was copied into the single-Space handler without renaming. The list endpoint and the Block-level endpoints were never affected, which is why the fault went unseenexpand=trueon a Space or Block containing a vWAN hub silently returned unexpanded network references — just an ID and active flag — with an HTTP 200 and no error. The expanded network model requires asubnetsfield, which virtual networks carry and virtual hubs never had, so validation of the expanded shape failed and theUnionresponse model quietly fell through to the plain reference shape. The effect was not limited to the hub: a single hub in a Block collapsed every network in that Block back to a reference. Virtual hubs now carry an empty subnet list, which is what they genuinely have, so the expanded shape validates. The Block networks and available-networks endpoints were never affected, as their response model does not requiresubnetsGET /api/azure/vhubreturned 500 as soon as any vWAN hub was associated to a Block, reporting Input should be a valid string forparent_block. A network can belong to Blocks in more than one Space, so the engine has always produced a list of Block names here; the response model declared a single string. The model was corrected to match the data rather than the reverse, since collapsing to one name would discard a real association. The defect was invisible on/api/azure/network, which returns the same hub data but declares no response model, and on the virtual network equivalent for the same reasonparent_spaceas a single value alongsideparent_blockas a list, but a Block is identified only by the pair of Space and Block name — Block names are unique within a Space, not across a tenant. A network associated to Blocks in two Spaces therefore returned both Block names against whichever Space happened to match first, and the second Space was unrecoverable from the response. Three consumers had each independently reconstructed the pairing, and each failed on that data. The Planner joins every Block name against the reported Space and skipped any Block it could not find, dropping the network from the view with nothing but a console warning. External Network validation collected the prefixes already present in a Block by the same join, so a network whose reported Space differed was skipped and its address space read as free — client-side only, ascreate_external_networkrepeats the check server-side and refused the request rather than writing an overlap. Block rename matched networks on Block name alone, so renaming a Block relabelled same-named Blocks in other Spaces. Both fields are replaced byparent_containers, a list of Space and Block pairs reusing theCIDRContainermodel thatPOST /api/tools/cidrCheckalready returns, which moves to the shared response models rather than being duplicated. An unassociated network now reports an empty list instead of a pair of nulls, which removes a null check from every consumer. The four comparisons that resolve a network to its Blocks were also still case-sensitive, so a stored ID whose casing differed from the one Azure returned made a managed network report no Space or Block at all; they now match the rest of the engine, and the Block list they search is built once per request rather than once per network, against a pre-lowered set./api/azure/*is registered withinclude_in_schema = Falseand appears in no documentation, example or test, so this is not a change to a published contractGET /api/spaces/{space}/blocks/{block}/availablefiltered a network's prefixes individually, keeping those inside the Block that overlapped nothing and discarding the rest, then offered the network on the strength of whatever survived. The association handlers do not work that way —POSTandPUT .../networksevaluate every in-Block prefix together and refuse the whole network if any one of them overlaps an External Network or an unfulfilled Reservation. A Virtual Network with two prefixes inside a Block, one of them colliding, was therefore listed as available, displayed with the colliding prefix silently removed, and then rejected on save describing an overlap the user could not see anywhere in the interface, because the range responsible had been edited out of the very list offering the network. The endpoint now applies the same whole-network rule as the handlers, so what it returns can actually be associated. Since that makes an expected network vanish rather than fail, aninclude_blockedparameter returns those networks as well, each carrying ablocked_byentry naming the External Network or Reservation occupying each prefix; it requiresexpand, as the unexpanded response is an array of resource IDs with nowhere to carry a reason. The blocked shape is a separateNetworkExpandBlockedresponse model rather than a field onNetworkExpand, which is shared with the Block networks endpoint where a network is associated by definition and can never be blocked. Attribution is computed only for a network that is actually blocked, and only when the parameter is set, so the default response costs the same single set intersection per network that it did before (fixes Virtual networks not discoverable #200)PUT /api/spaces/{space}/blocks/{block}/networksreported Network list contains CIDR(s) that overlap external networks without saying which of the submitted networks was responsible, leaving the caller to find it by inspection across a list that can run to dozens of entries. The three overlap checks tested a merged CIDR set built from every network in the request, and that set had already discarded the attribution by the time the comparison ran. Each check now compares per network and collects the offenders, which is the pattern the same handler already followed for invalid resource IDs and for networks outside the Block CIDR — two of its five errors named the entries at fault and three did not. The conditions under which each error is raised are unchanged, since a merged set intersects only where one of its members does, as is the order in which they are reportedauthorization, carrying a description intended to render an input box in the interactive documentation. The OpenAPI specification requires that a header parameter namedAuthorizationbe ignored, andswagger-clientimplements that literally: it rendered the box, then discarded the value when building the request. The result was the worst available shape — a field that looks like it works, silently drops what is typed into it, and returns Authorization header is missing. The document declared no security scheme at all, so the Authorize button could not help either: of 58 documented operations, none carried a security requirement and 55 declared the parameter that is thrown away. The token is now advertised as anHTTPBearersecurity scheme, which is the one place the specification reserves for it, so Authorize attaches the header and the generated cURL includes it. The scheme is declared as a sub-dependency ofvalidate_tokenrather than as a separate router dependency, so an operation cannot advertise authentication without also enforcing it — the reverse arrangement would let a router display a padlock while accepting every request. The 120-lineIPAMTokenclass commented out independencies.pysince the framework commit was removed rather than revived: it called its ownasyncvalidator without awaiting it, so every token passed; it calledcheck_adminas a bare name from inside a method, where it is not in scope; it read a module-level_sessionit never assigned; and it fetched signing keys withrequests, which validates TLS against the bundled certifi roots and is banned for that reason (fixes IP reservation from Swagger API UI errors with "Authorization header missing" #99)if not is_admin: raise HTTPException(403), and nothing about that requirement appeared in the API documentation — a caller could not tell which operations needed admin without reading the source. Annotating a403response by hand on each one was considered and rejected, as it leaves forty-six pieces of metadata sitting next to, but not derived from, the check they describe; the first guard added without its annotation publishes a contract that is wrong, and nothing catches it. The inline checks are replaced by arequire_admindependency, and the four endpoints where only theexpandparameter is restricted by anadmin_expandgate that declares the parameter itself. A generator in the newapp/openapi.pywalks each route's dependency tree and annotates the schema from what it finds:require_adminappends(Admin Only)to the operation summary and attaches a documented403, while a parameter-level gate marks only the parameter it guards, since the endpoint itself is not restricted. Documentation and enforcement therefore cannot disagree. Marking is best effort and never fatal — it readsfastapi.routing.iter_route_contexts, which is not a public API, so a FastAPI upgrade could break it; the engine still serves a valid schema and logs the number of marked operations, and enforcement is unaffected either way because the gates are ordinary dependencies. Removing the inline checks also left thirty-sevenis_adminparameters unused, which are dropped. Two wordings of the same message — API restricted to admins. used twenty-nine times and This API is admin restricted. used nine — collapse to one; the ownership checks that also return403, such as refusing to delete another user's Reservation, are deliberately left inline, as they are not admin restrictionsvalidate_tokenwrote the tenant ID ontorequest.stateandcheck_adminwrote the admin flag, whichget_tenant_idandget_adminthen read back. Nothing in the dependency graph expressed that ordering, so a route using either accessor without also declaring the auth chain raisedAttributeErrorand returned a500where it should have returned a401. The side-channel dates from the original multi-tenancy work, where the auth gate returned nothing andrequest.statewas the only way to reach a route handler; that stopped being true once the gate became a value-returning dependency. The accessors now depend on the function that produces what they need, and FastAPI's per-request caching means token validation and the Cosmos admin lookup each still run exactly once. No router changes were required, as every call site already went throughDepends(get_admin)orDepends(get_tenant_id)Authorizationheader and nineteen call sites across the routers and their helpers immediately stripped theBearerprefix withauthorization.split(' ')[1]. The header value was used for nothing else anywhere in the engine. Routes now depend onget_token_auth_header, which already existed and already parsed the header correctly, and pass the token itself down to the helpers. The inline parsing split on a literal single space, so a tab or a doubled space yielded an empty token or an uncaughtIndexError, and aBasicheader would have had its credential extracted; those paths were unreachable only because validation rejects malformed headers first. Parsing now happens in exactly one placeUI & UX Improvements
toAgGridFilterconverts into an AG Grid filter model, and that conversion never read theoperatorthe caller set — it hardcoded AG Grid'scontains, so every text filter was a substring match. Drill-down therefore returned more than it was asked for: a Block namedProdreturned the networks ofProductionalongside its own, and the same held for a Space, Virtual Network or subnet whose name is a prefix of another. Nothing was mis-reported, but the row set silently included rows belonging to a sibling. The pre-AG Grid implementation merged the whole descriptor onto its own filter object, so the operator was honoured without a mapping and none was ever written; the port rebuilt the descriptor field by field and dropped it. The operator is now mapped to an AG Grid filter type and drill-down asks forequals, since it wants the children of one named parent, while the search bar continues to sendcontainswhere a substring match is the intent — itslikewording is display text for the dropdown and was never an operator. Exact matching also required an array-aware matcher for the Block column, whose filter value is its elements joined into a single string, soequalsagainstBlockA, BlockBcould never have matched a network associated to both; the unusedarrayTextFilterComparatoris replaced byarrayTextMatcher, which compares each element and honours the filter option in use. Drill-down still identified a parent by name alone at that point, so a Block, Virtual Network or subnet sharing a name with one under a different parent still returned both; that is addressed in the entry belowSetof parent names per child collection, so every subnet nameddefaultoffered a drill-down into another subnet's endpoints, and a Block offered one because a same-named Block elsewhere had networks. The six name-keyed selectors are replaced by oneselectDrillIndexkeyed on identity — theida Block already carries, and the ARM resource IDs the engine already returns, compared without regard to casing — which also retires a renderer hook that could read only two drill-down targets and silently ignored a third. A filter model resolving to no entries was truthy and cleared every filter, so a drill-down with nothing to match on read as one into the whole table; it now applies no filter at all. The two drill-downs into Endpoints stay on names, as an Endpoint records its own resource group and subscription rather than its parent network's, so neither can narrow the match and the only exact key is a resource ID that has no place in a filter box. A network in Blocks across several Spaces can likewise satisfy the Space of one Block and the name of another and appear under a Block it does not belong to; pairing them requires a blended column existing only to serve this filter, so the extra rows are accepted and the reasoning is recorded where the filter is declared<Unassigned>in Discover, and its Block offered no drill-down, until the next refresh re-fetched the networks. The association is now mirrored onto the network in the same reducer, following the pattern a Space or Block rename already uses, so no further call is made to the API and the periodic refresh reconciles the result either way. Only the named Block is rewritten, leaving a Block of the same name in another Space untouched. A Block's identity, which pairs its name with its Space, was also stamped only when Spaces were fetched: creating a Block never set it, and renaming a Block or its Space left it behind, so the grid tracked rows by an identity that no longer described them. It is now derived in one place and reapplied wherever either name changes. Block and Space utilization still come from the engine and settle on the next refreshAuthHandlerfor MSAL error handling, centralized token acquisition viatokenServiceDraggablePapercomponent: Replacedreact-draggablepackage with a purpose-built componentDataGridandConfigureGridcomponents with shared filter utilities, consistent loading overlays, and AG Grid custom styling (brightnessfilter for row hover)error.response.data.errorunconditionally, so any response that did not use the{ error }envelope produced anErrorwith an empty message — and an error snackbar with no text at all. Unhandled engine exceptions return plain text, request validation failures return{ detail }, and proxy errors return HTML; each is now resolved to a message, falling back to the Axios status text. This also fixes silent failures on any request rejected by model validation, which had always returned 422ErrNotFound. Three AG Grid defaults had to be corrected before any of this was legible: a two second tooltip delay which reads as no tooltip rather than a slow one, a tooltip element which collapses newlines so that several ranges ran together on one line, and a disabled checkbox which still takes focus on a row that can never be selectedxxl={1}rule packed twelve tiles per row at 1920px and left 141px for a CIDR needing 150px, so10.183.119.224/31rendered as10.183.119.224/…— the mask being the one part that distinguishes a tile from its neighbour. The font clamp had already been walked down from 20px to 16px to 15px with an ellipsis added to absorb the overflow, each change moving the width at which the text stopped fitting rather than removing it, since no fraction fits content at every viewport. The tiles are now laid out with CSS Gridauto-fill, declaring a minimum tile width and letting the browser choose the column count, so the constraint runs from the content outward and no viewport can produce a column too narrow for the longest possible CIDR; verified from 320px to 3840px in 20px steps against255.255.255.255/32. The minimum is expressed in pixels rather thanremto stay in step with the px font clamp, as aremminimum shrank with a reduced browser root font size while the text did not, reintroducing the same truncation at 14px and below.plannerThemeis removed with it, having existed only to supply thexxlbreakpoint the grid no longer usesNotifications & Service Management
GET /api/notifications,POST /api/notifications/{id}/resolve): A self-describing, API-first advisory system. Server-side detectors emit notifications that the UI — or any API / IaC consumer — can read, while remediation is resolve-by-reference: the client asks the backend to resolve a notification by id and the backend owns all the logic (admin-gated, with an active-notification guard). Adding a new advisory is a single detector module registered in one listazureipam.azurecr.io, critical) or the development registry (azureipamdev.azurecr.io, warning) and offers a one-click remediation that repoints the App Service / FunctionLinuxFxVersiontoregistry.azureipam.com. The target image is validated as anonymously pullable before any change is applied, then the app restarts to pull it — complementing theupdatescript's auto-migration with an in-app pathIPAM_VERSIONagainst the latest published GitHub release and surfaces an informational notice linking to the update guide. Fails safe (no notification, no error) on network or rate-limit failuresDEPLOYMENT_STACK == "LegacyCompose", critical) and links to the migration guide ahead of Microsoft's March 31, 2027 retirement of Docker Compose support for Azure App Service. Link-only guidance (no in-app remediation) since migration is a scripted, multi-step process run from the operator's workstation/api/status, and confirms recovery via a changed service start time before reloading — with calm, time-keyed messaging and a deliberate manual-reload escape hatch if recovery runs long. Background polling is paused while the gate is upDeployment, Build & Infrastructure
update: The update script now compares an existing deployment against what a fresh deployment would produce today, presents the differences, and converges them on approval. Detection is based on live Azure resource state rather than the version originally deployed, so each difference is evaluated independently and a partially-current deployment only sees what it actually needs. Covers the container registry endpoint, Python runtime version, App Service startup command, health check, baseline app settings, creation of thestagingslot, and Function App slot-sticky content-share settings. Existing values and user-added settings are never overwritten, and removal is restricted to settings Azure IPAM owns that no longer apply to the deployment's shape. Production is converged before the staging slot so a newly created slot inherits corrected values, and the two are kept in sync thereafter so a future swap cannot regress production. When an archive is supplied explicitly, the Python runtime version is read from that archive rather than from the local checkout, soLinuxFxVersionis retargeted to match the wheels actually bundled in it — those wheels are ABI-specific, and a mismatch leaves every native module unimportable at startup-Force. Images built without a version stamp report the0.0.0Dockerfile default and are treated as an unknown version rather than a downgrade-ContainerType(Debian|RHEL) override onupdatematches the existingmigrateswitch, so a private ACR deployment can still be rebuilt when its container distro can't be probed. The distro probe is also now bounded by a timeout, so an application whose container fails to start no longer stalls the update indefinitelyupdateandmigrateno longer raise exceptions for situations with a known remedy, such as an undetectable container distro. These now print actionable guidance under the relevant phase heading and exit cleanly, matching how legacy Compose deployments and out-of-resource-group registries were already handled. Genuinely unexpected errors now surface their message on screen rather than only in the logDOCKER_REGISTRY_SERVER_URLremoved from all deployment templates: This setting is only required for registries authenticating with stored credentials. Azure IPAM pulls anonymously from the public registry or with a managed identity from a private ACR, so it was never needed, and App Service removes it on its own whenever the container registry is reconfigured — which caused the update script to repeatedly report it as configuration drift#Requiresmodule pins match the versions packaged in that rollup. Previously the update guide understated its requirement (Az.Resources 6.16.0is only available from Az 11.4.0), anddeploy.ps1pinned the Az 10.3.0 module set while its documentation stated Az 11.0.0update.ps1built Debian images withPORT=80whiledeploy.ps1,migrate.ps1, and the Dockerfile default all use8080azureipam.azurecr.ioto the newregistry.azureipam.comendpoint. The deployment and migration Bicep templates now reference the new registry by default, while theupdatescript continues to recognize the legacyazureipam.azurecr.ioendpoint so existing deployments keep working. The Docker Compose migration tooling intentionally still targets the legacy endpoint, as it only ever processes pre-existing legacy deploymentsmigratescript now halts on a non-standard or unresolvable container registry with guidance to re-run using the-JsonFileoverride, instead of silently falling back to the public registry. A new-ContainerType(Debian|RHEL) override handles cases where the source app is stopped/unreachable and its distro can't be auto-detecteddeploy,update, andmigratePowerShell scripts (previously still pinned to UBI8)build.ps1version gate and the UIpackage.jsonenginesfieldADD→COPY, JSON notation forCMD/ENTRYPOINT,pipefailfor pipedRUNcommands). Added centralized.hadolint.yamlfor rule suppressionsenableSoftDeleteandenablePurgeProtectionare now set on the Key Vault resource. Without them, deployment is rejected outright under the Azure Landing Zone Enforce recommended guardrails for Azure Key Vault policy set (fixes Enable soft delete for Key Vault #373)checkout@v7,setup-node@v7,setup-python@v7,github-script@v9,create-github-app-token@v3,azure/login@v3,hadolint-action@v3.5.0), initially to clear the Node.js 20 runner deprecation and then to the current majors so the release does not ship already behind. Thev7line of theactions/*set is an ESM migration on the samenode24runtime, and none of the inputs these workflows pass were changed or removed. The fork-PR checkout restriction added incheckout@v7does not apply here, as no workflow usespull_request_targetorworkflow_run@v7. A tag can be retargeted to arbitrary code by anyone able to push to the action's repository — the mechanism behind thetj-actions/changed-filesandcodfish/semantic-release-actioncompromises — and every one of these workflows holds credentials, whether an OIDC federated identity or a GitHub App private key. A new.github/dependabot.ymltracks thegithub-actionsecosystem weekly as a single grouped PR, with a seven day cooldown so a compromised release has a window to be reported before it is proposed here. Dependabot updates the SHA and the version comment together, so the pins do not go stale. Verified with the GitHub REST API that each pinned SHA is the commit the corresponding release tag resolves toGet-AzAccessTokenbreaking changes (fixes Breaking changes to Get-AzAccessToken #343); updated deploy & migrate scriptsorg.opencontainers.image.version,.title,.source), stamped at build time via a newIPAM_VERSIONbuild arg. This lets you determine the exact version behind a floating tag likelatestby inspecting the registry — no pull or run required (e.g.docker buildx imagetools inspectoraz acr manifest show). Covers alldeb,rhel, andfuncvariants across the root, engine, ui, and lb images, with the build workflow passing--build-arg IPAM_VERSIONto everyaz acr build. Labels begin with the v4.0.0 release imagesv(^v) from the release tag, preserving suffixes such as-previewAZURE_US_GOV_SECRET) never resolved its bundled Python packages.init.shreferenced anAPP_PATHvariable that is defined nowhere in the repository — App Service exposes it only to interactive SSH sessions via~/.bashrc, which a non-interactive startup command never reads — soPYTHONPATHexpanded to a non-existent path at the filesystem root and no bundled dependency could be imported. The application root is now derived from the script's own location, matching whatfunction_app.pyalready did for the Function App entry point. The accompanyingPATHexport was removed: it pointed atpackageswhile pip installs console scripts topackages/bin, and nothing invokes thempip install --targetresolves wheels for the machine running pip. When the GitHub Actions runner moved to Ubuntu 24.04,cryptographybegan resolving to amanylinux_2_34wheel that cannot load on the App Service Python 3.11 image (Debian bullseye, glibc 2.31), producingGLIBC_2.33 not foundat startup. Wheel resolution is now pinned explicitly (--only-binary=:all:,--platform manylinux2014_x86_64,--implementation cp,--python-version,--abi), with the Python tag and ABI derived fromengine/app/version.json.manylinux2014(glibc 2.17) is targeted deliberately, since sovereign clouds can run older stamps than commercial Azure.pyd. Both defects above were invisible at build time and only surfaced after deployment — and the ABI mismatch presented asModuleNotFoundError, which reads like a missing dependency rather than a build fault. The gate also inspects each distribution's*.dist-info/WHEELmetadata and rejects any wheel whose platform tag exceeds the target, so a locally compiledlinux_x86_64build or an over-newmanylinux_2_28/manylinux_2_34variant is caught by name before the binary scan has to find it. The highest glibc symbol required by any bundled module is now reported on every build, not only on failurebuild.jsonat its root recording the build timestamp, the app and Python versions, the pip platform, the glibc ceiling and the highest glibc actually required, the native module count, and the resolved wheel tag for every package. In an air-gapped cloud a build log cannot be copied out, so establishing which build is actually deployed previously meant inference from indirect evidence; it is now a singlecatAZURE_US_GOV_SECRET, IL6) run against endpoints whose certificate chains are issued by that cloud's own roots, which are not present in the App Service image's default trust store. Every outbound TLS call the engine makes — Key Vault references, Cosmos DB, ARM, Microsoft Graph — therefore failed certificate validation.WEBSITES_INCLUDE_CLOUD_CERTSis now set for that cloud in the deployment, update, and migration templates, so the platform injects the cloud's root certificates. The update script's configuration drift detection also adds it to existing secret cloud deployments. This setting is necessary but was not sufficient on its own — see the TLS validation fix under Engine & Backend, without which the engine still could not validate tokens in that cloud. The setting is gated on the cloud rather than on the deployment shape, so it now also reaches container deployments in that cloud, which the previous nesting excluded-ResourceNamesfailed withRoleAssignmentUpdateNotPermitted— Tenant ID, application ID, principal ID, and scope are not allowed to be updated — whenever the managed identity had been deleted and recreated. Role assignment names are globally unique GUIDs, and the Contributor and Managed Identity Operator grants seeded theirs from the identity's resource ID, which survives a delete and recreate unchanged. The name therefore matched an existing assignment while the principal behind it had changed, which Azure treats as an illegal update rather than a create. Every other module already seeded from the principal ID and was never exposed;managedIdentity.bicepcould not, because a role assignment name must resolve at the start of deployment (BCP120) and the principal ID does not exist until the identity is created. Both grants moved into a newmanagedIdentityRoles.bicepmodule that receives the principal ID as a parameter, following Microsoft's documentedguid(scope, principalId, roleDefinitionId)pattern — a recreated identity now yields a new assignment name and deploys cleanly, with no manual cleanup. Because the names change, a first re-run ofdeploy.ps1against an intact deployment created by an earlier version may reportRoleAssignmentExists; removing the two superseded assignments clears it. Deployments that let Azure IPAM generate resource names are unaffected either way, as every run produces fresh names.updateandmigrateneeded no equivalent change —updatedeploys no role assignments at all, andmigratereferences the identity asexistingand already seeds from the principal IDexit, which returns 0. A build that could not find NodeJS, or that rejected the installed Python version, printed errors and then reported success to CI. All now exit non-zero. The exception handler also printed an unassigned variable in place of the log pathCompress-Archivetook 353 seconds to package the 6,404-file deploy archive;ZipFile.CreateFromDirectoryproduces an identically sized result in 11 seconds. The new API also preserves Unix file modes, whichCompress-Archiveflattened to0644engine/app/version.jsoninstead of pinning3.11by hand. This matters most in the versioning workflow, which regeneratesrequirements.lock.txt— dependency resolution is Python-version sensitive, so a lock file produced on the wrong interpreter can omit packages the runtime requiresDocumentation Overhaul
.markdownlint.jsonconfigurationaiohttpExamples
examples/scripts/folder with new helper scripts and READMETesting
smallest_cidr), and the genuinely exhausted casePSScriptAnalyzerSettings.psd1, which is how the test suite came to be the only script not following the house conventions and the only one carrying an analyzer finding. The lint job now runs PSScriptAnalyzer over every script and fails on a finding at any severity, and also fails if it matches no scripts at all, so the check cannot pass green having analyzed nothing. The step is deliberately scoped to the four rules the settings file configures: runningPSUseCorrectCasingalongside the full default rule set trips a thread-safety defect in the analyzer's command cache and aborts the run roughly a third of the time, which would fail the build on a clean tree. That is PSScriptAnalyzer #1708, open since 2021 and reproducible in both 1.24.0 and 1.25.0, so pinning an older analyzer does not avoid it. The scoping is annotated in the workflow with the single change needed to undo it once the defect is fixedFunctionandParam, making it the only script not following the lowercase keyword style the settings file describes and every other script applies, and it carried the repository's only analyzer error — a false positive from normalizing an Azure access token to aSecureString, now suppressed inline with the same justification already used in the deployment scripts.version.ps1declared pipeline binding on all four parameters without aprocessblock, so piping several objects would have silently processed only the last; nothing pipes to it, and no comparable script declares that binding, so the unused bindings were removed. Every script also declared$logPathand then referenced$logpathon the following line, a mismatch copied into all five. Every PowerShell file in the repository now reports no analyzer findings at any severityRetry-After; the client errors the suite deliberately asserts are never retried, and transport failures are never replayed, since a POST may already have applied server-side. Access tokens are cached and refreshed ahead of expiry, so a long run no longer fails partway through on an expired tokenUtilization & Expansioncontext exercisingutilization=trueandexpand=trueacross the Spaces, Space, Blocks and Block endpoints. Neither parameter had any coverage at all, which is precisely why the Space utilization defect above survived. The context asserts that the utilization reported without expansion matches the expansion path exactly — the two are answered by different Resource Graph queries, so this guards the prefix-only query against divergence; that a Block's used address count equals its networks plus its external networks; and that expanded responses genuinely carry the expanded fields, since aUnionresponse model degrades silently rather than erroring. Every assertion was confirmed to fail against the unfixed engine before the corresponding fix landed, so none of them can pass vacuously. All are read-only, leaving the suite's ordered state untouchedsizeandused, nor that external networks reported utilization at all, so both defects fixed in this release would have passed the suite. Four assertions were added: that an expanded network never reports more space used than its size, that an external network'susedis the space assigned to its subnets, that an external subnet'susedis its endpoint count, and that a Block counts an external network once rather than counting its subnets again. The first three were confirmed to fail against the code that carried each defect; the fourth is a guard that would catch an external network being double counted if the accounting were ever changedBug Fixes
Authorization, which the OpenAPI specification requires be ignored, so the value typed into it was discarded and every call reported the header as missing. The token is now a bearer security scheme, attached through the Authorize buttonGet-AzAccessTokenbreaking changes not accounted forRequestDisallowedByPolicywhere the Azure Landing Zone Key Vault guardrails are enforced, as the policy set requires soft deletenextAvailableVNetfailed with an internal server error when using multiple Blocks withsmallest_cidrand no range was available in the first Block[major]