Skip to content

Commit 2fb3b5a

Browse files
authored
fix: delete-unmatched revisioned API cleanup (Closes #142)
1 parent 98908d0 commit 2fb3b5a

6 files changed

Lines changed: 380 additions & 32 deletions

File tree

.github/workflows/integration-test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,23 @@ jobs:
232232
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
233233
-LogLevel '${{ steps.settings.outputs.logLevel }}'
234234
235+
- name: Run Round-Trip Phase 6b (Delete Unmatched)
236+
if: success()
237+
shell: pwsh
238+
env:
239+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
240+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
241+
run: |
242+
$extractOutputDir = '${{ steps.phase2.outputs.ExtractOutputDir }}'
243+
244+
./tests/integration/all-resource-types/phases/run-phase6-delete-unmatched.ps1 `
245+
-TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' `
246+
-TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' `
247+
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
248+
-OverrideFile '${{ steps.phase4.outputs.overrideFile }}' `
249+
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
250+
-ExtractOutputDir $extractOutputDir
251+
235252
- name: Run Round-Trip Phase 7 (Teardown)
236253
if: always()
237254
shell: pwsh

src/services/publish-service.ts

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -665,49 +665,127 @@ async function deleteTier(
665665
context: ApimServiceContext,
666666
descriptors: ResourceDescriptor[]
667667
): Promise<PublishActionResult[]> {
668-
const tasks = descriptors.map((descriptor) => async () => {
669-
try {
670-
const deleted = await client.deleteResource(context, descriptor);
668+
const apiDescriptors = descriptors.filter((d) => d.type === ResourceType.Api);
669+
const nonApiDescriptors = descriptors.filter((d) => d.type !== ResourceType.Api);
671670

672-
return {
673-
descriptor,
674-
action: 'delete' as const,
675-
status: deleted ? ('success' as const) : ('skipped' as const),
676-
};
677-
} catch (error) {
678-
logger.error(
679-
`Failed to delete ${buildResourceLabel(descriptor)}:`,
680-
error
681-
);
682-
return {
683-
descriptor,
684-
action: 'delete' as const,
685-
status: 'failed' as const,
686-
error: error instanceof Error ? error : new Error(String(error)),
687-
};
671+
const results: PublishActionResult[] = [];
672+
673+
if (nonApiDescriptors.length > 0) {
674+
const nonApiResults = await deleteDescriptorsInParallel(
675+
client,
676+
context,
677+
nonApiDescriptors
678+
);
679+
results.push(...nonApiResults);
680+
}
681+
682+
if (apiDescriptors.length > 0) {
683+
const orderedApiDescriptors = orderApiDescriptorsForDelete(apiDescriptors);
684+
const apiResults = await deleteDescriptorsSequentially(
685+
client,
686+
context,
687+
orderedApiDescriptors
688+
);
689+
results.push(...apiResults);
690+
}
691+
692+
return results;
693+
}
694+
695+
function orderApiDescriptorsForDelete(
696+
descriptors: ResourceDescriptor[]
697+
): ResourceDescriptor[] {
698+
return [...descriptors].sort((a, b) => {
699+
const aName = getNamePart(a.nameParts, 0);
700+
const bName = getNamePart(b.nameParts, 0);
701+
const aRoot = getApiRootName(aName);
702+
const bRoot = getApiRootName(bName);
703+
704+
if (aRoot !== bRoot) {
705+
return aRoot.localeCompare(bRoot);
706+
}
707+
708+
const aIsRevision = isApiRevisionName(aName);
709+
const bIsRevision = isApiRevisionName(bName);
710+
711+
if (aIsRevision === bIsRevision) {
712+
return aName.localeCompare(bName);
688713
}
714+
715+
return aIsRevision ? -1 : 1;
689716
});
717+
}
718+
719+
async function deleteDescriptorsSequentially(
720+
client: IApimClient,
721+
context: ApimServiceContext,
722+
descriptors: ResourceDescriptor[]
723+
): Promise<PublishActionResult[]> {
724+
const results: PublishActionResult[] = [];
725+
726+
for (const descriptor of descriptors) {
727+
results.push(await deleteDescriptor(client, context, descriptor));
728+
}
729+
730+
return results;
731+
}
732+
733+
async function deleteDescriptorsInParallel(
734+
client: IApimClient,
735+
context: ApimServiceContext,
736+
descriptors: ResourceDescriptor[]
737+
): Promise<PublishActionResult[]> {
738+
const tasks = descriptors.map((descriptor) => async () =>
739+
deleteDescriptor(client, context, descriptor)
740+
);
690741

691742
const taskResults = await runParallel(tasks, 5);
692743

693744
return taskResults.map((tr, index) => {
694745
if (tr.status === 'fulfilled' && tr.value) {
695746
return tr.value;
696-
} else {
697-
const descriptor = descriptors[index];
698-
if (!descriptor) {
699-
throw new Error('No descriptor found for failed task');
700-
}
701-
return {
702-
descriptor,
703-
action: 'delete' as const,
704-
status: 'failed' as const,
705-
error: tr.reason || new Error('Unknown error'),
706-
};
707747
}
748+
749+
const descriptor = descriptors[index];
750+
if (!descriptor) {
751+
throw new Error('No descriptor found for failed task');
752+
}
753+
return {
754+
descriptor,
755+
action: 'delete' as const,
756+
status: 'failed' as const,
757+
error: tr.reason || new Error('Unknown error'),
758+
};
708759
});
709760
}
710761

762+
async function deleteDescriptor(
763+
client: IApimClient,
764+
context: ApimServiceContext,
765+
descriptor: ResourceDescriptor
766+
): Promise<PublishActionResult> {
767+
try {
768+
const deleted = await client.deleteResource(context, descriptor);
769+
770+
return {
771+
descriptor,
772+
action: 'delete' as const,
773+
status: deleted ? ('success' as const) : ('skipped' as const),
774+
};
775+
} catch (error) {
776+
logger.error(
777+
`Failed to delete ${buildResourceLabel(descriptor)}:`,
778+
error
779+
);
780+
return {
781+
descriptor,
782+
action: 'delete' as const,
783+
status: 'failed' as const,
784+
error: error instanceof Error ? error : new Error(String(error)),
785+
};
786+
}
787+
}
788+
711789
/**
712790
* Convert ResourcePublishResult to PublishActionResult.
713791
*/

tests/integration/all-resource-types/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,18 @@ Compares source and target APIM resources and reports differences or parity.
181181
./phases/run-phase6-compare.ps1 -SourceSubscriptionId 11111111-1111-1111-1111-111111111111 -SourceResourceGroup rg-src -SourceApimName src-apim -TargetSubscriptionId 22222222-2222-2222-2222-222222222222 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -LogLevel Verbose
182182
```
183183

184+
**Phase 6b: Delete-unmatched validation** (`phases/run-phase6-delete-unmatched.ps1`).
185+
186+
Removes a revisioned API from extracted artifacts, runs `apiops publish --delete-unmatched`, and verifies the removed API revisions are deleted from target APIM.
187+
188+
```powershell
189+
# Minimum parameters
190+
./phases/run-phase6-delete-unmatched.ps1 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -OverrideFile ./phases/extracted-artifacts/.overrides.yaml
191+
192+
# All parameters
193+
./phases/run-phase6-delete-unmatched.ps1 -TargetSubscriptionId 22222222-2222-2222-2222-222222222222 -TargetResourceGroup rg-tgt -TargetApimName tgt-apim -LogLevel Verbose -OverrideFile ./phases/extracted-artifacts/.overrides.yaml -ExtractOutputDir ./phases/extracted-artifacts
194+
```
195+
184196
**Phase 7: Teardown** (`phases/run-phase7-teardown.ps1`).
185197

186198
Deletes source and target resource groups and purges soft-deleted APIM services. This phase always run, regardless of the success of previous phases, unles `-SkipTeardown` switch is specified.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
#requires -Version 7.0
4+
<#
5+
.SYNOPSIS
6+
Phase 6b - Validate publish --delete-unmatched with revisioned APIs.
7+
.DESCRIPTION
8+
Removes a revisioned API from extracted artifacts, runs publish with
9+
--delete-unmatched, and verifies the revisioned API resources are deleted
10+
from the target APIM instance.
11+
#>
12+
13+
[CmdletBinding()]
14+
param(
15+
[string]$TargetSubscriptionId,
16+
17+
[Parameter(Mandatory)]
18+
[string]$TargetResourceGroup,
19+
20+
[Parameter(Mandatory)]
21+
[string]$TargetApimName,
22+
23+
[ValidateSet('Info', 'Verbose', 'Debug')]
24+
[string]$LogLevel = 'Verbose',
25+
26+
[Parameter(Mandatory)]
27+
[string]$OverrideFile,
28+
29+
[string]$ExtractOutputDir = "$PSScriptRoot/extracted-artifacts"
30+
)
31+
32+
$ErrorActionPreference = 'Stop'
33+
$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' }
34+
$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' }
35+
36+
$phaseRoot = Split-Path $PSScriptRoot -Parent
37+
$maskingModule = Join-Path $phaseRoot 'modules/LogMasking.psm1'
38+
$scriptArgModule = Join-Path $phaseRoot 'modules/ScriptRuntime.psm1'
39+
$apiopsCliModule = Join-Path $phaseRoot 'modules/ApiopsCli.psm1'
40+
41+
foreach ($requiredFile in @($maskingModule, $scriptArgModule, $apiopsCliModule)) {
42+
if (-not (Test-Path $requiredFile)) {
43+
Write-Error "Required file not found: $requiredFile"
44+
exit 2
45+
}
46+
}
47+
48+
Import-Module $maskingModule -Force
49+
Import-Module $scriptArgModule -Force
50+
Import-Module $apiopsCliModule -Force
51+
52+
if (-not (Test-Path $ExtractOutputDir)) {
53+
Write-Error "ExtractOutputDir not found: $ExtractOutputDir"
54+
exit 2
55+
}
56+
57+
if (-not (Test-Path $OverrideFile)) {
58+
Write-Error "OverrideFile not found: $OverrideFile"
59+
exit 2
60+
}
61+
62+
$targetSubscriptionIdValue = Get-BoundParameterValueOrNull -BoundParameters $PSBoundParameters -Name 'TargetSubscriptionId'
63+
$apiopsLogLevel = Get-ApiopsLogLevel -ScriptLogLevel $LogLevel
64+
$apiopsAuthArgs = Get-ApiopsAuthArgs
65+
66+
$apiFoldersToRemove = @(
67+
'src-rest-revisioned',
68+
'src-rest-revisioned;rev=2'
69+
)
70+
71+
Write-Host "🧪 Delete-unmatched — remove revisioned API artifacts"
72+
foreach ($apiFolder in $apiFoldersToRemove) {
73+
$apiDirectory = Join-Path $ExtractOutputDir "apis/$apiFolder"
74+
if (-not (Test-Path $apiDirectory)) {
75+
Write-Error "Expected API artifact directory not found: $apiDirectory"
76+
exit 2
77+
}
78+
79+
Remove-Item -Path $apiDirectory -Recurse -Force
80+
Write-Host "Removed artifact directory: $apiDirectory"
81+
}
82+
83+
Write-Host "🧪 Delete-unmatched — publish with --delete-unmatched"
84+
$publishArgs = @(
85+
'publish',
86+
'--resource-group', $TargetResourceGroup,
87+
'--service-name', $TargetApimName,
88+
'--source', $ExtractOutputDir,
89+
'--overrides', $OverrideFile,
90+
'--delete-unmatched',
91+
'--log-level', $apiopsLogLevel
92+
)
93+
if (-not [string]::IsNullOrWhiteSpace($targetSubscriptionIdValue)) {
94+
$publishArgs += @('--subscription-id', $targetSubscriptionIdValue)
95+
}
96+
$publishArgs += $apiopsAuthArgs
97+
98+
$replacements = @{
99+
$TargetResourceGroup = Protect-ResourceGroupName -Value $TargetResourceGroup
100+
$TargetApimName = Protect-ApimName -Value $TargetApimName
101+
$OverrideFile = '.overrides.yaml'
102+
}
103+
Add-ArgumentIfSet -Hashtable $replacements -Key $targetSubscriptionIdValue -Value (Protect-SubscriptionId -Value $targetSubscriptionIdValue)
104+
105+
$publishExitCode = Invoke-MaskedApiopsCommand -Replacements $replacements -Arguments $publishArgs
106+
if ($publishExitCode -ne 0) {
107+
Write-Error "Publish with --delete-unmatched failed (exit code $publishExitCode)"
108+
exit 2
109+
}
110+
111+
Write-Host "🧪 Delete-unmatched — verify APIs are deleted from target APIM"
112+
foreach ($apiId in $apiFoldersToRemove) {
113+
$listArgs = @(
114+
'apim', 'api', 'list',
115+
'--resource-group', $TargetResourceGroup,
116+
'--service-name', $TargetApimName,
117+
'--query', "[?name=='$apiId'].name",
118+
'--output', 'tsv'
119+
)
120+
if (-not [string]::IsNullOrWhiteSpace($targetSubscriptionIdValue)) {
121+
$listArgs += @('--subscription', $targetSubscriptionIdValue)
122+
}
123+
124+
$existingApiNames = Invoke-MaskedAzCommand -Replacements $replacements -Arguments $listArgs
125+
if ($LASTEXITCODE -ne 0) {
126+
Write-Error "Failed to query APIs for validation (api-id: $apiId)"
127+
exit 2
128+
}
129+
130+
$apiNameMatches = $existingApiNames -split '[\r\n]+' |
131+
ForEach-Object { $_.Trim() } |
132+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -eq $apiId }
133+
134+
if ($apiNameMatches) {
135+
Write-Error "API still exists after delete-unmatched publish: $apiId"
136+
exit 2
137+
}
138+
}
139+
140+
Write-Host "✅ Delete-unmatched validation passed for revisioned API cleanup"
141+
exit 0

