Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
17 changes: 17 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ jobs:
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
-LogLevel '${{ steps.settings.outputs.logLevel }}'

- name: Run Round-Trip Phase 6b (Delete Unmatched)

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.

Name 'Phase 7', not 'Phase '6b'. Update numbers of subsequent phases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in f4e93d9. Delete-unmatched is now labeled Phase 7, teardown is Phase 8, and subsequent references were renumbered across workflow, round-trip orchestrator, phase scripts, and integration README.

if: success()
shell: pwsh
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
run: |
$extractOutputDir = '${{ steps.phase2.outputs.ExtractOutputDir }}'

./tests/integration/all-resource-types/phases/run-phase6-delete-unmatched.ps1 `
-TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' `
-TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' `
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
-OverrideFile '${{ steps.phase4.outputs.overrideFile }}' `
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
-ExtractOutputDir $extractOutputDir

- name: Run Round-Trip Phase 7 (Teardown)
if: always()
shell: pwsh
Expand Down
138 changes: 108 additions & 30 deletions src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,49 +665,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
12 changes: 12 additions & 0 deletions tests/integration/all-resource-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ Compares source and target APIM resources and reports differences or parity.
./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
```

**Phase 6b: Delete-unmatched validation** (`phases/run-phase6-delete-unmatched.ps1`).

Removes a revisioned API from extracted artifacts, runs `apiops publish --delete-unmatched`, and verifies the removed API revisions are deleted from target APIM.

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

