Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"vitest.explorer",
"GitHub.vscode-pull-request-github",
"GitHub.copilot-chat",
"GitHub.vscode-github-actions",
"ms-azuretools.vscode-azureresourcegroups",
"ms-azure-devops.azure-pipelines",
"vitest.dev"
Expand Down
104 changes: 104 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# ===========================================================================
# Integration Test — Extract→Publish Round-Trip
# ===========================================================================
# Manually triggered workflow that deploys source + target APIM instances,
# extracts from source, publishes to target, and compares the result.
#
# Required secrets (in the 'integration-test' environment):
# - AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (OIDC login)
# - APIM_PUBLISHER_EMAIL
# ===========================================================================

name: Integration Test — Extract→Publish Round-Trip

on:
workflow_dispatch:
inputs:
sku:
description: 'APIM SKU'
required: true
type: choice
options:
- StandardV2
- Developer
- Premium
- PremiumV2
default: StandardV2
location:
description: 'Azure region'
required: false
type: string
default: 'eastus2'
skip_teardown:
description: 'Skip teardown (for debugging)'
required: false
type: boolean
default: false

permissions:
id-token: write
contents: read

concurrency:
group: integration-test
cancel-in-progress: false # Don't cancel — would leave resources running

env:
SOURCE_RG: rg-apiops-bvt-source-${{ github.run_id }}
TARGET_RG: rg-apiops-bvt-target-${{ github.run_id }}

jobs:
roundtrip-test:
name: Extract→Publish Round-Trip
runs-on: ubuntu-latest
timeout-minutes: 120
environment: integration-test # Requires approval for cost protection

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- run: npm ci && npm run build

- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Run Round-Trip Test
shell: pwsh
run: |
$skipTeardown = '${{ inputs.skip_teardown }}' -eq 'true'
$params = @{
SourceResourceGroup = '${{ env.SOURCE_RG }}'
TargetResourceGroup = '${{ env.TARGET_RG }}'
SkuName = '${{ inputs.sku }}'
Location = '${{ inputs.location }}'
PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}'
ExtractOutputDir = './extracted-artifacts'
}
if ($skipTeardown) { $params.SkipTeardown = $true }

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

- name: Upload Extracted Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: extracted-artifacts-${{ inputs.sku }}
path: ./extracted-artifacts/
if-no-files-found: ignore

- name: Emergency Teardown
if: failure() && inputs.skip_teardown != true
shell: pwsh
run: |
Write-Host "🚨 Emergency teardown — deleting resource groups..."
az group delete --name '${{ env.SOURCE_RG }}' --yes --no-wait 2>$null
az group delete --name '${{ env.TARGET_RG }}' --yes --no-wait 2>$null
7 changes: 7 additions & 0 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,17 @@ export class ApimClient implements IApimClient {
});

// Poll for long-running operations (201/202 responses).
// Skip polling for association resources that don't support GET (supportsGet: false in metadata).
// Check status BEFORE reading the body so the body stream is not consumed
// unnecessarily — and to avoid JSON-parsing failures when the 201/202 body
// is XML (e.g. policy endpoints that echo back raw XML on creation).
if (response.status === 201 || response.status === 202) {
const metadata = RESOURCE_TYPE_METADATA[descriptor.type];
if (!metadata.supportsGet) {
// Association resources don't support GET - return empty on success
logger.debug(`Skipping provisioning poll for association resource: ${buildResourceLabel(descriptor)}`);
return {};
}
return await this.pollProvisioningState(context, descriptor);
}

