Skip to content

Commit d925527

Browse files
author
Peter Hauge
committed
Merge branch 'main' into copilot/fix-github-issue-96
2 parents 03e0e4c + 20f50d9 commit d925527

35 files changed

Lines changed: 2930 additions & 1028 deletions

.github/workflows/integration-test.yml

Lines changed: 129 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
13
# ===========================================================================
24
# Integration Test — Extract→Publish Round-Trip
35
# ===========================================================================
@@ -31,6 +33,7 @@ on:
3133
required: true
3234
type: choice
3335
options:
36+
- Standard
3437
- StandardV2
3538
- Premium
3639
- PremiumV2
@@ -72,6 +75,8 @@ on:
7275
required: true
7376
APIM_PUBLISHER_EMAIL:
7477
required: true
78+
APIM_SKU:
79+
required: false
7580

7681
permissions:
7782
id-token: write
@@ -84,6 +89,8 @@ concurrency:
8489
env:
8590
SOURCE_RG: rg-apiops-bvt-source-${{ github.run_id }}
8691
TARGET_RG: rg-apiops-bvt-target-${{ github.run_id }}
92+
SOURCE_APIM: apiops-bvt-src-${{ github.run_id }}
93+
TARGET_APIM: apiops-bvt-tgt-${{ github.run_id }}
8794

8895
jobs:
8996
roundtrip-test:
@@ -103,14 +110,8 @@ jobs:
103110

104111
- run: npm ci && npm run build
105112