# All parameters
./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
```

**Phase 7: Teardown** (`phases/run-phase7-teardown.ps1`).

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
#requires -Version 7.0
<#
.SYNOPSIS
Phase 6b - Validate publish --delete-unmatched with revisioned APIs.
.DESCRIPTION
Removes a revisioned API from extracted artifacts, runs publish with
--delete-unmatched, and verifies the revisioned API resources are deleted
from the target APIM instance.
#>

[CmdletBinding()]
param(
[string]$TargetSubscriptionId,

[Parameter(Mandatory)]
[string]$TargetResourceGroup,

[Parameter(Mandatory)]
[string]$TargetApimName,

[ValidateSet('Info', 'Verbose', 'Debug')]
[string]$LogLevel = 'Verbose',

[Parameter(Mandatory)]
[string]$OverrideFile,

[string]$ExtractOutputDir = "$PSScriptRoot/extracted-artifacts"
)

$ErrorActionPreference = 'Stop'
$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' }
$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' }

$phaseRoot = Split-Path $PSScriptRoot -Parent
$maskingModule = Join-Path $phaseRoot 'modules/LogMasking.psm1'
$scriptArgModule = Join-Path $phaseRoot 'modules/ScriptRuntime.psm1'
$apiopsCliModule = Join-Path $phaseRoot 'modules/ApiopsCli.psm1'

foreach ($requiredFile in @($maskingModule, $scriptArgModule, $apiopsCliModule)) {
if (-not (Test-Path $requiredFile)) {
Write-Error "Required file not found: $requiredFile"
exit 2
}
}

Import-Module $maskingModule -Force
Import-Module $scriptArgModule -Force
Import-Module $apiopsCliModule -Force

if (-not (Test-Path $ExtractOutputDir)) {
Write-Error "ExtractOutputDir not found: $ExtractOutputDir"
exit 2
}

if (-not (Test-Path $OverrideFile)) {
Write-Error "OverrideFile not found: $OverrideFile"
exit 2
}

$targetSubscriptionIdValue = Get-BoundParameterValueOrNull -BoundParameters $PSBoundParameters -Name 'TargetSubscriptionId'
$apiopsLogLevel = Get-ApiopsLogLevel -ScriptLogLevel $LogLevel
$apiopsAuthArgs = Get-ApiopsAuthArgs

$apiFoldersToRemove = @(
'src-rest-revisioned',
'src-rest-revisioned;rev=2'
)

Write-Host "🧪 Delete-unmatched — remove revisioned API artifacts"
foreach ($apiFolder in $apiFoldersToRemove) {
$apiDirectory = Join-Path $ExtractOutputDir "apis/$apiFolder"
if (-not (Test-Path $apiDirectory)) {
Write-Error "Expected API artifact directory not found: $apiDirectory"
exit 2
}

Remove-Item -Path $apiDirectory -Recurse -Force
Write-Host "Removed artifact directory: $apiDirectory"
}

Write-Host "🧪 Delete-unmatched — publish with --delete-unmatched"
$publishArgs = @(
'publish',
'--resource-group', $TargetResourceGroup,
'--service-name', $TargetApimName,
'--source', $ExtractOutputDir,
'--overrides', $OverrideFile,
'--delete-unmatched',
'--log-level', $apiopsLogLevel
)
if (-not [string]::IsNullOrWhiteSpace($targetSubscriptionIdValue)) {
$publishArgs += @('--subscription-id', $targetSubscriptionIdValue)
}
$publishArgs += $apiopsAuthArgs

$replacements = @{
$TargetResourceGroup = Protect-ResourceGroupName -Value $TargetResourceGroup
$TargetApimName = Protect-ApimName -Value $TargetApimName
$OverrideFile = '.overrides.yaml'
}
Add-ArgumentIfSet -Hashtable $replacements -Key $targetSubscriptionIdValue -Value (Protect-SubscriptionId -Value $targetSubscriptionIdValue)

$publishExitCode = Invoke-MaskedApiopsCommand -Replacements $replacements -Arguments $publishArgs
if ($publishExitCode -ne 0) {
Write-Error "Publish with --delete-unmatched failed (exit code $publishExitCode)"
exit 2
}

Write-Host "🧪 Delete-unmatched — verify APIs are deleted from target APIM"
foreach ($apiId in $apiFoldersToRemove) {
$listArgs = @(
'apim', 'api', 'list',
'--resource-group', $TargetResourceGroup,
'--service-name', $TargetApimName,
'--query', "[?name=='$apiId'].name",
'--output', 'tsv'
)
if (-not [string]::IsNullOrWhiteSpace($targetSubscriptionIdValue)) {
$listArgs += @('--subscription', $targetSubscriptionIdValue)
}

$existingApiNames = Invoke-MaskedAzCommand -Replacements $replacements -Arguments $listArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to query APIs for validation (api-id: $apiId)"
exit 2
}

$apiNameMatches = $existingApiNames -split '[\r\n]+' |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -eq $apiId }

if ($apiNameMatches) {
Write-Error "API still exists after delete-unmatched publish: $apiId"
exit 2
}
}

Write-Host "✅ Delete-unmatched validation passed for revisioned API cleanup"
exit 0
24 changes: 22 additions & 2 deletions tests/integration/all-resource-types/run-roundtrip-test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Licensed under the MIT license.
<#
.SYNOPSIS
Master orchestrator for the 7-phase round-trip integration workflow.
Master orchestrator for the round-trip integration workflow.
.DESCRIPTION
Single entry point that runs the full round-trip sequence:
1) deploy source + target APIM instances,
Expand All @@ -11,6 +11,7 @@
4) generate target environment overrides,
5) publish artifacts to target,
6) compare source and target,
6b) validate --delete-unmatched behavior,
7) teardown.

Works both locally and in CI (writes to GITHUB_OUTPUT when available).
Expand Down Expand Up @@ -115,9 +116,10 @@ $phase3ValidateExtractScript = Join-Path $PSScriptRoot 'phases/run-phase3-valida
$phase4CreateOverridesScript = Join-Path $PSScriptRoot 'phases/run-phase4-create-overrides.ps1'
$phase5PublishScript = Join-Path $PSScriptRoot 'phases/run-phase5-publish.ps1'
$phase6CompareScript = Join-Path $PSScriptRoot 'phases/run-phase6-compare.ps1'
$phase6bDeleteUnmatchedScript = Join-Path $PSScriptRoot 'phases/run-phase6-delete-unmatched.ps1'
$phase7TeardownScript = Join-Path $PSScriptRoot 'phases/run-phase7-teardown.ps1'

foreach ($requiredFile in @($phase1DeployScript, $phase2ExtractScript, $phase3ValidateExtractScript, $phase4CreateOverridesScript, $phase5PublishScript, $phase6CompareScript, $phase7TeardownScript)) {
foreach ($requiredFile in @($phase1DeployScript, $phase2ExtractScript, $phase3ValidateExtractScript, $phase4CreateOverridesScript, $phase5PublishScript, $phase6CompareScript, $phase6bDeleteUnmatchedScript, $phase7TeardownScript)) {
if (-not (Test-Path $requiredFile)) {
Write-Error "Required file not found: $requiredFile"
exit 2
Expand Down Expand Up @@ -244,6 +246,24 @@ try {
$global:LASTEXITCODE = 0
& $phase6CompareScript @phase6Args

if ($LASTEXITCODE -ne 0) {
$exitCode = $LASTEXITCODE
exit $exitCode
}

# Phase 6b: Validate delete-unmatched for revisioned APIs
$currentPhase = 'phase6-delete-unmatched'
$phase6bDeleteUnmatchedArgs = @{
TargetResourceGroup = $TargetResourceGroup
TargetApimName = $TargetApimName
TargetSubscriptionId = $TargetSubscriptionId
OverrideFile = $overrideFile
LogLevel = $LogLevel
}
Add-ArgumentIfSet -Hashtable $phase6bDeleteUnmatchedArgs -Key 'ExtractOutputDir' -Value $extractOutputDirValue
$global:LASTEXITCODE = 0
& $phase6bDeleteUnmatchedScript @phase6bDeleteUnmatchedArgs

$exitCode = $LASTEXITCODE
}
catch {
Expand Down
Loading