Expand Down
4 changes: 2 additions & 2 deletions src/clients/artifact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class ArtifactStore implements IArtifactStore {
async writeAssociation(
baseDir: string,
descriptor: ResourceDescriptor,
associationType: 'apis' | 'groups',
associationType: 'apis' | 'groups' | 'tags',
names: string[]
): Promise<void> {
const filePath = buildAssociationFilePath(baseDir, descriptor, associationType);
Expand Down Expand Up @@ -158,7 +158,7 @@ export class ArtifactStore implements IArtifactStore {
async readAssociation(
baseDir: string,
descriptor: ResourceDescriptor,
associationType: 'apis' | 'groups'
associationType: 'apis' | 'groups' | 'tags'
): Promise<string[]> {
const filePath = buildAssociationFilePath(baseDir, descriptor, associationType);

Expand Down
6 changes: 3 additions & 3 deletions src/clients/iartifact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export interface IArtifactStore {
): Promise<void>;

/**
* Write an association file (e.g., product → apis.json, product → groups.json).
* Write an association file (e.g., product → apis.json, product → groups.json, product → tags.json).
*/
writeAssociation(
baseDir: string,
descriptor: ResourceDescriptor,
associationType: 'apis' | 'groups',
associationType: 'apis' | 'groups' | 'tags',
names: string[]
): Promise<void>;

Expand Down Expand Up @@ -65,7 +65,7 @@ export interface IArtifactStore {
readAssociation(
baseDir: string,
descriptor: ResourceDescriptor,
associationType: 'apis' | 'groups'
associationType: 'apis' | 'groups' | 'tags'
): Promise<string[]>;

/**
Expand Down
35 changes: 35 additions & 0 deletions src/lib/auto-generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Utilities for detecting auto-generated APIM resource IDs.
*
* APIM auto-generates 24-character lowercase hex IDs for certain resources:
* - ApiSchema: when importing OpenAPI/WSDL specs, schemas get auto-generated IDs
* - NamedValue: logger credentials (e.g., EventHub connection strings)
*
* These auto-generated resources should typically be skipped during publish
* because APIM recreates them when the parent resource is published.
*/

/**
* Pattern matching auto-generated 24-character lowercase hex IDs that APIM creates.
* Examples: "69f15c3c10a45d29d855583a", "69f16ad1ee7da61d543198f7"
*/
const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/;

/**
* Checks if a resource name/ID is an auto-generated 24-character hex ID.
*
* APIM auto-generates these IDs for:
* - ApiSchema resources when importing OpenAPI/WSDL specifications
* - NamedValue resources for logger credentials (EventHub, etc.)
*
* @param name - The resource name or ID to check
* @returns true if the name matches the auto-generated pattern
*
* @example
* isAutoGeneratedId('69f15c3c10a45d29d855583a') // true
* isAutoGeneratedId('src-rest-schema-item') // false
* isAutoGeneratedId('my-named-value') // false
*/
export function isAutoGeneratedId(name: string): boolean {
return AUTO_GENERATED_ID_PATTERN.test(name);
}
97 changes: 95 additions & 2 deletions src/lib/resource-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,13 @@ export function buildSpecificationFilePath(
*
* @param baseDir - Root artifact directory
* @param descriptor - Product or Gateway descriptor
* @param associationType - Type of association (apis or groups)
* @param associationType - Type of association (apis, groups, or tags)
* @returns Full path to association file
*/
export function buildAssociationFilePath(
baseDir: string,
descriptor: ResourceDescriptor,
associationType: 'apis' | 'groups'
associationType: 'apis' | 'groups' | 'tags'
): string {
const validTypes = [ResourceType.Product, ResourceType.Gateway];
if (!validTypes.includes(descriptor.type)) {
Expand Down Expand Up @@ -384,3 +384,96 @@ export function parseArtifactPath(

return undefined;
}

/**
* Check if a resource type is a singleton (no list, only get).
* Singletons have armPathSuffix ending with a fixed segment (no `{n}` placeholder).
* E.g., ServicePolicy (`policies/policy`), ApiWiki (`apis/{0}/wikis/default`).
*/
export function isSingletonType(type: ResourceType): boolean {
const meta = RESOURCE_TYPE_METADATA[type];
// Singleton if the last segment doesn't contain a placeholder
const lastSegment = meta.armPathSuffix.split('/').pop() ?? '';
return !lastSegment.includes('{');
}

/**
* Check if a resource type is a child type requiring a parent.
* Child types have armPathSuffix with more path segments after the first placeholder.
* E.g., `apis/{0}/tags/{1}` or `apis/{0}/policies/policy`.
*/
export function isChildType(type: ResourceType): boolean {
const meta = RESOURCE_TYPE_METADATA[type];
const placeholderCount = countTemplatePlaceholders(meta.armPathSuffix);
// 2+ placeholders means it's definitely a child (e.g., apis/{0}/tags/{1})
if (placeholderCount >= 2) return true;
// Check for nested fixed-name resources under a parent (e.g., apis/{0}/policies/policy)
const parts = meta.armPathSuffix.split('/');
const firstPlaceholderIdx = parts.findIndex(p => p.includes('{'));
return firstPlaceholderIdx >= 0 && firstPlaceholderIdx < parts.length - 1;
}

/**
* Compute the publish tier for a resource type based on ARM path structure.
* Resources are published from lowest tier to highest; same tier runs in parallel.
*
* Tier formula: `placeholderCount * 2 + (hasSegmentsAfterLastPlaceholder ? 1 : 0)`
*
* This ensures:
* - Fewer placeholders = earlier tier (parents before children)
* - Within same placeholder count, resources ending at a placeholder come
* before those with fixed segments after (e.g., operations before operation policies)
*
* Examples:
* `apis/{0}` → tier 2 (1 placeholder, ends at placeholder)
* `apis/{0}/policies/policy` → tier 3 (1 placeholder, has suffix)
* `apis/{0}/operations/{1}` → tier 4 (2 placeholders, ends at placeholder)
* `apis/{0}/operations/{1}/policies/policy` → tier 5 (2 placeholders, has suffix)
*/
export function getPublishTier(type: ResourceType): number {
const meta = RESOURCE_TYPE_METADATA[type];
const parts = meta.armPathSuffix.split('/');
const placeholderCount = countTemplatePlaceholders(meta.armPathSuffix);

// Find the index of the last segment containing a placeholder
let lastPlaceholderIdx = -1;
for (let i = 0; i < parts.length; i++) {
if (parts[i].includes('{')) {
lastPlaceholderIdx = i;
}
}

// Check if there are segments after the last placeholder
const hasSegmentsAfter = lastPlaceholderIdx >= 0 && lastPlaceholderIdx < parts.length - 1;

return placeholderCount * 2 + (hasSegmentsAfter ? 1 : 0);
}

/**
* Check if a resource type is a "grandchild" - has path segments after the last placeholder.
* These types depend on an intermediate parent that must exist first.
*
* @deprecated Use getPublishTier() for N-tier ordering instead
*/
export function hasNestedParent(type: ResourceType): boolean {
const meta = RESOURCE_TYPE_METADATA[type];
const parts = meta.armPathSuffix.split('/');

// Find the index of the last segment containing a placeholder
let lastPlaceholderIdx = -1;
for (let i = 0; i < parts.length; i++) {
if (parts[i].includes('{')) {
lastPlaceholderIdx = i;
}
}

// No placeholders = top-level singleton (ServicePolicy), not a grandchild
if (lastPlaceholderIdx === -1) return false;

// Grandchild if there are segments after the last placeholder
// AND there are at least 2 placeholders (meaning there's an intermediate parent)
const hasSegmentsAfter = lastPlaceholderIdx < parts.length - 1;
const placeholderCount = countTemplatePlaceholders(meta.armPathSuffix);

return hasSegmentsAfter && placeholderCount >= 2;
}
Loading
Loading