106-
- name: Azure Login (OIDC)
107-
uses: azure/login@v2
108-
with:
109-
client-id: ${{ secrets.AZURE_CLIENT_ID }}
110-
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
111-
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
112-
113-
- name: Run Round-Trip Test
113+
- name: Resolve Workflow Settings
114+
id: settings
114115
shell: pwsh
115116
run: |
116117
$logLevel = '${{ inputs.log_level }}'
@@ -119,61 +120,140 @@ jobs:
119120
throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug."
120121
}
121122
123+
$skuName = '${{ secrets.APIM_SKU }}'
124+
if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = '${{ inputs.sku }}' }
125+
if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = 'StandardV2' }
126+
127+
"logLevel=$logLevel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
128+
"skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
129+
130+
- name: Azure Login
131+
uses: azure/login@v2
132+
with:
133+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
134+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
135+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
136+
137+
- name: Run Round-Trip Phase 1 (Deploy)
138+
id: phase1
139+
shell: pwsh
140+
run: |
122141
$params = @{
123142
SourceResourceGroup = '${{ env.SOURCE_RG }}'
124143
TargetResourceGroup = '${{ env.TARGET_RG }}'
125-
SkuName = '${{ inputs.sku }}'
144+
SourceApimName = '${{ env.SOURCE_APIM }}'
145+
TargetApimName = '${{ env.TARGET_APIM }}'
146+
SourceSubscriptionId = '${{ secrets.AZURE_SUBSCRIPTION_ID }}'
147+
TargetSubscriptionId = '${{ secrets.AZURE_SUBSCRIPTION_ID }}'
148+
SkuName = '${{ steps.settings.outputs.skuName }}'
126149
Location = '${{ inputs.location }}'
127-
LogLevel = $logLevel
150+
LogLevel = '${{ steps.settings.outputs.logLevel }}'
128151
PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}'
129-
ExtractOutputDir = './extracted-artifacts'
130-
HardDelete = $true
131152
}
153+
./tests/integration/all-resource-types/phases/run-phase1-deploy.ps1 @params
132154
133-
./tests/integration/all-resource-types/run-roundtrip-test.ps1 @params
155+
- name: Azure Login - Refresh Before Phase 2
156+
if: success()
157+
uses: azure/login@v2
158+
with:
159+
# Phase 1 can run for a long time while APIM activates; refresh login
160+
# before extract/publish so apiops receives a fresh federated session.
161+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
162+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
163+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
164+
165+
- name: Run Round-Trip Phase 2 (Extract)
166+
id: phase2
167+
if: success()
168+
shell: pwsh
169+
env:
170+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
171+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
172+
run: |
173+
./tests/integration/all-resource-types/phases/run-phase2-extract.ps1 `
174+
-SourceSubscriptionId '${{ steps.phase1.outputs.sourceSubscriptionId }}' `
175+
-SourceResourceGroup '${{ steps.phase1.outputs.sourceResourceGroup }}' `
176+
-SourceApimName '${{ steps.phase1.outputs.sourceApimName }}' `
177+
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
178+
-ExtractOutputDir './extracted-artifacts'
179+
180+
- name: Run Round-Trip Phase 3 (Validate Extract)
181+
if: success()
182+
shell: pwsh
183+
run: |
184+
$skuName = '${{ steps.phase1.outputs.skuName }}'
185+
$extractOutputDir = '${{ steps.phase2.outputs.ExtractOutputDir }}'
186+
187+
./tests/integration/all-resource-types/phases/run-phase3-validate-extract.ps1 `
188+
-SkuName $skuName `
189+
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
190+
-ExtractOutputDir $extractOutputDir
191+
192+
- name: Run Round-Trip Phase 4 (Create Overrides)
193+
id: phase4
194+
if: success()
195+
shell: pwsh
196+
run: |
197+
$extractOutputDir = '${{ steps.phase2.outputs.ExtractOutputDir }}'
198+
199+
./tests/integration/all-resource-types/phases/run-phase4-create-overrides.ps1 `
200+
-TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' `
201+
-TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' `
202+
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
203+
-ExtractOutputDir $extractOutputDir
204+
205+
- name: Run Round-Trip Phase 5 (Publish)
206+
if: success()
207+
shell: pwsh
208+
env:
209+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
210+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
211+
run: |
212+
$extractOutputDir = '${{ steps.phase2.outputs.ExtractOutputDir }}'
213+
214+
./tests/integration/all-resource-types/phases/run-phase5-publish.ps1 `
215+
-TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' `
216+
-TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' `
217+
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
218+
-OverrideFile '${{ steps.phase4.outputs.overrideFile }}' `
219+
-LogLevel '${{ steps.settings.outputs.logLevel }}' `
220+
-ExtractOutputDir $extractOutputDir
221+
222+
- name: Run Round-Trip Phase 6 (Compare)
223+
if: success()
224+
shell: pwsh
225+
run: |
226+
./tests/integration/all-resource-types/phases/run-phase6-compare.ps1 `
227+
-SourceSubscriptionId '${{ steps.phase1.outputs.sourceSubscriptionId }}' `
228+
-SourceResourceGroup '${{ steps.phase1.outputs.sourceResourceGroup }}' `
229+
-SourceApimName '${{ steps.phase1.outputs.sourceApimName }}' `
230+
-TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' `
231+
-TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' `
232+
-TargetApimName '${{ steps.phase1.outputs.targetApimName }}' `
233+
-LogLevel '${{ steps.settings.outputs.logLevel }}'
134234
135235
- name: Upload Extracted Artifacts
136236
if: always()
137237
uses: actions/upload-artifact@v4
138238
with:
139-
name: extracted-artifacts-${{ inputs.sku }}
140-
path: ./extracted-artifacts/
239+
name: extracted-artifacts-${{ steps.phase1.outputs.skuName }}
240+
path: ${{ steps.phase2.outputs.ExtractOutputDir || './extracted-artifacts/' }}
141241
if-no-files-found: ignore
142242

143-
- name: Emergency Teardown
144-
if: failure()
243+
- name: Run Round-Trip Phase 7 (Teardown)
244+
if: always()
145245
shell: pwsh
146246
run: |
147-
$sourceRg = '${{ env.SOURCE_RG }}'
148-
$targetRg = '${{ env.TARGET_RG }}'
149-
$location = '${{ inputs.location }}'
150-
151-
# Capture APIM names before RG deletion so we can purge soft-deleted services.
152-
$sourceApimName = az apim list --resource-group $sourceRg --query "[0].name" -o tsv 2>$null
153-
$targetApimName = az apim list --resource-group $targetRg --query "[0].name" -o tsv 2>$null
154-
155-
Write-Host "🚨 Emergency teardown — deleting resource groups..."
156-
az group delete --name $sourceRg --yes --no-wait 2>$null
157-
az group delete --name $targetRg --yes --no-wait 2>$null
158-
159-
Write-Host "⏳ Waiting for resource group deletions before APIM purge..."
160-
$maxWaitSeconds = 900
161-
$waited = 0
162-
$interval = 30
163-
164-
while ($waited -lt $maxWaitSeconds) {
165-
$srcExists = (az group exists --name $sourceRg -o tsv 2>$null) -eq 'true'
166-
$tgtExists = (az group exists --name $targetRg -o tsv 2>$null) -eq 'true'
167-
if (-not $srcExists -and -not $tgtExists) {
168-
break
169-
}
170-
Start-Sleep -Seconds $interval
171-
$waited += $interval
172-
}
247+
$sourceResourceGroup = '${{ steps.phase1.outputs.sourceResourceGroup }}'
248+
if ([string]::IsNullOrWhiteSpace($sourceResourceGroup)) { $sourceResourceGroup = '${{ env.SOURCE_RG }}' }
173249
174-
foreach ($apimName in @($sourceApimName, $targetApimName)) {
175-
if (-not [string]::IsNullOrWhiteSpace($apimName)) {
176-
Write-Host "🗑️ Purging soft-deleted APIM: $apimName"
177-
az apim deletedservice purge --service-name $apimName --location $location 2>$null
178-
}
179-
}
250+
$targetResourceGroup = '${{ steps.phase1.outputs.targetResourceGroup }}'
251+
if ([string]::IsNullOrWhiteSpace($targetResourceGroup)) { $targetResourceGroup = '${{ env.TARGET_RG }}' }
252+
253+
$location = '${{ steps.phase1.outputs.location }}'
254+
if ([string]::IsNullOrWhiteSpace($location)) { $location = '${{ inputs.location }}' }
255+
256+
./tests/integration/all-resource-types/phases/run-phase7-teardown.ps1 `
257+
-SourceResourceGroup $sourceResourceGroup `
258+
-TargetResourceGroup $targetResourceGroup `
259+
-Location $location

