You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix: never delete APIM's master subscription, and delete resources that use each other in one run - #339
Stops --delete-unmatched from permanently deleting APIM's built-in master subscription, and lets one publish delete resources that use each other instead of needing several runs.
Changes
APIM's master subscription
--delete-unmatched deleted APIM's built-in all-access subscription, master, whenever the artifacts didn't contain it. SYSTEM_RESOURCES listed master as a product ("Master product"), which APIM doesn't have, and isSystemResource only checks that list for products and APIs. APIM accepts the delete, and refuses to recreate the subscription afterwards: a subscription's scope has to be an API or a product.
isBuiltInSubscription now names it. Extract leaves it out; publish leaves it out when choosing what to publish, so the "Skipping subscription "master"" warning every publish gave is gone; full and incremental deletes, real and dry-run, never delete it.
Deletes check the subscription under its name on the instance, after any environment prefix or suffix, so one that deploys as master is never deleted and dev-master is deleted like any other. Publish leaves out, with a warning, a subscription that an affix would publish as master (aster with prefix m).
Publish's separate root-scope check is gone: master is the only root-scoped subscription APIM has.
Deletes of resources that use each other
Publish deletes tier by tier, each tier's deletes in parallel. Resources in one tier can use each other: a backend pool its members, a backend or logger the named value in its credentials, a policy fragment a named value. APIM rejects deleting one that something still uses, so emptying an instance took several runs, and an incremental publish (--commit-id) left the resource behind for good, since the next commit doesn't list its deletion again. --delete-unmatched parameter doesn't delete some API's cleanly #142's log shows the same failure for a named value its logger used.
A tier's deletes now run in rounds: a delete APIM rejects because another resource still uses it (isResourceInUseError: "is used by the following entities", "is referenced in") is tried again once the rest of the round is done, for as long as each round deletes something. Any other failure isn't retried, and failures are logged once, at the end. A delete's HTTP retries are counted across its rounds.
Docs
The environment-overrides guide's note on master, and the publish command reference on how deletes are ordered and which built-in resources they leave alone: a full --delete-unmatched leaves all of them, --commit-id deletes one whose artifact the commit removes, and master is never deleted.
Why the added structure
The constitution asks for added complexity to be justified (§V):
Delete rounds instead of ordering deletes by dependency: for a full --delete-unmatched, the resources being deleted exist only on the target, so their dependencies aren't in the artifacts to model. Retrying only APIM's "in use" rejections, while each round deletes something, covers every such dependency, including ones publish doesn't model, and adds at most one round per level.
Behaviour changes users may notice
master is no longer extracted, published or deleted. An earlier extract's artifact for it is ignored, and --remove-stale removes it when subscriptions are in its scope.
A product named master is now deleted by --delete-unmatched like any other product missing from the artifacts (APIM has no built-in master product).
A subscription that an environment affix would publish as master is left out with a warning.
--delete-unmatched and incremental deletes remove resources that use each other in one run.
Validation
npm test: all pass except the 2 environment-related package-build integration tests that also fail on main. npm run lint and npm run build are clean, and every commit passes on its own.
Integration runs on 2026-11-06 against StandardV2 source and target instances:
On main, publishing an empty folder with --delete-unmatched deleted the target's master subscription; a PUT to recreate it was rejected ("Subscription scope should be one of '/apis', '/apis/{apiId}', '/products/{productId}'").
With this change: extract writes no master; publishing an older extract's output that has it sends nothing for it and logs nothing about it; a dry-run --delete-unmatched from an empty folder against the source plans 46 deletes, none of them master.
A target with a backend pool and its member, a logger and a backend whose credentials use named values, APIs, a product and a workspace was emptied by one --delete-unmatched run: 20 deletes, two extra rounds, exit 0. On main the same took three runs.
Not verified
A real --delete-unmatched run against an instance that has master: only the dry run was checked, to keep the source's master.
Related Issue(s)
Refs #142 (the delete ordering failure in its log)
…ish and delete
APIM's built-in all-access subscription, master, is scoped to the service
root. APIM manages it: a subscription's scope has to be an API or a
product, so it can't be created or updated through the API, and once
deleted it can't be recreated.
- --delete-unmatched deleted it whenever the artifacts didn't contain it.
SYSTEM_RESOURCES listed 'master' as a product ("Master product"), but
isSystemResource only checks that list for products and APIs, so the
subscription was never protected; APIM accepts the DELETE. Confirmed on
a StandardV2 instance, where the deleted master couldn't be put back.
An incremental publish with --delete-unmatched did the same when its
artifact was removed from the source.
- Extract wrote it, and every publish then warned "Skipping subscription
"master": root-scoped subscriptions are managed by APIM", although the
docs already said publish skips it.
isBuiltInSubscription now names it, and extract leaves it out, publish
leaves it out when choosing what to publish (quietly, so no warning on
every run), and full and incremental deletes, real and dry-run, never
delete it. Publish's separate root-scope check is gone: master is the only
root-scoped subscription APIM has. There is no master product, so a
product named master is now deleted by --delete-unmatched like any other.
…are gone
Publish deletes tier by tier, each tier's deletes in parallel. Resources
in one tier can use each other: a backend pool its members, a backend or
logger the named value in its credentials, a policy fragment a named
value. APIM rejects deleting one that something still uses ("The Api
Management Property 'x' is used by the following entities: /loggers/y",
"Backend Entity 'x' is referenced in Backend 'pool'"), so emptying an
instance with --delete-unmatched took three runs, and an incremental
publish (--commit-id) left the resource behind for good, since the next
commit doesn't list its deletion again. Azure#142's log shows the same failure
for a named value its logger used.
A tier's deletes now run in rounds: a delete APIM rejects because another
resource still uses it (isResourceInUseError) is tried again once the
rest of the round is done, for as long as each round deletes something.
That covers every such dependency without modelling it, including
resources only the target knows about, whose artifacts a full
--delete-unmatched doesn't have. Any other failure isn't retried, and a
delete something outside the run still blocks fails after one more try.
Failures are logged once, at the end.
Refs Azure#142
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
The deletion guard checks canonical rather than deployed names, allowing environment mapping to trigger deletion of the built-in master subscription, which cannot be recreated.
This PR protects APIM’s built-in master subscription across extraction and publishing, and adds deletion rounds to handle dependencies within a tier, addressing the ordering failure illustrated in #142.
Changes:
Centralizes master detection and excludes it from extraction, publish targets, and delete paths.
Retries resource-in-use deletion failures while each round makes progress.
Updates regression tests and documents deletion behavior.
File
Description
tests/unit/services/resource-publisher.test.ts
Removes the superseded root-scope skip test.
tests/unit/services/resource-extractor.test.ts
Tests exclusion of master from extraction.
tests/unit/services/publish-service.test.ts
Tests subscription protection and deletion rounds.
tests/unit/services/dry-run-reporter.test.ts
Removes expectations for generic root-scope skipping.
The deletes guarded `master` by its source name, before environment
mapping. Subscriptions are in the default appliesTo, so with prefix `m` a
source subscription `aster` deploys as `master`, and its deletion sent a
DELETE for the built-in subscription; with prefix `dev-`, a user's own
`dev-master` read as `master` and was never deleted.
Full and incremental deletes, real and dry-run, now check the name on the
instance.
A delete retried before APIM rejected it as in use, then deleted in a
later round, reported only the last round's retries. Each resource's
retries now add up across rounds.
This checks the artifact name before environment mapping is initialized: aster with namePrefix: 'm' passes the filter and is published as master, including a planned PUT in dry-run. Conversely, a valid API- or product-scoped artifact named master that would deploy as dev-master is silently omitted. Check the deployed identity after mapping is initialized and before publication or dry-run planning; distinguish legacy root-scoped artifacts by scope so they remain ignored without excluding valid affixed subscriptions.
Limit built-in delete exclusions to full publishes
docs/commands/publish.md:206
The 'never deleted' guarantee is incorrect for incremental publishes: removing a starter product artifact with --commit-id --delete-unmatched still sends its DELETE. Commit-scoped deletes protect master but bypass the other built-in exclusions in full unmatched discovery. Scope those other protections to full publishes so users do not rely on a safety guarantee incremental mode lacks.
…master
Publish left out the built-in `master` by its source name, before the
environment mapping is built, so a subscription `aster` with
`namePrefix: 'm'` was published, and planned in dry-run, as `master`,
writing to APIM's built-in subscription. Once the mapping is built, a
subscription whose name on the instance is `master` is left out with a
warning, as deletes already check it.
On the two items the latest review lists as previously missed:
Filter by the name on the instance (publish-service.ts): fixed in a31ea45 for the affix case. Once the environment mapping is built, a subscription whose name on the instance would be master (aster with namePrefix: 'm') is left out of publish and of the dry-run plan, with a warning. Deletes already check the name on the instance.
The other case doesn't arise: APIM keeps the service-level name master for its built-in subscription, so a master artifact is always that subscription. It is scoped to /, so it can't be published under any name, dev-master included, and publish keeps leaving it out by its source name.
"Never deleted" for incremental publishes (docs/commands/publish.md): fixed in 20d964a. The reference now says a full --delete-unmatched leaves the built-in groups, unlimited, starter and echo-api alone, and that with --commit-id a commit that removes one of their artifacts deletes it. Only master is never deleted. I scoped the docs rather than the code because removing an artifact in a commit is an explicit request to delete it.
The comment on the master delete guard is already fixed in e70159a and has a reply, so its thread only needs resolving.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Asynchronous DELETE in-use failures are not retried because polling throws a plain Error that fails the HttpError check, potentially leaving resources behind that subsequent incremental publishes may not select again.
Retry asynchronous DELETE failures when the resource is in use
src/clients/apim-client.ts:59
An in-use failure returned by an asynchronous DELETE is not retried: pollAsyncOperation throws a plain Error (apim-client.ts:1087–1092), which fails this HttpError check even when its message matches. The resource is left behind after its dependents are deleted, and subsequent incremental publishes may not select it again. Preserve and recognize structured in-use errors from polling, and add a regression test for that asynchronous failure path.
On the previously missed item about async DELETE failures (apim-client.ts:59): I haven't changed the code, because APIM reports this rejection synchronously.
pollAsyncOperation does throw a plain Error, so an in-use failure reported through polling wouldn't be retried. But APIM checks whether a resource is in use before it accepts the DELETE, and answers with a 400 straight away:
In the integration runs, every in-use rejection came back as a synchronous 400 ValidationError, for named values (whose writes are asynchronous), backends in a pool and backends using named values.
I also checked a policy fragment, whose writes are asynchronous too: deleting it while an API policy includes it returned an immediate HTTP 400 with "The Policy Fragment '…' is used by the following entities", not a 202.
So isResourceInUseError sees every in-use rejection we've seen APIM give. If APIM ever reports one through the async operation, carrying the error code through polling would be the fix; I'd rather not add that without a case that needs it.
This branch has not been deployed
No deployments
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
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.
Summary
Stops
--delete-unmatchedfrom permanently deleting APIM's built-inmastersubscription, and lets one publish delete resources that use each other instead of needing several runs.Changes
APIM's
mastersubscription--delete-unmatcheddeleted APIM's built-in all-access subscription,master, whenever the artifacts didn't contain it.SYSTEM_RESOURCESlistedmasteras a product ("Master product"), which APIM doesn't have, andisSystemResourceonly checks that list for products and APIs. APIM accepts the delete, and refuses to recreate the subscription afterwards: a subscription's scope has to be an API or a product.isBuiltInSubscriptionnow names it. Extract leaves it out; publish leaves it out when choosing what to publish, so the "Skipping subscription "master"" warning every publish gave is gone; full and incremental deletes, real and dry-run, never delete it.masteris never deleted anddev-masteris deleted like any other. Publish leaves out, with a warning, a subscription that an affix would publish asmaster(asterwith prefixm).masteris the only root-scoped subscription APIM has.Deletes of resources that use each other
--commit-id) left the resource behind for good, since the next commit doesn't list its deletion again. --delete-unmatched parameter doesn't delete some API's cleanly #142's log shows the same failure for a named value its logger used.isResourceInUseError: "is used by the following entities", "is referenced in") is tried again once the rest of the round is done, for as long as each round deletes something. Any other failure isn't retried, and failures are logged once, at the end. A delete's HTTP retries are counted across its rounds.Docs
master, and the publish command reference on how deletes are ordered and which built-in resources they leave alone: a full--delete-unmatchedleaves all of them,--commit-iddeletes one whose artifact the commit removes, andmasteris never deleted.Why the added structure
The constitution asks for added complexity to be justified (§V):
--delete-unmatched, the resources being deleted exist only on the target, so their dependencies aren't in the artifacts to model. Retrying only APIM's "in use" rejections, while each round deletes something, covers every such dependency, including ones publish doesn't model, and adds at most one round per level.Behaviour changes users may notice
masteris no longer extracted, published or deleted. An earlier extract's artifact for it is ignored, and--remove-staleremoves it when subscriptions are in its scope.masteris now deleted by--delete-unmatchedlike any other product missing from the artifacts (APIM has no built-inmasterproduct).masteris left out with a warning.--delete-unmatchedand incremental deletes remove resources that use each other in one run.Validation
npm test: all pass except the 2 environment-relatedpackage-buildintegration tests that also fail onmain.npm run lintandnpm run buildare clean, and every commit passes on its own.main, publishing an empty folder with--delete-unmatcheddeleted the target'smastersubscription; a PUT to recreate it was rejected ("Subscription scope should be one of '/apis', '/apis/{apiId}', '/products/{productId}'").master; publishing an older extract's output that has it sends nothing for it and logs nothing about it; a dry-run--delete-unmatchedfrom an empty folder against the source plans 46 deletes, none of themmaster.--delete-unmatchedrun: 20 deletes, two extra rounds, exit 0. Onmainthe same took three runs.Not verified
--delete-unmatchedrun against an instance that hasmaster: only the dry run was checked, to keep the source'smaster.Related Issue(s)
Refs #142 (the delete ordering failure in its log)