Azure IPAM v4.0.0 Release - #377
Open
DCMattyG wants to merge 257 commits into
Open
Conversation
…DataGrid component
Upated NPM packages to latest versions.
Pin every action reference to an immutable commit SHA with a trailing version comment, and add a Dependabot configuration for the github-actions ecosystem so the pins stay current. Pinning by SHA prevents supply-chain attacks in which a mutable tag is retargeted to malicious code, per the GitHub Actions security hardening guidance. The 7 day Dependabot cooldown leaves a window for compromised releases to be reported before they are proposed as updates. Also move every action to its latest GA release so the branch does not ship already behind: checkout v7.0.1, setup-node v7.0.0 and setup-python v7.0.0 (all ESM migrations, both majors already on the node24 runtime), plus hadolint-action v3.5.0. The inputs in use are unchanged across the major boundary, and the fork-PR checkout restriction added in checkout v7 does not apply here as no workflow uses pull_request_target or workflow_run. hadolint-action v3.5.0 upgrades the linter from v2.14.0 to v2.15.1, which adds DL3066 and flags the intentional `USER root` directives in the RHEL images. Ignore DL3066 so the lint job stays green; this affects linting only and leaves the built images unchanged. Supersedes #386, which was authored against main and pinned to action versions that predate the OIDC migration and action bumps on this branch. Co-authored-by: Dan Fiedler <151573964+danfiedler-msft@users.noreply.github.com>
Handle both aggregated ARG private_ips arrays and singular private_ip records returned by the sovereign-cloud SDK path.
…ng them as 403
Every non-success response from Azure Resource Graph was caught as a bare
HttpResponseError and re-raised as "403 Access denied.", so throttling,
upstream outages and expired credentials were all reported as authorization
failures. Because ClientAuthenticationError subclasses HttpResponseError, the
"Token has expired." handlers in the three ARG wrappers were unreachable.
Failures are now classified at the single choke point in arg_query_helper:
- 429 is retried with jittered backoff that honours Retry-After and
x-ms-user-quota-resets-after, then surfaced as 429 with Retry-After when
the wait would exceed the retry budget
- 5xx and unexpected upstream statuses become 502 rather than being relayed
and blamed on the caller
- authentication failures become 401, or 500 when the IPAM service principal
itself failed to authenticate
- genuine Forbidden responses remain 403
Only the individual page request is retried, so paged results are never
re-appended. Remaining Resource Graph quota is logged per page, and the HTTP
exception handler now propagates response headers so Retry-After reaches the
caller.
Also converts percent-style placeholders in loguru calls, which silently
dropped their arguments.
BREAKING CHANGE: Azure Resource Graph throttling now returns 429, upstream
failures return 502, and authentication failures return 401 or 500. These
previously returned 403.
…okens The Pester suite issued every request once and treated any failure as final, so a single Azure Resource Graph throttle surfaced as an unrelated assertion failure and aborted the whole run. Requests now route through a shared pipeline that retries only definitive transient responses (429, 500, 502, 503, 504), honouring the Retry-After header the engine sends. Client errors the suite asserts deliberately (400/403/404/409/422) are never retried, and transport failures are never replayed because a POST may already have been applied server-side. Retries are logged as warnings so throttling that was silently recovered from is still visible in CI output. The access token is now cached and re-acquired on expiry rather than fetched once in BeforeAll. The suite waits about twelve minutes on Resource Graph propagation by design, so a slow run could previously outlive its token and fail with an unexplained 401. A 401 is deliberately not retried, so genuine authentication regressions still fail loudly. Helper signatures and all test bodies are unchanged.
get_network, get_vnet and get_vhub served as both FastAPI routes and internal helpers, so their Depends() defaults made a missing positional argument silently truthy rather than an error. Thirteen internal callers passed only two arguments, which put the admin flag into tenant_id and left admin as an unresolved Depends object. Every one of those calls therefore queried across the whole tenant by accident, and the Cosmos lookup in get_vnet hit a nonexistent partition, so parent_space and parent_block always resolved to None. The query bodies are now plain functions with required arguments (fetch_vnets, fetch_vhubs, fetch_networks) and the routes are thin delegates, so an omitted argument is a TypeError. The third parameter is named all_networks because the question at an internal call site is whether the operation needs every network in the tenant, not whether the caller is an admin. Occupancy questions -- reservations, next-available searches, CIDR overlap validation and utilization totals -- deliberately ask for every network: answering them from the caller's scope would let IPAM allocate over networks the caller cannot see. Resource enumeration stays scoped, so /available continues to offer only networks the caller can actually associate. Behaviour is unchanged apart from parent_space and parent_block now resolving correctly and one redundant Cosmos query per call being removed.
Occupancy and utilization paths read only a network's ID and address prefixes, but called fetch_networks, which expands every subnet, joins peerings, resolves vHub route tables via ARM and enriches from Cosmos DB. On large tenants that is the dominant source of Azure Resource Graph quota pressure and a contributor to 429 throttling. Add fetch_network_prefixes, backed by a single NET_BASIC query, and use it at the thirteen call sites that need nothing further. Paths that return whole network objects still use fetch_networks, selected on the expand flag. Nothing is cached. Both queries are read live on every request, so allocation decisions remain based on the current state of Azure.
…orks
GET /api/spaces/{space}?utilization=true returned 500 for any space with
an external network in one of its blocks. External address space was
accumulated onto the space path parameter, which is a string, rather than
the space document:
File "app/routers/space.py", line 744, in get_space
space['used'] += IPNetwork(ext['cidr']).size
TypeError: string indices must be integers, not 'str'
The line is correct in get_spaces, where `space` is the loop variable, and
was copied into get_space without renaming. The list endpoint and the
block-level endpoints were never affected, which is why this went unseen.
Requesting expand=true on a space or block containing a vWAN hub silently
returned unexpanded network references, with an HTTP 200 and no error.
VNetExpand requires a subnets field. Virtual networks carry one, virtual
hubs never did, so validation of the expanded shape failed and the Union
response model fell through to the plain reference shape. The effect was
not confined to the hub: a single hub in a block collapsed every network
in that block back to {id, active}.
Virtual hubs now carry an empty subnet list, which is what they actually
have, so the expanded shape validates. The block networks and available
endpoints were unaffected, as NetworkExpand does not require subnets.
GET /api/azure/vhub returned 500 as soon as any vWAN hub was associated
to a block:
ResponseValidationError
{'loc': ('response', 0, 'parent_block'),
'msg': 'Input should be a valid string', 'input': ['BlockHub']}
A network can belong to blocks in more than one space, so fetch_vhubs has
always assigned a list of block names. The model declared a single string.
Correct the model to match the data rather than the reverse, since
collapsing to one name would discard a real association. parent_space is
unchanged; it resolves to a single value.
The defect was masked on /api/azure/network, which returns the same hub
data but declares no response model.
Neither utilization=true nor expand=true had any coverage, which is why the space utilization defect and the silent virtual hub expansion fallback both went unnoticed. Add a read-only Utilization & Expansion context covering both parameters across the spaces, space, blocks and block endpoints: - utilization reported without expansion must match the expansion path exactly. The two are answered by different Resource Graph queries, so this guards the prefix-only query against divergence - a block's used address count must equal its networks plus its externals - expanded responses must actually carry the expanded fields, since a Union response model degrades silently rather than erroring Every assertion was confirmed to fail against the unfixed engine before the fixes landed, so none can pass vacuously. All are GETs, leaving the suite's ordered, stateful sequence untouched.
Azure Resource Graph throttles a POST and states the wait as x-ms-user-quota-resets-after rather than Retry-After. azure-core therefore never retried it: POST sits outside the method allowlist, and the Retry-After short circuit that would have overridden that never fires. Teach the SDK's own retry policy both facts rather than hand-rolling a retry loop beside it. It now waits exactly as long as Resource Graph asked instead of backing off blindly, and the fixed attempt count and backoff ceiling give way to the SDK's own settings. Also guard result completeness. Resource Graph withholds the continuation token when it truncates a result set, so the missing rows cannot be paged and the response is silently short. That now raises rather than returning partial data.
Azure returns resource IDs with inconsistent casing between APIs, and Microsoft's naming rules direct callers to always compare names case-insensitively. Azure IPAM stores whichever casing the client supplied when associating a network, because association validates with a case-insensitive lookup but persists the request body verbatim. Fifteen comparisons matched a stored ID against an Azure-returned ID exactly, so a casing difference made a managed network invisible: - utilization under-reported the address space in use - block CIDR and external network validation skipped the network - reconciliation marked the network inactive - the CIDR check reported it as belonging to no space or block - removing a network by ID rejected a valid ID as invalid Every one failed in the same direction, reporting occupied space as free. One case compared a Resource Graph ID against an ARM SDK ID, where the two APIs genuinely disagree on casing. Reservation identifiers are generated by Azure IPAM rather than Azure, so those stay case-sensitive.
When a virtual network connects to a vWAN hub, Azure peers it to a generated transit network named HV_<hub>_<suffix>, which is substituted back to the hub for display. The hub name was interpolated into a pattern without escaping, and Azure permits periods in hub names, so a hub named "my.hub" produced a pattern where the period matched any character and "myXhub" matched as well. The pattern was also case-sensitive against a Resource Graph identifier. Match the transit network as a lowercased substring instead. That is all the comparison ever needed, and it removes both the escaping hazard and the case sensitivity.
…query The available-networks endpoint used the detailed network query whenever expansion was requested, then returned a NetworkExpand response whose six fields are exactly what the prefix-only query already provides. Every additional field the detailed query produced was discarded field-for-field, at the cost of a second Resource Graph query, a subnet expansion of up to 1,024 rows per virtual network, two peering joins, an ARM call per virtual hub and two Cosmos DB queries. The UI association picker always requests expansion, so this was the most frequently exercised expanded path.
Scale Set discovery extracted the resource group, virtual network, subnet and instance number from ARM resource IDs by matching the resourceGroups/, providers/, virtualNetworks/, subnets/ and virtualMachines/ segments literally. Azure does not guarantee the casing of those segments, and a non-matching expression returned None, whose immediate .group(0) raised and failed the whole request with a 500. This is the SDK-based path, reached only when AZURE_ENV is not AZURE_PUBLIC, so it affects sovereign and air-gapped clouds; commercial deployments answer the same request from Resource Graph. The Reservation path already matched case-insensitively; these six now do the same.
The helper splitting a reservation tag into IDs wrapped its work in a bare except returning an empty list, conflating a network with no tag at all -- the normal case for nearly every network -- with a tag whose value could not be read. The Resource Graph query parses tag values as JSON so an absent tag resolves to null, which also means a value resembling JSON arrives as a list, object or number rather than text. That network was then treated as having no reservation, so the reservation waited to be fulfilled indefinitely with nothing logged. The type check is now explicit, and reconciliation reports the network ID and offending value once per run rather than once per comparison. Values that cannot be read are still skipped rather than guessed at.
Resource Graph quota is shared by every caller using the same credentials rather than allocated per user, and replenishes roughly every five seconds. The SDK's default of three status retries could therefore be spent in about fifteen seconds while a burst of requests was still draining, surfacing a 429 to the caller. Raise the status allowance to six, bounding a throttled request at roughly thirty seconds of waiting instead. It is not raised further deliberately: past ten the total retry budget becomes the real limit, and a longer wait encourages a page reload, which adds load to the same quota.
The arithmetic populating size and used on a block, its networks and their subnets was duplicated across the four endpoints reporting utilization -- about thirty lines repeated four times, differing only in local variable names and whether the running space totals were accumulated alongside. That duplication is why the space utilization defect existed in exactly one copy. Collapse the four into add_block_utilization, summing the space totals from each block's own figures, which is the same value since the space totals were only ever the sum of their blocks. Behaviour is unchanged, including two quirks in the per-network figures that are addressed separately.
A block counted each external network's full address range toward its own utilization but reported nothing about how that range was allocated, even though external subnets and endpoints were added to the model later. External networks and their subnets now carry size and used alongside the Azure networks beside them. An external network's used is the space assigned to its subnets, mirroring a virtual network; an external subnet's used is its endpoint count, mirroring an Azure subnet's consumed addresses, without the five addresses Azure reserves since an external network reserves none. The block's own used is unchanged and still counts each external range once, as its subnets sit inside that range.
For an expanded network, size counted only the prefixes falling inside the block while used counted the subnets of every prefix the network owns, including those outside it. The two answered different questions and were presented as a ratio, so a network straddling a block boundary could report more space used than it has. used was also reset inside the loop over in-block prefixes, so a network with no prefixes inside the block never had it reset and reported the whole network's subnet total against a size of zero. Initialise used once and count only subnets inside the block. Block and space totals are unaffected, as they never consulted subnets.
The utilization tests asserted block and space arithmetic but nothing verified a network's own size and used, nor that external networks reported utilization at all. Both defects fixed in this branch would have passed CI. Assert that an expanded network never reports more used than its size, that an external network's used is the space assigned to its subnets, that an external subnet's used is its endpoint count, and that a block counts an external network once rather than its subnets again.
…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
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.0.x,@mui/icons-material7.3.x → 9.0.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+)Engine & Backend
msal,azure-common,azure-keyvault-secrets, andsix; addedazure-mgmt-resource-subscriptionsto address Azure SDK module separationpyproject.tomlwith Ruff linter configuration (pycodestyle, pyflakes, isort). Resolved all lint violations across 18 engine files — bare except clauses, wildcard imports replaced with explicit names, unused imports/variables, invalid escape sequences, import sorting and groupingnextAvailableVNetaborted 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 problemsresourceGroups/,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 reasonUI & UX Improvements
AuthHandlerfor 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 422Notifications & 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 un-importable 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 scriptsbuild.ps1version gate and the UIpackage.jsonenginesfieldADD→COPY, JSON notation forCMD/ENTRYPOINT,pipefailfor pipedRUNcommands). Added centralized.hadolint.yamlfor rule suppressionscheckout@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.jsonconfigurationExamples
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
Get-AzAccessTokenbreaking changes not accounted fornextAvailableVNetfailed with an internal server error when using multiple Blocks withsmallest_cidrand no range was available in the first Block[major]