.gitignore

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ Desktop.ini
3939
.local-extract*/
4040

4141
# Files for integration tests
42-
tests/integration/all-resource-types/logs/**
43-
tests/integration/all-resource-types/extracted-artifacts*/**
44-
tests/integration/all-resource-types/target-apim.json
45-
tests/integration/all-resource-types/source-apim.json
46-
tests/integration/all-resource-types/source-apim-post-activation.bicep
42+
tests/integration/all-resource-types/**/logs/**
43+
tests/integration/all-resource-types/**/extracted-artifacts*/**
44+
tests/integration/all-resource-types/bicep/target-apim.json
45+
tests/integration/all-resource-types/bicep/source-apim.json
46+
tests/integration/all-resource-types/bicep/source-apim-post-activation.bicep
4747

4848
# Environment variables
4949
.env
@@ -57,4 +57,3 @@ tests/integration/all-resource-types/source-apim-post-activation.bicep
5757
# Squad: SubSquad activation file (local to this machine)
5858
.squad-workstream
5959
*.d.ts.map
60-
tests/integration/all-resource-types/source-apim-post-activation.json

docs/reference/artifact-format.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ Example policy XML:
241241
- **Info file names** follow the pattern `{resourceType}Information.json`
242242
- **Policy files** are always named `policy.xml`
243243
- **API specifications** are named `specification.{ext}` (e.g., `specification.yaml`, `specification.json`, `specification.wsdl`)
244+
- Specification files are not exported for API types that don't use OpenAPI/WSDL artifacts (for example: WebSocket, MCP, A2A).
244245
- **Wiki files** are named `wiki.md`
245246

246247
---

src/clients/apim-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ export class ApimClient implements IApimClient {
586586
* graphql-link – GraphQL SDL blob (type=graphql)
587587
* wsdl-link – WSDL blob (type=soap)
588588
* openapi-link – OpenAPI 3.0 YAML (type=http, default)
589-
* undefined – (type=websocket — no spec; callers should skip)
589+
* undefined – (type=websocket|a2a — no spec; callers should skip)
590590
*
591591
* SOAP APIs use wsdl-link so the exported specification can be re-imported
592592
* faithfully on publish (matches the Azure/apiops reference tool). APIM's
@@ -612,6 +612,7 @@ export class ApimClient implements IApimClient {
612612
case 'graphql': return 'graphql-link';
613613
case 'soap': return 'wsdl-link';
614614
case 'websocket': return undefined;
615+
case 'a2a': return undefined;
615616
default: return 'openapi-link';
616617
}
617618
}
@@ -743,4 +744,3 @@ export class ApimClient implements IApimClient {
743744
}
744745

745746
}
746-

src/services/api-extractor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@ async function extractApiSpecification(
358358
return false;
359359
}
360360

361+
if (apiType?.toLowerCase() === 'a2a') {
362+
logger.debug(`Skipping spec export for A2A API "${getNamePart(apiDescriptor.nameParts, 0)}" — A2A APIs use JSON-RPC + agent card endpoints, not OpenAPI`);
363+
return false;
364+
}
365+
361366
if (apiType?.toLowerCase() === 'graphql' && hasGraphQLSchema(extractedSchemas)) {
362367
logger.debug(
363368
`Skipping spec export for synthetic GraphQL API "${getNamePart(apiDescriptor.nameParts, 0)}" — schema is captured via ApiSchema`

src/services/api-publisher.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export async function publishApi(
9797
* Maps spec file format to APIM ContentFormat for inline import.
9898
*/
9999
function getImportFormat(specFormat: string, _apiType?: string): string | undefined {
100+
const apiType = _apiType?.toLowerCase();
101+
if (apiType === 'a2a') {
102+
return undefined;
103+
}
104+
100105
switch (specFormat) {
101106
case 'yaml':
102107
return 'openapi';
@@ -178,7 +183,7 @@ async function publishRootApi(
178183

179184
// The `type` property from GET is read-only and ignored on PUT.
180185
// APIM uses `apiType` to determine the API kind during spec import.
181-
// Always set it explicitly (valid values: http, soap, websocket, graphql, odata, grpc, mcp).
186+
// Always set it explicitly (valid values include http, soap, websocket, graphql, odata, grpc, mcp, a2a).
182187
if (apiType) {
183188
cleanProps.apiType = apiType;
184189
}

src/templates/configs/filter-config.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,44 @@ export function generateFilterConfig(): string {
4747
# - rate-limit-fragment
4848
# - cors-fragment
4949
50-
# Uncomment to extract ALL resources (default behavior if no filters specified)
51-
# Leave all sections commented to extract everything
50+
# Extract only specific gateways
51+
# gatewayNames:
52+
# - default
53+
# - internal-gateway
54+
55+
# Extract only specific version sets
56+
# versionSetNames:
57+
# - payments-v1
58+
59+
# Extract only specific groups
60+
# groupNames:
61+
# - administrators
62+
63+
# Extract only specific subscriptions
64+
# subscriptionNames:
65+
# - starter-subscription
66+
67+
# Extract only specific schemas
68+
# schemaNames:
69+
# - pet-schema
70+
71+
# Extract only specific policy restrictions
72+
# policyRestrictionNames:
73+
# - global-policy-restriction
74+
75+
# Extract only specific documentations
76+
# documentationNames:
77+
# - getting-started
78+
79+
# Extract only specific workspaces
80+
# workspaceNames:
81+
# - dev-workspace
82+
83+
# Filter behavior:
84+
# - Leave a section commented out to include ALL resources of that type
85+
# - Set a section to an empty array ([]) to exclude ALL resources of that type
86+
# Example:
87+
# gatewayNames: []
88+
# subscriptionNames: []
5289
`;
5390
}

tests/contract/.gitkeep

Whitespace-only changes.

tests/integration/all-resource-types/Compare-ApimInstance.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
13
<#
24
.SYNOPSIS
35
Compares two Azure API Management instances via ARM REST API.

0 commit comments

Comments
 (0)