Skip to content

fix: never delete APIM's master subscription, and delete resources that use each other in one run - #339

Open
Chris Dayne (cdayne) wants to merge 6 commits into
Azure:mainfrom
cdayne:fix/publish-deletes
Open

Chris Dayne (cdayne) wants to merge 6 commits into
Azure:mainfrom
cdayne:fix/publish-deletes

Conversation

@cdayne

@cdayne Chris Dayne (cdayne) commented Nov 6, 2026 •

Copy link
Copy Markdown

Summary

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
Copilot AI balanced review requested due to automatic review settings November 6, 2026 06:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

3 open issues
What changed in this PR

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.
tests/​unit/​services/​delete-unmatched-service.test.ts Tests protection of the built-in subscription.
tests/​unit/​models/​resource-types.test.ts Tests built-in subscription identification.
src/​services/​resource-publisher.ts Removes the separate root-scope eligibility check.
src/​services/​resource-extractor.ts Skips the built-in subscription during extraction.
src/​services/​publish-service.ts Filters master and retries dependency-blocked deletes.
src/​services/​dry-run-reporter.ts Excludes master from incremental delete previews.
src/​services/​delete-unmatched-service.ts Protects master as a subscription rather than a product.
src/​models/​resource-types.ts Adds the shared built-in subscription predicate.
src/​clients/​apim-client.ts Recognizes resource-in-use deletion errors.
docs/​guides/​environment-overrides.md Explains how master artifacts are handled.
docs/​commands/​publish.md Documents deletion rounds and built-in exclusions.

Review effort: Balanced

Comment thread src/services/publish-service.ts Resolved
Comment thread src/services/publish-service.ts Resolved
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.
Copilot AI balanced review requested due to automatic review settings November 6, 2026 07:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Filtering before environment mapping can allow publication as master while silently omitting valid affixed subscriptions.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity Filter artifacts by mapped deployment identity

src/​services/​publish-service.ts:304

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.

Low severity 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.

2 resolved since last review

Review effort: Balanced

…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.
Copilot AI balanced review requested due to automatic review settings November 6, 2026 07:26
@cdayne

Copy link
Copy Markdown
Author

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity 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.

Review effort: Balanced

@cdayne

Copy link
Copy Markdown
Author

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants