Skip to content
Draft
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
4 changes: 3 additions & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ jobs:
Location = '${{ inputs.location }}'
LogLevel = '${{ steps.settings.outputs.logLevel }}'
PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}'
TestDeleteUnmatched = $true
}
./tests/integration/all-resource-types/phases/run-phase1-deploy.ps1 @params

Expand Down Expand Up @@ -217,7 +218,8 @@ jobs:
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
-OverrideFile '${{ steps.phase4.outputs.overrideFile }}' `
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
-ExtractOutputDir $extractOutputDir
-ExtractOutputDir $extractOutputDir `
-TestDeleteUnmatched

- name: Run Round-Trip Phase 6 (Compare)
if: success()
Expand Down
9 changes: 9 additions & 0 deletions src/services/delete-unmatched-service.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 { ResourceType } from '../models/resource-types.js';
import { getTopologicalOrder } from '../lib/dependency-graph.js';
import { getNameFromNameParts } from '../lib/resource-path.js';
import { isAutoGeneratedId } from '../lib/auto-generated.js';

/**
* Built-in groups that should never be deleted
Expand Down Expand Up @@ -171,6 +172,14 @@ function isSystemResource(descriptor: ResourceDescriptor): boolean {
}
}

// Auto-generated named values (e.g. Logger-Credentials--<hex>) are managed by
// APIM and referenced by loggers. They cannot be deleted directly — APIM returns
// HTTP 400 while the logger still references them — and are recreated with their
// parent logger, so they must never be treated as unmatched deletions.
if (descriptor.type === ResourceType.NamedValue && isAutoGeneratedId(ownName)) {
return true;
}

// Check if group name starts with built-in prefix
if (descriptor.type === ResourceType.Group) {
if (ownName.startsWith('built-in')) {
Expand Down
12 changes: 12 additions & 0 deletions src/services/override-merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ const GRANDCHILD_OVERRIDE_MAP: Partial<Record<ResourceType, {
[ResourceType.ApiOperationPolicy]: { parentSection: 'apis', childKey: 'operations', grandchildKey: 'policies' },
};

/**
* Check whether a named value has an explicit override entry.
* Uses case-insensitive matching to align with override-merger behavior.
*/
export function hasNamedValueOverride(name: string, overrides?: OverrideConfig): boolean {
if (!overrides?.namedValues) return false;
const lowerName = name.toLowerCase();
return Object.keys(overrides.namedValues).some(
(key) => key.toLowerCase() === lowerName
);
}

/**
* Apply environment overrides from OverrideConfig to a resource JSON payload.
* Deep-merges matching override properties using case-insensitive key matching.
Expand Down
151 changes: 109 additions & 42 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js';
import { computeDeleteActions } 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';
import { REDACTION_MARKER } from './secret-redactor.js';

/**
Expand Down Expand Up @@ -446,18 +447,6 @@ function splitNamedValues(
return { namedValues, otherTier1 };
}

/**
* Check whether a named value has an explicit override entry.
* Uses case-insensitive matching to align with override-merger behavior.
*/
function hasNamedValueOverride(name: string, overrides?: OverrideConfig): boolean {
if (!overrides?.namedValues) return false;
const lowerName = name.toLowerCase();
return Object.keys(overrides.namedValues).some(
(key) => key.toLowerCase() === lowerName
);
}