tests/integration/all-resource-types/run-roundtrip-test.ps1

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Licensed under the MIT license.
33
<#
44
.SYNOPSIS
5-
Master orchestrator for the 7-phase round-trip integration workflow.
5+
Master orchestrator for the round-trip integration workflow.
66
.DESCRIPTION
77
Single entry point that runs the full round-trip sequence:
88
1) deploy source + target APIM instances,
@@ -11,6 +11,7 @@
1111
4) generate target environment overrides,
1212
5) publish artifacts to target,
1313
6) compare source and target,
14+
6b) validate --delete-unmatched behavior,
1415
7) teardown.
1516
1617
Works both locally and in CI (writes to GITHUB_OUTPUT when available).
@@ -115,9 +116,10 @@ $phase3ValidateExtractScript = Join-Path $PSScriptRoot 'phases/run-phase3-valida
115116
$phase4CreateOverridesScript = Join-Path $PSScriptRoot 'phases/run-phase4-create-overrides.ps1'
116117
$phase5PublishScript = Join-Path $PSScriptRoot 'phases/run-phase5-publish.ps1'
117118
$phase6CompareScript = Join-Path $PSScriptRoot 'phases/run-phase6-compare.ps1'
119+
$phase6bDeleteUnmatchedScript = Join-Path $PSScriptRoot 'phases/run-phase6-delete-unmatched.ps1'
118120
$phase7TeardownScript = Join-Path $PSScriptRoot 'phases/run-phase7-teardown.ps1'
119121

