Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 33 additions & 4 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,18 @@ export class ApimClient implements IApimClient {

return response;
} catch (error) {
// Do not retry client errors (4xx) — they are deterministic, not transient.
// 429 rate-limiting is already handled above and never reaches here.
if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
// APIM reports operations blocked by an API's in-progress async operation
// as a transient 409. Other client errors are deterministic.
const isPessimisticConcurrencyConflict =
error instanceof HttpError &&
error.status === 409 &&
error.code === 'PessimisticConcurrencyConflict';
if (
error instanceof HttpError &&
error.status >= 400 &&
error.status < 500 &&
!isPessimisticConcurrencyConflict
) {
throw error;
}
if (attempt >= ApimClient.MAX_RETRIES) {
Expand Down Expand Up @@ -454,7 +463,18 @@ export class ApimClient implements IApimClient {
context: ApimServiceContext,
descriptor: ResourceDescriptor
): Promise<boolean> {
const url = buildArmUri(context, descriptor);
let url = buildArmUri(context, descriptor);

// Deleting an API that has revisions requires deleteRevisions=true; without it
// APIM refuses the base (current-revision) delete with "Cannot delete the
// current revision of an API." This also removes all revisions in one call,
// so callers skip the individual ;rev=N deletes.
if (
descriptor.type === ResourceType.Api &&
!(descriptor.nameParts[0] ?? '').includes(';rev=')
) {
url += '&deleteRevisions=true';
}

for (let attempt = 1; ; attempt++) {
try {
Expand Down Expand Up @@ -482,6 +502,15 @@ export class ApimClient implements IApimClient {
if (message.includes('404')) {
return false;
}
// Resource is still referenced by another entity (e.g. a policy fragment
// used by the service policy). It cannot be deleted until the reference is
// removed; skip it with a warning instead of failing the whole prune.
if (message.includes('is used by the following entities')) {
logger.warn(
`Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity`
);
return false;
}
// Transient optimistic-concurrency conflict: cascade deletes of related
// resources (subscriptions, product/gateway associations) can modify
// the resource while its async DELETE is in flight. Retry the DELETE.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/resource-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ export function getNamePart(nameParts: string[], index: number): string {
return value;
}

/** True when an API name carries a revision suffix (e.g. "my-api;rev=2"). */
export function isApiRevisionName(apiName: string): boolean {
return apiName.includes(';rev=');
}

/** Root API name with any ";rev=N" suffix stripped. */
export function getApiRootName(apiName: string): string {
return apiName.split(';rev=')[0] ?? apiName;
}

/**
* Converts a positional template string to a capturing regex.
* Each `{i}` placeholder becomes a `([^/]+)` capture group; all other
Expand Down
6 changes: 4 additions & 2 deletions src/services/api-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,10 @@ async function extractApiRevisions(
for await (const revision of revisions) {
try {
const revNumber = (revision.apiRevision ?? revision.revisionNumber) as string | undefined;
if (!revNumber || revNumber === '1') {
// Skip revision 1 — it's the main API
// Skip the current revision — it is represented by the main API folder.
// Using isCurrent (not a hard-coded '1') correctly handles APIs whose
// current revision is not revision 1.
if (!revNumber || revision.isCurrent === true) {
continue;
}

Expand Down
62 changes: 57 additions & 5 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PublishConfig } from '../models/config.js';
import * as yaml from 'js-yaml';
import { ResourceType } from '../models/resource-types.js';
import {
normalizeApiAuthenticationSettings,
normalizeMcpToolOperationIds,
publishResource,
type ResourcePublishResult,
Expand Down Expand Up @@ -54,8 +55,13 @@ export async function publishApi(
config: PublishConfig
): Promise<ResourcePublishResult> {
try {
// Step 1: Publish root API (with spec import if available)
const rootResult = await publishRootApi(client, store, context, descriptor, config);
// Step 1: Publish root API (with spec import if available).
// On a fresh target the root is created at its source revision number so
// it cannot collide with ;rev=N revision artifacts.
const putDescriptor = await resolveRootApiPutDescriptor(client, store, context, descriptor, config);
Comment thread
Alexey-Zheltov marked this conversation as resolved.
Outdated
const rootResult = await publishRootApi(client, store, context, descriptor, config, {
putDescriptor,
});
if (rootResult.status !== 'success') {
return rootResult;
}
Expand Down Expand Up @@ -148,6 +154,44 @@ interface RootApiResult {

interface PublishRootApiOptions {
includeSpecification?: boolean;
/** Descriptor to PUT to (defaults to the artifact descriptor). Lets the root
* API be created at apis/{name};rev=N while still reading apis/{name} artifacts. */
putDescriptor?: ResourceDescriptor;
}

/**
* A plain root PUT creates a brand-new API as revision 1, which collides with
* a ;rev=1 revision artifact and silently absorbs it whenever the source's
* current revision number is > 1. When the API does not yet exist on the
* target, PUT the root at its true revision number (apis/{name};rev=N) instead.
* Existing APIs keep the plain root PUT — their current revision cannot be
* renumbered.
*/
async function resolveRootApiPutDescriptor(
client: IApimClient,
store: IArtifactStore,
context: ApimServiceContext,
descriptor: ResourceDescriptor,
config: PublishConfig
): Promise<ResourceDescriptor> {
const json = await store.readResource(config.sourceDir, descriptor);
const rev = (json?.properties as Record<string, unknown> | undefined)?.apiRevision;
if (typeof rev !== 'string' || rev === '' || rev === '1') {
return descriptor;
}

const existing = await client.getResource(context, descriptor);
if (existing) {
return descriptor;
Comment thread
Alexey-Zheltov marked this conversation as resolved.
Outdated
}

return {
...descriptor,
nameParts: [
`${getNamePart(descriptor.nameParts, 0)};rev=${rev}`,
...descriptor.nameParts.slice(1),
],
};
}

/**
Expand Down Expand Up @@ -181,6 +225,7 @@ async function publishRootApi(

// Apply overrides
json = applyOverrides(descriptor, json, config.overrides);
json = normalizeApiAuthenticationSettings(json);
Comment thread
Alexey-Zheltov marked this conversation as resolved.
Outdated
const isCurrent = getApiIsCurrent(json);

// Try to read the specification file for this API
Expand Down Expand Up @@ -239,7 +284,7 @@ async function publishRootApi(
}

// PUT the API resource to APIM
await client.putResource(context, descriptor, json);
await client.putResource(context, options?.putDescriptor ?? descriptor, json);

return {
descriptor,
Expand Down Expand Up @@ -294,9 +339,16 @@ async function publishApiRevisions(
return revA - revB;
});

// Publish each revision in order
// Publish each revision in order; a failed revision must fail the API —
// otherwise errors are silently swallowed and the exit code stays 0.
for (const revDescriptor of sortedRevisions) {
await publishResource(client, store, context, revDescriptor, config);
const result = await publishResource(client, store, context, revDescriptor, config);
Comment thread
Alexey-Zheltov marked this conversation as resolved.
Comment thread
Alexey-Zheltov marked this conversation as resolved.
if (result.status === 'failed') {
throw new Error(
`Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ` +
`${result.error?.message ?? 'unknown error'}`
);
Comment thread
Alexey-Zheltov marked this conversation as resolved.
}
}

return sortedRevisions.length;
Expand Down
46 changes: 45 additions & 1 deletion src/services/delete-unmatched-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,54 @@ import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'
import type { PublishConfig } from '../models/config.js';
import { ResourceType } from '../models/resource-types.js';
import { getTopologicalOrder } from '../lib/dependency-graph.js';
import { getNameFromNameParts } from '../lib/resource-path.js';
import {
getNameFromNameParts,
getNamePart,
getApiRootName,
isApiRevisionName,
} from '../lib/resource-path.js';
import { logger } from '../lib/logger.js';
import { toCanonicalDescriptor } from './env-mapper.js';

/**
* Drop ;rev=N API deletes whose base API (same workspace) is also queued for
* deletion. The base API delete uses deleteRevisions=true and removes all
* revisions in one call, so individual revision deletes are redundant and can
* hit APIM's "Cannot delete the current revision of an API" error.
*
* Shared by the real delete path and the dry-run reporter so the preview
* matches what publish would actually delete.
*/
export function filterRevisionDeletesHandledByBaseApi(
descriptors: ResourceDescriptor[]
): ResourceDescriptor[] {
const baseApiKeys = new Set<string>();
for (const descriptor of descriptors) {
if (descriptor.type !== ResourceType.Api) {
continue;
}
const apiName = getNamePart(descriptor.nameParts, 0);
if (!isApiRevisionName(apiName)) {
baseApiKeys.add(`${descriptor.workspace ?? ''}::${apiName}`);
}
}

if (baseApiKeys.size === 0) {
return descriptors;
}

return descriptors.filter((descriptor) => {
if (descriptor.type !== ResourceType.Api) {
return true;
}
const apiName = getNamePart(descriptor.nameParts, 0);
if (!isApiRevisionName(apiName)) {
return true;
}
return !baseApiKeys.has(`${descriptor.workspace ?? ''}::${getApiRootName(apiName)}`);
});
}

/**
* Built-in groups that should never be deleted
*/
Expand Down
23 changes: 15 additions & 8 deletions src/services/dry-run-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { buildResourceLabel } from '../lib/resource-uri.js';
import { getNamePart } from '../lib/resource-path.js';
import { ResourceType } from '../models/resource-types.js';
import { logger } from '../lib/logger.js';
import { computeDeleteActions } from './delete-unmatched-service.js';
import {
computeDeleteActions,
filterRevisionDeletesHandledByBaseApi,
} from './delete-unmatched-service.js';

export interface DryRunAction {
operation: 'PUT' | 'DELETE' | 'SKIP';
Expand Down Expand Up @@ -106,8 +109,10 @@ export async function generateDryRunReport(

// In incremental mode, use precomputed deleted descriptors from git diff.
// Otherwise, if delete-unmatched is enabled, calculate full unmatched deletes.
// Apply the same ;rev=N filtering as the real delete path so the preview
// matches what publish would actually delete.
if (incrementalDeletedDescriptors.length > 0) {
for (const descriptor of incrementalDeletedDescriptors) {
for (const descriptor of filterRevisionDeletesHandledByBaseApi(incrementalDeletedDescriptors)) {
try {
const existing = await client.getResource(context, descriptor);

Expand Down Expand Up @@ -145,12 +150,14 @@ export async function generateDryRunReport(
}
}
} else if (config.deleteUnmatched) {
const deleteActions = await computeDeleteActionsForDryRun(
client,
store,
context,
config,
targetDescriptors
const deleteActions = filterRevisionDeletesHandledByBaseApi(
await computeDeleteActionsForDryRun(
client,
store,
context,
config,
targetDescriptors
)
);

for (const descriptor of deleteActions) {
Expand Down
35 changes: 20 additions & 15 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ import { logger } from '../lib/logger.js';
import { isAutoGeneratedId } from '../lib/auto-generated.js';
import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { getNamePart, isChildType, isTopLevelSingleton } from '../lib/resource-path.js';
import { getNamePart, isChildType, isTopLevelSingleton, isApiRevisionName, getApiRootName } from '../lib/resource-path.js';

// Import from other agents' files (will be created in parallel)
import { publishResource, ResourcePublishResult, buildKnownArtifactSets } from './resource-publisher.js';
import { publishApi } from './api-publisher.js';
import { publishProduct } from './product-publisher.js';
import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js';
import { computeDeleteActions } from './delete-unmatched-service.js';
import {
computeDeleteActions,
filterRevisionDeletesHandledByBaseApi,
} from './delete-unmatched-service.js';
import { computeGitDiff } from './git-diff-service.js';
import { scanForRedactionMarkers } from './secret-redaction-guard.js';
import { hasNamedValueOverride } from './override-merger.js';
Expand Down Expand Up @@ -344,19 +347,23 @@ async function executePuts(
}
} else if (tier === 2) {
const tier2Descriptors = filterApiRevisionsHandledByRootApis(descriptors);
const apiDescriptors = tier2Descriptors.filter((d) => d.type === ResourceType.Api);
const nonApiDescriptors = tier2Descriptors.filter((d) => d.type !== ResourceType.Api);

const { mcpApis, regularTier2 } = await splitMcpApis(
const { mcpApis, regularTier2: regularApis } = await splitMcpApis(
store,
config.sourceDir,
tier2Descriptors
apiDescriptors
);

await publishAndOutput(client, store, context, config, regularTier2, results);
await publishAndOutput(client, store, context, config, regularApis, results);

if (mcpApis.length > 0) {
logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular tier 2 resources`);
logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular APIs`);
await publishAndOutput(client, store, context, config, mcpApis, results);
}

await publishAndOutput(client, store, context, config, nonApiDescriptors, results);
} else {
// For tiers 3/4, exclude child resources whose parent is being published
// in tier 2 (publishApi/publishProduct handle their children internally).
Expand Down Expand Up @@ -625,14 +632,6 @@ function filterApiRevisionsHandledByRootApis(
});
}

function isApiRevisionName(apiName: string): boolean {
return apiName.includes(';rev=');
}

function getApiRootName(apiName: string): string {
return apiName.split(';rev=')[0] ?? apiName;
}

/**
* Execute DELETE operations in reverse dependency order (tier 4 → tier 1).
*/
Expand Down Expand Up @@ -674,9 +673,15 @@ async function executeDeletesForDescriptors(

const results: PublishActionResult[] = [];

// When a base API is being deleted, the DELETE uses deleteRevisions=true and
// 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);

// Group by tier
const tierGroups = new Map<number, ResourceDescriptor[]>();
for (const descriptor of deleteDescriptors) {
for (const descriptor of effectiveDescriptors) {
const tier = getResourceTier(descriptor.type);
if (!tierGroups.has(tier)) {
tierGroups.set(tier, []);
Expand Down
Loading