/**
* Separates Backend descriptors that are pool backends (properties.type === "Pool")
* from all other descriptors in the supplied list.
Expand Down Expand Up @@ -705,49 +694,127 @@ async function deleteTier(
context: ApimServiceContext,
descriptors: ResourceDescriptor[]
): Promise<PublishActionResult[]> {
const tasks = descriptors.map((descriptor) => async () => {
try {
const deleted = await client.deleteResource(context, descriptor);
const apiDescriptors = descriptors.filter((d) => d.type === ResourceType.Api);
const nonApiDescriptors = descriptors.filter((d) => d.type !== ResourceType.Api);

return {
descriptor,
action: 'delete' as const,
status: deleted ? ('success' as const) : ('skipped' as const),
};
} catch (error) {
logger.error(
`Failed to delete ${buildResourceLabel(descriptor)}:`,
error
);
return {
descriptor,
action: 'delete' as const,
status: 'failed' as const,
error: error instanceof Error ? error : new Error(String(error)),
};
const results: PublishActionResult[] = [];

if (nonApiDescriptors.length > 0) {
const nonApiResults = await deleteDescriptorsInParallel(
client,
context,
nonApiDescriptors
);
results.push(...nonApiResults);
}

if (apiDescriptors.length > 0) {
const orderedApiDescriptors = orderApiDescriptorsForDelete(apiDescriptors);
const apiResults = await deleteDescriptorsSequentially(
client,
context,
orderedApiDescriptors
);
results.push(...apiResults);
}

return results;
}

function orderApiDescriptorsForDelete(
descriptors: ResourceDescriptor[]
): ResourceDescriptor[] {
return [...descriptors].sort((a, b) => {
const aName = getNamePart(a.nameParts, 0);
const bName = getNamePart(b.nameParts, 0);
const aRoot = getApiRootName(aName);
const bRoot = getApiRootName(bName);

if (aRoot !== bRoot) {
return aRoot.localeCompare(bRoot);
}

const aIsRevision = isApiRevisionName(aName);
const bIsRevision = isApiRevisionName(bName);

if (aIsRevision === bIsRevision) {
return aName.localeCompare(bName);
}

return aIsRevision ? -1 : 1;
});
}

async function deleteDescriptorsSequentially(
client: IApimClient,
context: ApimServiceContext,
descriptors: ResourceDescriptor[]
): Promise<PublishActionResult[]> {
const results: PublishActionResult[] = [];

for (const descriptor of descriptors) {
results.push(await deleteDescriptor(client, context, descriptor));
}

return results;
}

async function deleteDescriptorsInParallel(
client: IApimClient,
context: ApimServiceContext,
descriptors: ResourceDescriptor[]
): Promise<PublishActionResult[]> {
const tasks = descriptors.map((descriptor) => async () =>
deleteDescriptor(client, context, descriptor)
);

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];
if (!descriptor) {
throw new Error('No descriptor found for failed task');
}
return {
descriptor,
action: 'delete' as const,
status: 'failed' as const,
error: tr.reason || new Error('Unknown error'),
};
}

const descriptor = descriptors[index];
if (!descriptor) {
throw new Error('No descriptor found for failed task');
}
return {
descriptor,
action: 'delete' as const,
status: 'failed' as const,
error: tr.reason || new Error('Unknown error'),
};
});
}

async function deleteDescriptor(
client: IApimClient,
context: ApimServiceContext,
descriptor: ResourceDescriptor
): Promise<PublishActionResult> {
try {
const deleted = await client.deleteResource(context, descriptor);

return {
descriptor,
action: 'delete' as const,
status: deleted ? ('success' as const) : ('skipped' as const),
};
} catch (error) {
logger.error(
`Failed to delete ${buildResourceLabel(descriptor)}:`,
error
);
return {
descriptor,
action: 'delete' as const,
status: 'failed' as const,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}

/**
* Convert ResourcePublishResult to PublishActionResult.
*/
Expand Down
14 changes: 13 additions & 1 deletion src/services/secret-redaction-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import type { IArtifactStore } from '../clients/iartifact-store.js';
import type { PublishConfig } from '../models/config.js';
import type { ResourceDescriptor } from '../models/types.js';
import { ResourceType } from '../models/resource-types.js';
import { applyOverrides } from './override-merger.js';
import { applyOverrides, hasNamedValueOverride } from './override-merger.js';
import { POLICY_TYPES } from './resource-publisher.js';
import { REDACTION_MARKER } from './secret-redactor.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { getNamePart } from '../lib/resource-path.js';
import { isAutoGeneratedId } from '../lib/auto-generated.js';

