Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/commands/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ The output lists each resource and the planned action (create, update, or delete

For a full publish, `--delete-unmatched` deletes APIM resources that are **not** present in the source artifacts. With `--commit-id`, it deletes only resources and Product associations removed by the selected commit.

Deletes run from the most dependent resources to the least (tier 4 to tier 1). Within a tier, resources 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 — and APIM rejects deleting one that something still uses. Such a delete is tried again once the rest of the tier is deleted, so one run removes them all. A full `--delete-unmatched` leaves APIM's built-in resources alone (the built-in groups, the `unlimited` and `starter` products, and `echo-api`); with `--commit-id`, a commit that removes one of their artifacts deletes it. The built-in `master` subscription is never deleted.

> **Warning:** Use with caution. Resources created manually in the Azure portal that are not in your artifact directory will be removed.

## Exit codes
Expand Down
5 changes: 4 additions & 1 deletion docs/guides/environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ subscriptions:
scope: "/apis/petstore-api"
```

> **Note:** The built-in `master` subscription is automatically skipped during publish.
> **Note:** APIM's built-in `master` subscription (the all-access one, scoped to the whole instance) is left alone:
> extract doesn't write it, publish doesn't publish it, and neither `--delete-unmatched` nor an incremental publish
> deletes it, since APIM can't recreate it. An artifact for it from an earlier extract is ignored, and
> `--remove-stale` removes it when subscriptions are in its scope.
> When publish creates a new product, the subscriptions APIM automatically creates for it are deleted,
> so only the extracted product subscriptions (including ones with 24-character hex IDs) are published.
> Deletion is best-effort: a failed delete is logged as a warning and does not fail the publish.
Expand Down
16 changes: 16 additions & 0 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ export function isLinkAlreadyExistsError(error: unknown): boolean {
return error instanceof HttpError && error.status === 409;
}

/**
* Returns true when APIM rejects a DELETE because another resource still uses
* the one being deleted, for example "The Api Management Property 'x' is used
* by the following entities: /backends/y", "Backend Entity 'x' is referenced
* in Backend 'pool'" or "The Policy Fragment 'x' is used by the following
* entities". The message can be the error's own or one of its details, so the
* whole response text is matched.
*/
export function isResourceInUseError(error: unknown): boolean {
return (
error instanceof HttpError &&
error.status === 400 &&
/is used by the following entities|is referenced in/i.test(error.message)
);
}

/** Returns true when APIM reports that an association's referenced API/group is absent. */
export function isAssociationReferenceNotFoundError(error: unknown): boolean {
if (
Expand Down
16 changes: 16 additions & 0 deletions src/models/resource-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ export interface ResourceTypeMetadata {
readonly workspaceLinkIdProperty?: string;
}

/**
* Whether a descriptor is APIM's built-in all-access subscription: `master`,
* scoped to the service root. APIM creates it and manages it: it can't be
* created or updated through the API (a subscription's scope has to be an API
* or a product), and one that is deleted can't be recreated. Extract, publish
* and deletes leave it alone.
*/
export function isBuiltInSubscription(descriptor: { type: ResourceType; nameParts: string[]; workspace?: string }): boolean {
return (
descriptor.type === ResourceType.Subscription &&
!descriptor.workspace &&
descriptor.nameParts.length === 1 &&
descriptor.nameParts[0]?.toLowerCase() === 'master'
);
}

export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata> = {
[ResourceType.NamedValue]: {
armPathSuffix: 'namedValues/{0}',
Expand Down
9 changes: 6 additions & 3 deletions src/services/delete-unmatched-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { IApimClient } from '../clients/iapim-client.js';
import type { IArtifactStore } from '../clients/iartifact-store.js';
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import type { PublishConfig } from '../models/config.js';
import { ResourceType } from '../models/resource-types.js';
import { isBuiltInSubscription, ResourceType } from '../models/resource-types.js';
import { getTopologicalOrder } from '../lib/dependency-graph.js';
import {
getNameFromNameParts,
Expand Down Expand Up @@ -86,7 +86,6 @@ const BUILT_IN_GROUPS = ['administrators', 'developers', 'guests'];
* System resources that should never be deleted
*/
const SYSTEM_RESOURCES = new Set<string>([
'master', // Master product
'unlimited', // Unlimited product
'starter', // Starter product
'echo-api', // Echo API (system test API)
Expand Down Expand Up @@ -135,7 +134,11 @@ export async function computeDeleteActions(
for await (const resource of apimResources) {
const deployedDescriptor = parseResourceDescriptor(resource, resourceType);

if (!deployedDescriptor) {
// APIM's built-in `master` subscription can't be recreated once deleted.
// It is checked under its name on the instance: with an environment
// affix, a source name can deploy as `master`, and `master` can read as
// another source name.
if (!deployedDescriptor || isBuiltInSubscription(deployedDescriptor)) {
continue;
}

Expand Down
6 changes: 5 additions & 1 deletion src/services/dry-run-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { IApimClient } from '../clients/iapim-client.js';
import type { IArtifactStore } from '../clients/iartifact-store.js';
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import type { PublishConfig } from '../models/config.js';
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { isBuiltInSubscription, ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { getTopologicalOrder } from '../lib/dependency-graph.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { getResourceDescriptorKey } from '../lib/resource-path.js';
Expand Down Expand Up @@ -167,6 +167,10 @@ export async function generateDryRunReport(
const deployedDescriptor = config.envMapping
? mapDescriptor(descriptor, config.envMapping)
: descriptor;
// As publish: never the built-in `master` subscription, by its name on the instance.
if (isBuiltInSubscription(deployedDescriptor)) {
continue;
}
const resolvedDescriptor = await resolveAssociationDeleteDescriptor(
client,
context,
Expand Down
103 changes: 83 additions & 20 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
*/

import { IApimClient } from '../clients/iapim-client.js';
import { isResourceInUseError } from '../clients/apim-client.js';
import { IArtifactStore } from '../clients/iartifact-store.js';
import { FilterConfig, PublishConfig, OverrideConfig } from '../models/config.js';
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import { ResourceType } from '../models/resource-types.js';
import { isBuiltInSubscription, ResourceType } from '../models/resource-types.js';
import { getResourceTier, TIER_LABELS } from '../lib/dependency-graph.js';
import { runParallel } from '../lib/parallel-runner.js';
import { logger } from '../lib/logger.js';
Expand Down Expand Up @@ -125,7 +126,7 @@ async function runPublishPipeline(
await client.validatePreFlight(config.service);

// Step 1: Determine target descriptors based on incremental mode
const { targetDescriptors, deletedDescriptors } = await determinePublishTargets(
const { targetDescriptors: selectedDescriptors, deletedDescriptors } = await determinePublishTargets(
store,
config
);
Expand All @@ -139,10 +140,13 @@ async function runPublishPipeline(
// every unchanged artifact in incremental runs.
const allDescriptors = config.commitId
? await store.listResources(config.sourceDir)
: targetDescriptors;
: selectedDescriptors;

// Step 1b: Validate env mapping and store it on config for downstream use
validateAndBuildEnvMapping(config.overrides, allDescriptors, config);
const targetDescriptors = selectedDescriptors.filter(
(descriptor) => !deploysAsBuiltInSubscription(descriptor, config)
);

// Step 1c: Build known artifact sets for policy ref rewriting
config.knownArtifactSets = buildKnownArtifactSets(allDescriptors);
Expand Down Expand Up @@ -299,6 +303,8 @@ async function determinePublishTargets(
deletedDescriptors = [];
}

// APIM's built-in `master` subscription can't be published (isBuiltInSubscription).
targetDescriptors = targetDescriptors.filter((descriptor) => !isBuiltInSubscription(descriptor));
targetDescriptors = filterPublishDescriptors(targetDescriptors, config.filter);
deletedDescriptors = filterPublishDescriptors(deletedDescriptors, config.filter);

Expand All @@ -316,6 +322,22 @@ async function determinePublishTargets(
return { targetDescriptors, deletedDescriptors };
}

/**
* Whether an environment affix makes a subscription's name on the instance
* `master`: publishing it would write to APIM's built-in subscription
* (isBuiltInSubscription), so it is left out with a warning. The source's
* own `master` is left out quietly when choosing what to publish.
*/
function deploysAsBuiltInSubscription(descriptor: ResourceDescriptor, config: PublishConfig): boolean {
if (!config.envMapping || !isBuiltInSubscription(mapDescriptor(descriptor, config.envMapping))) {
return false;
}
logger.warn(
`Skipping subscription "${descriptor.nameParts.join('/')}": the environment affix would publish it as APIM's built-in "master" subscription`
);
return true;
}