120-
foreach ($requiredFile in @($phase1DeployScript, $phase2ExtractScript, $phase3ValidateExtractScript, $phase4CreateOverridesScript, $phase5PublishScript, $phase6CompareScript, $phase7TeardownScript)) {
122+
foreach ($requiredFile in @($phase1DeployScript, $phase2ExtractScript, $phase3ValidateExtractScript, $phase4CreateOverridesScript, $phase5PublishScript, $phase6CompareScript, $phase6bDeleteUnmatchedScript, $phase7TeardownScript)) {
121123
if (-not (Test-Path $requiredFile)) {
122124
Write-Error "Required file not found: $requiredFile"
123125
exit 2
@@ -244,6 +246,24 @@ try {
244246
$global:LASTEXITCODE = 0
245247
& $phase6CompareScript @phase6Args
246248

249+
if ($LASTEXITCODE -ne 0) {
250+
$exitCode = $LASTEXITCODE
251+
exit $exitCode
252+
}
253+
254+
# Phase 6b: Validate delete-unmatched for revisioned APIs
255+
$currentPhase = 'phase6-delete-unmatched'
256+
$phase6bDeleteUnmatchedArgs = @{
257+
TargetResourceGroup = $TargetResourceGroup
258+
TargetApimName = $TargetApimName
259+
TargetSubscriptionId = $TargetSubscriptionId
260+
OverrideFile = $overrideFile
261+
LogLevel = $LogLevel
262+
}
263+
Add-ArgumentIfSet -Hashtable $phase6bDeleteUnmatchedArgs -Key 'ExtractOutputDir' -Value $extractOutputDirValue
264+
$global:LASTEXITCODE = 0
265+
& $phase6bDeleteUnmatchedScript @phase6bDeleteUnmatchedArgs
266+
247267
$exitCode = $LASTEXITCODE
248268
}
249269
catch {

0 commit comments

Comments
 (0)