/**
* A single artifact that still contains a redaction marker after overrides.
Expand Down Expand Up @@ -103,6 +105,16 @@ async function scanNamedValue(
config: PublishConfig,
descriptor: ResourceDescriptor
): Promise<RedactionMarkerFinding | undefined> {
// Auto-generated named values (e.g. Logger-Credentials--<hex>) are skipped
// during publish because APIM recreates them when the referencing resource
// (Logger, etc.) is published. splitNamedValues() drops them unless the user
// supplies an explicit override, so scanning them here would flag markers in
// artifacts that are never sent to APIM. Mirror the publish filter exactly.
const name = getNamePart(descriptor.nameParts, 0);
if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, config.overrides)) {
return undefined;
}

const json = await store.readResource(config.sourceDir, descriptor);
if (!json) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) Microsoft Corporation.
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
<#
.SYNOPSIS
Expand Down
19 changes: 14 additions & 5 deletions tests/integration/all-resource-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ cd tests/integration/all-resource-types
./run-roundtrip-test.ps1 -PublisherEmail admin@contoso.com
```

### Run full round trip with delete-unmatched coverage

Seeds extra "unmatched" resources into the target APIM, publishes with `--delete-unmatched`, and lets the compare phase confirm they were removed. This keeps the default round trip fast while still exercising the delete-unmatched path on demand.

```powershell
cd tests/integration/all-resource-types
./run-roundtrip-test.ps1 -PublisherEmail admin@contoso.com -TestDeleteUnmatched
```

### Run full round trip with log:

#### Bash
Expand Down Expand Up @@ -84,14 +93,14 @@ An apim instance with the following apis

**Phase 1: Deploy source + target** (`phases/run-phase1-deploy.ps1`).

Deploys source and target APIM environments in parallel.
Deploys source and target APIM environments in parallel. Add `-TestDeleteUnmatched` to seed extra "unmatched" resources (a revisioned API plus a named value and backend) into the target instance so the `--delete-unmatched` publish path can be exercised.

```powershell
# Minimum parameters
./phases/run-phase1-deploy.ps1 -SourceResourceGroup rg-src -TargetResourceGroup rg-tgt -PublisherEmail admin@contoso.com

# All parameters
./phases/run-phase1-deploy.ps1 -SourceResourceGroup rg-src -TargetResourceGroup rg-tgt -PublisherEmail admin@contoso.com -SkuName StandardV2 -Location eastus2 -LogLevel Verbose -SourceApimName src-apim -TargetApimName tgt-apim -SourceSubscriptionId 11111111-1111-1111-1111-111111111111 -TargetSubscriptionId 22222222-2222-2222-2222-222222222222
./phases/run-phase1-deploy.ps1 -SourceResourceGroup rg-src -TargetResourceGroup rg-tgt -PublisherEmail admin@contoso.com -SkuName StandardV2 -Location eastus2 -LogLevel Verbose -SourceApimName src-apim -TargetApimName tgt-apim -SourceSubscriptionId 11111111-1111-1111-1111-111111111111 -TargetSubscriptionId 22222222-2222-2222-2222-222222222222 -TestDeleteUnmatched
```

Script returns resolved names, which can be for later phases, especially in the case minimal parameters are passed to the script. Example return value:
Expand Down Expand Up @@ -159,19 +168,19 @@ Script returns path to created configuration overrides file. Example return valu

**Phase 5: Publish** (`phases/run-phase5-publish.ps1`).

Publishes extracted artifacts to the target APIM instance using the generated overrides file.
Publishes extracted artifacts to the target APIM instance using the generated overrides file. Add `-TestDeleteUnmatched` to publish with `--delete-unmatched`, removing resources present in the target but absent from the extracted artifacts.

```powershell
# Minimum parameters
./phases/run-phase5-publish.ps1 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -OverrideFile ./phases/extracted-artifacts/.overrides.yaml

# All parameters
./phases/run-phase5-publish.ps1 -TargetSubscriptionId 22222222-2222-2222-2222-222222222222 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -LogLevel Debug -OverrideFile ./phases/extracted-artifacts/.overrides.yaml -ExtractOutputDir ./phases/extracted-artifacts
./phases/run-phase5-publish.ps1 -TargetSubscriptionId 22222222-2222-2222-2222-222222222222 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -LogLevel Debug -OverrideFile ./phases/extracted-artifacts/.overrides.yaml -ExtractOutputDir ./phases/extracted-artifacts -TestDeleteUnmatched
```

**Phase 6: Compare source and target API Management instances** (`phases/run-phase6-compare.ps1`).

Compares source and target APIM resources and reports differences or parity.
Compares source and target APIM resources and reports differences or parity. When the round trip runs with `-TestDeleteUnmatched`, this phase also confirms the seeded unmatched resources were removed by the `--delete-unmatched` publish.

```powershell
# Minimum parameters
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) Microsoft Corporation.
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
<#
.SYNOPSIS
Expand Down
Loading