async function expandTransitivePublishTargets(
store: IArtifactStore,
sourceDir: string,
Expand Down Expand Up @@ -988,7 +1010,13 @@ async function executeDeletesForDescriptors(
// removes all of its revisions in one call. Drop individual ;rev=N deletes
// whose base API is also in the delete set to avoid redundant/racy deletes and
// the "Cannot delete the current revision of an API" error.
const effectiveDescriptors = filterRevisionDeletesHandledByBaseApi(deleteDescriptors);
// APIM's built-in `master` subscription is never deleted: one that is can't
// be recreated (isBuiltInSubscription). It is checked under the name on the
// instance, which an environment affix can make `master`.
const effectiveDescriptors = filterRevisionDeletesHandledByBaseApi(deleteDescriptors)
.filter((descriptor) => !isBuiltInSubscription(
config.envMapping && !descriptorsAreDeployed ? mapDescriptor(descriptor, config.envMapping) : descriptor
));

// Group by tier
const tierGroups = new Map<number, ResourceDescriptor[]>();
Expand Down Expand Up @@ -1064,10 +1092,6 @@ async function deleteTier(
status: deleted ? ('success' as const) : ('skipped' as const),
};
} catch (error) {
logger.error(
`Failed to delete ${buildResourceLabel(descriptor)}:`,
error
);
return {
descriptor,
action: 'delete' as const,
Expand All @@ -1077,18 +1101,17 @@ async function deleteTier(
}
};

const tasks = descriptors.map((descriptor) => async () => {
const { value, retries } = await trackRetries(() => deleteDescriptor(descriptor));
return attachRetries([value], retries)[0] ?? value;
});

const taskResults = await runParallel(tasks, 5);

return taskResults.map((tr, index) => {
if (tr.status === 'fulfilled' && tr.value) {
return tr.value;
} else {
const descriptor = descriptors[index];
const deleteAll = async (pending: ResourceDescriptor[]): Promise<PublishActionResult[]> => {
const tasks = pending.map((descriptor) => async () => {
const { value, retries } = await trackRetries(() => deleteDescriptor(descriptor));
return attachRetries([value], retries)[0] ?? value;
});
const taskResults = await runParallel(tasks, 5);
return taskResults.map((tr, index) => {
if (tr.status === 'fulfilled' && tr.value) {
return tr.value;
}
const descriptor = pending[index];
if (!descriptor) {
throw new Error('No descriptor found for failed task');
}
Expand All @@ -1098,7 +1121,47 @@ async function deleteTier(
status: 'failed' as const,
error: tr.reason || new Error('Unknown error'),
};
});
};

// Resources in one tier can use each other (a backend pool its members, a
// backend or logger the named value in its credentials, a fragment a named
// value), and APIM rejects deleting one that something still uses. Such a
// delete is tried again once the rest of the tier is gone, for as long as
// each round deletes something.
const finalResults = new Map<ResourceDescriptor, PublishActionResult>();
// HTTP retries add up across rounds: a delete can be retried before APIM
// rejects it as in use, then succeed in a later round.
const retries = new Map<ResourceDescriptor, number>();
let pending = descriptors;
while (pending.length > 0) {
const roundResults = await deleteAll(pending);
for (const result of roundResults) {
retries.set(result.descriptor, (retries.get(result.descriptor) ?? 0) + (result.retries ?? 0));
}
const inUse = roundResults.filter((result) => result.status === 'failed' && isResourceInUseError(result.error));
const progressed = roundResults.some((result) => result.status !== 'failed');
for (const result of roundResults) {
if (!inUse.includes(result) || !progressed) {
finalResults.set(result.descriptor, result);
}
}
if (!progressed) {
break;
}
pending = inUse.map((result) => result.descriptor);
if (pending.length > 0) {
logger.debug(`Deleting ${pending.length} resource(s) again that others being deleted were still using`);
}
}

return descriptors.map((descriptor) => {
const total = retries.get(descriptor) ?? 0;
const result = { ...finalResults.get(descriptor)!, ...(total > 0 ? { retries: total } : {}) };
if (result.status === 'failed') {
logger.error(`Failed to delete ${buildResourceLabel(descriptor)}:`, result.error);
}
return result;
});
}

Expand Down
6 changes: 5 additions & 1 deletion src/services/resource-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { IApimClient } from '../clients/iapim-client.js';
import { IArtifactStore } from '../clients/iartifact-store.js';
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { isBuiltInSubscription, ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
import { redactSecrets } from './secret-redactor.js';
import { shouldIncludeResource } from './filter-service.js';
import { FilterConfig } from '../models/config.js';
Expand Down Expand Up @@ -104,6 +104,10 @@ export async function extractResourceType(
logger.debug(`Filtered out ${buildResourceLabel(descriptor)}`);
continue;
}
if (isBuiltInSubscription(descriptor)) {
logger.debug(`Skipping ${buildResourceLabel(descriptor)}: APIM manages the built-in subscription`);
continue;
}

// Some APIM list endpoints return a shallow response that omits the
// heavyweight payload we need for round-trip publish. ApiSchema list
Expand Down
14 changes: 2 additions & 12 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,8 @@ export async function publishResource(
// but the PUT endpoint requires a relative APIM path (e.g. "/apis" or
// "/apis/{apiId}" or "/products/{productId}"). Strip the service base path
// prefix when it is present so that the publish round-trip works correctly.
// HOWEVER: Skip publishing subscriptions with root-level scope (e.g. the built-in
// "master" subscription), as APIM treats these as read-only system resources
// and returns ValidationError when attempting to update them.
// The built-in root-scoped `master` subscription never gets here: publish
// leaves it out (isBuiltInSubscription).
if (descriptor.type === ResourceType.Subscription) {
const subscriptionName = getNamePart(descriptor.nameParts, 0);
const eligibility = await evaluateResourceEligibility(
Expand Down Expand Up @@ -921,15 +920,6 @@ export async function evaluateResourceEligibility(
return { eligible: true };
}

const properties = json.properties as Record<string, unknown> | undefined;
const scope = properties?.scope as string | undefined;
if (
scope &&
(scope.endsWith('/') || (!scope.includes('/apis') && !scope.includes('/products')))
) {
return { eligible: false, reason: 'root-scoped subscriptions are managed by APIM' };
}

for (const target of findSubscriptionTargets(json, descriptor.workspace)) {
const eligibility = await evaluateAssociationEligibility(
store,
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/models/resource-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { describe, it, expect } from 'vitest';
import { ResourceType, RESOURCE_TYPE_METADATA } from '../../../src/models/resource-types.js';
import { ResourceType, RESOURCE_TYPE_METADATA, isBuiltInSubscription } from '../../../src/models/resource-types.js';

describe('ResourceType enum', () => {
it('should have exactly 35 resource types', () => {
Expand Down Expand Up @@ -146,3 +146,13 @@ describe('RESOURCE_TYPE_METADATA', () => {
}
});
});

describe('isBuiltInSubscription', () => {
it("should recognise only the service-level master subscription", () => {
expect(isBuiltInSubscription({ type: ResourceType.Subscription, nameParts: ['master'] })).toBe(true);
expect(isBuiltInSubscription({ type: ResourceType.Subscription, nameParts: ['MASTER'] })).toBe(true);
expect(isBuiltInSubscription({ type: ResourceType.Subscription, nameParts: ['master'], workspace: 'team-a' })).toBe(false);
expect(isBuiltInSubscription({ type: ResourceType.Subscription, nameParts: ['orders-sub'] })).toBe(false);
expect(isBuiltInSubscription({ type: ResourceType.Product, nameParts: ['master'] })).toBe(false);
});
});
Loading