From 7146ca89df88de1e0f8ac0c084125888c15da63c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 19:09:13 +0000 Subject: [PATCH 1/3] fixes for roundtrip tests in Premium SKU Agent-Logs-Url: https://github.com/Azure/apiops-cli/sessions/08964b6c-4c8a-4dda-bcb9-17322e0e16bb Co-authored-by: EMaher <9244742+EMaher@users.noreply.github.com> --- .github/workflows/integration-test.yml | 21 +- .gitignore | 7 +- .squad/agents/apimexpert/history.md | 8 +- .squad/agents/securityexpert/history.md | 6 + .squad/agents/testengineer/history.md | 13 + src/lib/dependency-graph.ts | 5 +- src/lib/resource-path.ts | 43 +- src/models/resource-types.ts | 8 + src/services/api-publisher.ts | 58 +- src/services/extract-service.ts | 18 +- src/services/product-publisher.ts | 63 + src/services/publish-service.ts | 64 +- src/services/resource-extractor.ts | 71 + src/services/resource-publisher.ts | 78 +- src/services/workspace-extractor.ts | 47 +- .../Compare-ApimInstance.ps1 | 41 +- .../all-resource-types/Deploy-SourceApim.ps1 | 104 +- .../all-resource-types/Deploy-TargetApim.ps1 | 111 ++ .../all-resource-types/DeploymentHelpers.psm1 | 65 + .../all-resource-types/MaskingHelpers.psm1 | 255 ++++ .../integration/all-resource-types/README.md | 5 +- .../expected-structure.json | 19 +- .../all-resource-types/run-roundtrip-test.ps1 | 228 ++- .../source-apim-post-activation.bicep | 106 ++ .../all-resource-types/source-apim.bicep | 124 +- .../all-resource-types/source-apim.json | 1336 ----------------- .../all-resource-types/target-apim.bicep | 22 +- tests/unit/lib/dependency-graph.test.ts | 10 +- tests/unit/lib/resource-path.test.ts | 23 + tests/unit/services/api-publisher.test.ts | 101 ++ tests/unit/services/product-publisher.test.ts | 92 +- tests/unit/services/publish-service.test.ts | 40 + .../unit/services/resource-publisher.test.ts | 136 ++ 33 files changed, 1669 insertions(+), 1659 deletions(-) create mode 100644 tests/integration/all-resource-types/Deploy-TargetApim.ps1 create mode 100644 tests/integration/all-resource-types/DeploymentHelpers.psm1 create mode 100644 tests/integration/all-resource-types/MaskingHelpers.psm1 mode change 100644 => 100755 tests/integration/all-resource-types/run-roundtrip-test.ps1 create mode 100644 tests/integration/all-resource-types/source-apim-post-activation.bicep delete mode 100644 tests/integration/all-resource-types/source-apim.json diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index fafddf9..ae81d2a 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -40,7 +40,12 @@ on: description: 'Azure region' required: false type: string - default: 'eastus2' + default: 'centralus' + log_level: + description: 'PowerShell log level (Info, Verbose, Debug)' + required: false + type: string + default: Verbose skip_teardown: description: 'Skip teardown (for debugging)' required: false @@ -58,7 +63,12 @@ on: description: 'Azure region' required: false type: string - default: 'eastus2' + default: 'centralus' + log_level: + description: 'PowerShell log level (Info, Verbose, Debug)' + required: false + type: string + default: Verbose skip_teardown: description: 'Skip teardown (for debugging)' required: false @@ -114,11 +124,18 @@ jobs: shell: pwsh run: | $skipTeardown = '${{ inputs.skip_teardown }}' -eq 'true' + $logLevel = '${{ inputs.log_level }}' + if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' } + if ($logLevel -notin @('Info', 'Verbose', 'Debug')) { + throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug." + } + $params = @{ SourceResourceGroup = '${{ env.SOURCE_RG }}' TargetResourceGroup = '${{ env.TARGET_RG }}' SkuName = '${{ inputs.sku }}' Location = '${{ inputs.location }}' + LogLevel = $logLevel PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}' ExtractOutputDir = './extracted-artifacts' HardDelete = $true diff --git a/.gitignore b/.gitignore index 51ea2d3..478d67e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,12 @@ Desktop.ini # Local testing output (use --output .local-extract for local runs) .local-extract*/ -# Log files for integration tests +# Files for integration tests tests/integration/all-resource-types/logs/** +tests/integration/all-resource-types/extracted-artifacts*/** +tests/integration/all-resource-types/target-apim.json +tests/integration/all-resource-types/source-apim.json +tests/integration/all-resource-types/source-apim-post-activation.bicep # Environment variables .env @@ -53,3 +57,4 @@ tests/integration/all-resource-types/logs/** # Squad: SubSquad activation file (local to this machine) .squad-workstream *.d.ts.map +tests/integration/all-resource-types/source-apim-post-activation.json diff --git a/.squad/agents/apimexpert/history.md b/.squad/agents/apimexpert/history.md index 9747ffc..8a2d744 100644 --- a/.squad/agents/apimexpert/history.md +++ b/.squad/agents/apimexpert/history.md @@ -98,5 +98,11 @@ the SDK surface, reference docs, or ad-hoc observation. **Research output:** `.squad/decisions.md` entry (merged from inbox), full analysis in `specs/sku-upgrade.md` -### 2026-05-13: APIM v1 → v2 SKU Migration Research +### 2026-05-19: `policyRestrictions.scope` grammar + +`Microsoft.ApiManagement/service/policyRestrictions@2025-09-01-preview`. Schema says `"Path to the policy document."` but the API validates `scope` as a **relative ARM path to an existing API, operation, or product**. + +- Accepted: `/apis/{apiId}`, `/apis/{apiId}/operations/{opId}`, `/products/{productId}`, `""`. +- Classic Developer/Premium SKU only. +- Docs: · diff --git a/.squad/agents/securityexpert/history.md b/.squad/agents/securityexpert/history.md index 6901599..dce0374 100644 --- a/.squad/agents/securityexpert/history.md +++ b/.squad/agents/securityexpert/history.md @@ -22,3 +22,9 @@ Performed a thorough read-only sensitivity audit across all tracked files in preparation for open-source publication. Scanned for secrets/credentials, internal Microsoft URLs, PII, internal comments, internal tool configs, sensitive paths, draft docs, hardcoded Azure resource IDs, and internal dependency references. Findings delivered for compliance sign-off. No live credentials, certificates, or storage keys were found. All Azure GUIDs encountered were either zero-padded placeholders or public Azure built-in role definition IDs. Primary cleanup items: a developer machine path and alias references in `.squad/` history/decisions; one real-looking storage account name in a test fixture. **Findings Summary:** 2 MEDIUM items, 3 LOW items. Orchestration log: `.squad/orchestration-log/2026-05-19T22-01-securityexpert.md` +- When using PowerShell transcript/trace logging, always pass `-UseMinimalHeader` to `Start-Transcript` to prevent machine/host environment details from being written to logs. +- `Start-Transcript -UseMinimalHeader` keeps machine/host details out of logs. +- **ARM async-operation URLs** (`Azure-AsyncOperation` / `Location`) include `t/c/s/h` query params that act as short-lived bearer credentials. Regex-mask them. +- **`x-ms-routing-request-id`** carries `REGION:UTC:GUID` — mask the whole value, not just the GUID. +- **PowerShell `Start-Transcript` double-emits native stderr** when paired with `2>&1 | Write-Host`. Either regex-mask in `Protect-LogLine` (so both copies get masked) or redirect the child's stderr to a pipe via `System.Diagnostics.Process` so the transcript never sees the raw line. Both layers together = defense in depth. +- **Do not mask all GUIDs.** Azure built-in role-definition IDs and ARM template hashes are public constants useful for debugging. Anchor secret regex to the path segment, header name, or query-parameter context that makes the value sensitive. diff --git a/.squad/agents/testengineer/history.md b/.squad/agents/testengineer/history.md index e54cc6b..cb3f08e 100644 --- a/.squad/agents/testengineer/history.md +++ b/.squad/agents/testengineer/history.md @@ -159,3 +159,16 @@ - History updated with dual-mode package consumption patterns + +### 2026-05-19: MaskingHelpers — capture child stderr directly + +Rewrote [tests/integration/all-resource-types/MaskingHelpers.psm1](tests/integration/all-resource-types/MaskingHelpers.psm1) around `System.Diagnostics.Process` with `RedirectStandardOutput/Error = $true` so the child's raw bytes bypass PowerShell's ErrorRecord promotion and never reach the parent transcript. Per-stream `Start-ThreadJob` readers drain into `ConcurrentQueue[string]`s; main runspace polls every 100 ms and emits through `Protect-LogLine`. + +Breaking signature: helpers now take `-Arguments [string[]]` instead of `-Command [scriptblock]`. All four call sites updated. + +Gotchas for future PowerShell work: + +- `Register-ObjectEvent` Action handlers do not drain reliably while the main runspace is in `Start-Sleep`. Use `Start-ThreadJob` (PS 7+) to bypass the engine event pump. +- `$x = if ($cond) { [List[T]]::new() }` assigns `$null` — PowerShell enumerates the empty list. Use `$x = $null; if ($cond) { $x = ... }`. +- `ProcessStartInfo.StandardOutputEncoding/StandardErrorEncoding` default to OEM on Windows; force UTF-8 or `az --debug` output mangles. + diff --git a/src/lib/dependency-graph.ts b/src/lib/dependency-graph.ts index 1482d76..4bcb8e8 100644 --- a/src/lib/dependency-graph.ts +++ b/src/lib/dependency-graph.ts @@ -20,9 +20,11 @@ import { DependencyEdge } from '../models/types.js'; export const DEPENDENCY_EDGES: DependencyEdge[] = [ // Tier 1 -> Tier 2 dependencies { from: ResourceType.Diagnostic, to: ResourceType.Logger, required: false }, + { from: ResourceType.Diagnostic, to: ResourceType.Workspace, required: false }, { from: ResourceType.ServicePolicy, to: ResourceType.NamedValue, required: false }, { from: ResourceType.ServicePolicy, to: ResourceType.PolicyFragment, required: false }, { from: ResourceType.Api, to: ResourceType.VersionSet, required: false }, + { from: ResourceType.PolicyRestriction, to: ResourceType.Product, required: false }, // Tier 2 -> Tier 3 dependencies { from: ResourceType.ProductPolicy, to: ResourceType.Product, required: true }, @@ -61,6 +63,7 @@ export const DEPENDENCY_EDGES: DependencyEdge[] = [ ]; export const TIER_1_RESOURCES: ResourceType[] = [ + ResourceType.Workspace, ResourceType.NamedValue, ResourceType.Tag, ResourceType.Gateway, @@ -70,7 +73,6 @@ export const TIER_1_RESOURCES: ResourceType[] = [ ResourceType.Group, ResourceType.PolicyFragment, ResourceType.GlobalSchema, - ResourceType.PolicyRestriction, ResourceType.Documentation, ]; @@ -82,6 +84,7 @@ export const TIER_2_RESOURCES: ResourceType[] = [ ]; export const TIER_3_RESOURCES: ResourceType[] = [ + ResourceType.PolicyRestriction, ResourceType.ProductPolicy, ResourceType.ProductGroup, ResourceType.ProductTag, diff --git a/src/lib/resource-path.ts b/src/lib/resource-path.ts index 27d473d..07c8f35 100644 --- a/src/lib/resource-path.ts +++ b/src/lib/resource-path.ts @@ -10,16 +10,20 @@ import { ResourceDescriptor } from '../models/types.js'; import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.js'; /** - * Association resource types that represent parent-child relationships - * (not independently publishable resources). - * These are handled specially during publishing via association files - * (apis.json, groups.json) and should not be discovered as individual resources. + * Association resource types that are published by specialized parent publishers. + * + * Product associations are handled by product-publisher using the parent + * product descriptor, so their association files should not be discovered as + * independent resources. + * + * Gateway associations are intentionally excluded from this set because they + * are published via the generic association path in resource-publisher and + * must therefore be discovered from gateways/{gateway}/apis.json. */ -const ASSOCIATION_TYPES = new Set([ +const PARENT_PUBLISHED_ASSOCIATION_TYPES = new Set([ ResourceType.ProductApi, ResourceType.ProductGroup, ResourceType.ProductTag, - ResourceType.GatewayApi, ]); const SUPPORTED_SPECIFICATION_EXTENSIONS = new Set([ @@ -369,6 +373,11 @@ export function parseArtifactPath( return undefined; } + const workspaceContainer = parseWorkspaceContainerDescriptor(fileName, workspace); + if (workspaceContainer) { + return workspaceContainer; + } + // Try to match against each resource type's pattern for (const [typeKey, metadata] of Object.entries(RESOURCE_TYPE_METADATA)) { const type = typeKey as ResourceType; @@ -379,8 +388,8 @@ export function parseArtifactPath( // Skip association resource types — these are handled specially during publishing // via their parent's association files (apis.json, groups.json) - if (ASSOCIATION_TYPES.has(type)) { - return undefined; + if (PARENT_PUBLISHED_ASSOCIATION_TYPES.has(type)) { + continue; } const nameParts = parseTemplatePath( @@ -396,6 +405,24 @@ export function parseArtifactPath( return undefined; } +function parseWorkspaceContainerDescriptor( + fileName: string, + workspace?: string +): ResourceDescriptor | undefined { + // Workspace container descriptors are stored at: + // workspaces/{workspace}/workspaceInformation.json + // and are not workspace-scoped children. Return a top-level Workspace + // descriptor so publish can create the container before workspace children. + if (fileName === 'workspaceInformation.json' && workspace) { + return { + type: ResourceType.Workspace, + nameParts: [workspace], + }; + } + + return undefined; +} + /** * Parse a changed artifact file path into a ResourceDescriptor. * diff --git a/src/models/resource-types.ts b/src/models/resource-types.ts index c80f0b2..4ebcdef 100644 --- a/src/models/resource-types.ts +++ b/src/models/resource-types.ts @@ -41,6 +41,8 @@ export enum ResourceType { GraphQLResolverPolicy = 'GraphQLResolverPolicy', /** MCP (Model Context Protocol) server configuration per API. Singleton per API. */ McpServer = 'McpServer', + /** Premium/PremiumV2 workspace container. */ + Workspace = 'Workspace', } /** @@ -278,4 +280,10 @@ export const RESOURCE_TYPE_METADATA: Record infoFile: 'mcpServerInformation.json', supportsGet: true, }, + [ResourceType.Workspace]: { + armPathSuffix: 'workspaces/{0}', + artifactDirectory: 'workspaces/{0}', + infoFile: 'workspaceInformation.json', + supportsGet: true, + }, }; diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index ac7bb0f..4aa64e1 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -57,7 +57,22 @@ export async function publishApi( } // Step 2: Find and publish revisions in numeric order - await publishApiRevisions(client, store, context, descriptor, config); + const publishedRevisionCount = await publishApiRevisions(client, store, context, descriptor, config); + + // Step 2b: Align root API only when source marks it as current. + // Source of truth is properties.isCurrent in root apiInformation.json. + if (publishedRevisionCount > 0 && rootResult.isCurrent === true) { + const alignResult = await alignActiveRevisionWithSource( + client, + store, + context, + descriptor, + config + ); + if (alignResult.status !== 'success') { + return alignResult; + } + } // Step 3: Publish child resources in parallel // When a spec was imported, operations and schemas are auto-created by APIM @@ -105,6 +120,11 @@ function getImportFormat(specFormat: string, _apiType?: string): string | undefi interface RootApiResult { status: 'success' | 'skipped'; specImported: boolean; + isCurrent?: boolean; +} + +interface PublishRootApiOptions { + includeSpecification?: boolean; } /** @@ -118,7 +138,8 @@ async function publishRootApi( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + options?: PublishRootApiOptions ): Promise { let json = await store.readResource(config.sourceDir, descriptor); if (!json) { @@ -132,10 +153,14 @@ async function publishRootApi( // Apply overrides json = applyOverrides(descriptor, json, config.overrides); + const isCurrent = getApiIsCurrent(json); // Try to read the specification file for this API let specImported = false; - const specResult = await store.readContent(config.sourceDir, descriptor, 'specification'); + const includeSpecification = options?.includeSpecification ?? true; + const specResult = includeSpecification + ? await store.readContent(config.sourceDir, descriptor, 'specification') + : undefined; if (specResult) { const properties = json.properties as Record | undefined; const apiType = properties?.type as string | undefined; @@ -180,9 +205,26 @@ async function publishRootApi( status: 'success', action: 'put', specImported, + isCurrent, }; } +async function alignActiveRevisionWithSource( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + descriptor: ResourceDescriptor, + config: PublishConfig +): Promise { + logger.debug( + `Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision` + ); + + return publishRootApi(client, store, context, descriptor, config, { + includeSpecification: false, + }); +} + /** * Find and publish API revisions in numeric order */ @@ -192,7 +234,7 @@ async function publishApiRevisions( context: ApimServiceContext, apiDescriptor: ResourceDescriptor, config: PublishConfig -): Promise { +): Promise { // List all resources from store const allDescriptors = await store.listResources(config.sourceDir); @@ -214,6 +256,8 @@ async function publishApiRevisions( for (const revDescriptor of sortedRevisions) { await publishResource(client, store, context, revDescriptor, config); } + + return sortedRevisions.length; } /** @@ -398,3 +442,9 @@ function extractRevisionNumber(apiName: string): number { const match = /;rev=(\d+)/.exec(apiName); return match ? parseInt(match[1], 10) : 0; } + +function getApiIsCurrent(json: Record): boolean | undefined { + const properties = json.properties as Record | undefined; + const isCurrent = properties?.isCurrent; + return typeof isCurrent === 'boolean' ? isCurrent : undefined; +} diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index 00d2aaf..0167a2d 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -553,18 +553,14 @@ async function extractWorkspaceResources( filter: FilterConfig | undefined, result: ExtractionResult ): Promise { - try { - const wsResults = await extractWorkspaces( - client, store, context, outputDir, filter - ); + const wsResults = await extractWorkspaces( + client, store, context, outputDir, filter + ); - result.workspaceResults = wsResults; + result.workspaceResults = wsResults; - for (const ws of wsResults) { - result.totalExtracted += ws.resourceCount; - result.totalErrors += ws.errorCount; - } - } catch (error) { - logger.warn(`Workspace extraction failed: ${(error as Error).message}`); + for (const ws of wsResults) { + result.totalExtracted += ws.resourceCount; + result.totalErrors += ws.errorCount; } } diff --git a/src/services/product-publisher.ts b/src/services/product-publisher.ts index 32db4d9..31cbc11 100644 --- a/src/services/product-publisher.ts +++ b/src/services/product-publisher.ts @@ -13,6 +13,7 @@ import { ResourceType } from '../models/resource-types.js'; import { publishResource, type ResourcePublishResult } from './resource-publisher.js'; import { logger } from '../lib/logger.js'; import { getNamePart } from '../lib/resource-path.js'; +import { parseArmUri } from '../lib/resource-uri.js'; /** * Publish a Product with all its associations (APIs, Groups, Tags). @@ -27,6 +28,7 @@ export async function publishProduct( ): Promise { try { const productName = getNamePart(descriptor.nameParts, 0); + const productExisted = (await client.getResource(context, descriptor)) !== undefined; // Step 1: Publish the Product itself const productResult = await publishResource(client, store, context, descriptor, config); @@ -34,6 +36,10 @@ export async function publishProduct( return productResult; } + if (!productExisted) { + await cleanupAutoCreatedProductResources(client, context, descriptor); + } + // Step 2: Publish ProductApi associations await publishProductAssociations( client, @@ -88,6 +94,63 @@ export async function publishProduct( } } +async function cleanupAutoCreatedProductResources( + client: IApimClient, + context: ApimServiceContext, + productDescriptor: ResourceDescriptor +): Promise { + await cleanupProductGroups(client, context, productDescriptor); +} + +async function cleanupProductGroups( + client: IApimClient, + context: ApimServiceContext, + productDescriptor: ResourceDescriptor +): Promise { + const productName = getNamePart(productDescriptor.nameParts, 0); + let deleted = 0; + + for await (const productGroup of client.listResources( + context, + ResourceType.ProductGroup, + productDescriptor + )) { + const descriptor = parseProductGroupDescriptor(productGroup, context); + if (!descriptor || descriptor.workspace !== productDescriptor.workspace) { + continue; + } + + try { + const removed = await client.deleteResource(context, descriptor); + if (removed) { + deleted++; + } + } catch (error) { + logger.warn( + `Failed to delete auto-created product group ${descriptor.nameParts.join('/')}: ${String(error)}` + ); + } + } + + if (deleted > 0) { + logger.info(`Deleted ${deleted} auto-created product group(s) for product: ${productName}`); + } +} + +function parseProductGroupDescriptor( + productGroup: Record, + context: ApimServiceContext +): ResourceDescriptor | undefined { + if (typeof productGroup.id === 'string') { + const parsed = parseArmUri(productGroup.id, context); + if (parsed?.type === ResourceType.ProductGroup) { + return parsed; + } + } + + return undefined; +} + /** * Publish associations (ProductApi or ProductGroup) for a product */ diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 60c4d0b..131a732 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -234,6 +234,13 @@ async function executePuts( logger.debug(`Publishing tier ${tier}: ${descriptors.length} resources`); if (tier === 1) { + const { workspaces, nonWorkspaceTier1 } = splitWorkspaces(descriptors); + + if (workspaces.length > 0) { + logger.debug(`Publishing ${workspaces.length} workspace container(s) first (wave 0 of tier 1)`); + await publishAndOutput(client, store, context, config, workspaces, results); + } + // Within Tier 1 we publish in three ordered waves to satisfy implicit // runtime dependencies: // @@ -250,7 +257,7 @@ async function executePuts( // Pool backends reference individual Backend resources and must be // published after the backends they aggregate (see pool backend // ordering comments in splitPoolBackends). - const { namedValues, otherTier1 } = splitNamedValues(descriptors); + const { namedValues, otherTier1 } = splitNamedValues(nonWorkspaceTier1); if (namedValues.length > 0) { logger.debug(`Publishing ${namedValues.length} named value(s) first (wave 1 of tier 1)`); @@ -276,6 +283,19 @@ async function executePuts( results ); } + } else if (tier === 2) { + const { mcpApis, regularTier2 } = await splitMcpApis( + store, + config.sourceDir, + descriptors + ); + + await publishAndOutput(client, store, context, config, regularTier2, results); + + if (mcpApis.length > 0) { + logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular tier 2 resources`); + await publishAndOutput(client, store, context, config, mcpApis, results); + } } else { // For tiers 3/4, exclude child resources whose parent is being published // in tier 2 (publishApi/publishProduct handle their children internally). @@ -310,6 +330,23 @@ async function executePuts( return results; } +function splitWorkspaces( + descriptors: ResourceDescriptor[] +): { workspaces: ResourceDescriptor[]; nonWorkspaceTier1: ResourceDescriptor[] } { + const workspaces: ResourceDescriptor[] = []; + const nonWorkspaceTier1: ResourceDescriptor[] = []; + + for (const descriptor of descriptors) { + if (descriptor.type === ResourceType.Workspace) { + workspaces.push(descriptor); + } else { + nonWorkspaceTier1.push(descriptor); + } + } + + return { workspaces, nonWorkspaceTier1 }; +} + /** * Publish a batch of descriptors and write their status lines to stdout/stderr. */ @@ -397,6 +434,31 @@ async function splitPoolBackends( return { poolBackends, regularTier1 }; } +async function splitMcpApis( + store: IArtifactStore, + sourceDir: string, + descriptors: ResourceDescriptor[] +): Promise<{ mcpApis: ResourceDescriptor[]; regularTier2: ResourceDescriptor[] }> { + const mcpApis: ResourceDescriptor[] = []; + const regularTier2: ResourceDescriptor[] = []; + + for (const descriptor of descriptors) { + if (descriptor.type === ResourceType.Api) { + const json = await store.readResource(sourceDir, descriptor); + const props = json?.properties as Record | undefined; + const mcpTools = props?.mcpTools; + if (Array.isArray(mcpTools) && mcpTools.length > 0) { + mcpApis.push(descriptor); + continue; + } + } + + regularTier2.push(descriptor); + } + + return { mcpApis, regularTier2 }; +} + /** * Publish a single tier of resources in parallel. */ diff --git a/src/services/resource-extractor.ts b/src/services/resource-extractor.ts index d99af70..8f85d59 100644 --- a/src/services/resource-extractor.ts +++ b/src/services/resource-extractor.ts @@ -88,6 +88,11 @@ export async function extractResourceType( }; try { + const loggerTokenMap = + type === ResourceType.Logger + ? await loadNamedValueDisplayNameMap(client, context) + : undefined; + const resources = client.listResources(context, type, parent); for await (const listJson of resources) { @@ -119,6 +124,10 @@ export async function extractResourceType( } } + if (type === ResourceType.Logger && loggerTokenMap && loggerTokenMap.size > 0) { + json = normalizeLoggerCredentialPlaceholders(json, loggerTokenMap); + } + // Apply secret redaction const safeJson = redactSecrets(descriptor, json); @@ -157,6 +166,68 @@ export async function extractResourceType( return result; } +async function loadNamedValueDisplayNameMap( + client: IApimClient, + context: ApimServiceContext +): Promise> { + const map = new Map(); + + for await (const namedValue of client.listResources(context, ResourceType.NamedValue)) { + const name = namedValue.name; + const properties = namedValue.properties as Record | undefined; + const displayName = properties?.displayName; + + if (typeof name === 'string' && typeof displayName === 'string' && displayName.length > 0) { + map.set(displayName, name); + } + } + + return map; +} + +function normalizeLoggerCredentialPlaceholders( + json: Record, + displayNameToName: Map +): Record { + const properties = json.properties as Record | undefined; + const credentials = properties?.credentials; + + if (!properties || credentials === undefined) { + return json; + } + + const normalizeValue = (value: unknown): unknown => { + if (typeof value === 'string') { + return value.replace(/\{\{([^}]+)\}\}/g, (match, tokenName: string) => { + const mappedName = displayNameToName.get(tokenName); + return mappedName ? `{{${mappedName}}}` : match; + }); + } + + if (Array.isArray(value)) { + return value.map(normalizeValue); + } + + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = normalizeValue(child); + } + return out; + } + + return value; + }; + + return { + ...json, + properties: { + ...properties, + credentials: normalizeValue(credentials), + }, + }; +} + /** * Extract a single resource by descriptor and write to artifact store. */ diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 2e7390d..a9c27b3 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -15,6 +15,7 @@ import { ResourceType } from '../models/resource-types.js'; import { applyOverrides } from './override-merger.js'; import { checkKeyVaultSecretAccess } from './keyvault-checker.js'; import { getNamePart } from '../lib/resource-path.js'; +import { isAutoGeneratedId } from '../lib/auto-generated.js'; export interface ResourcePublishResult { descriptor: ResourceDescriptor; @@ -148,6 +149,7 @@ export async function publishResource( if (descriptor.type === ResourceType.Subscription) { const props = json.properties as Record | undefined; const scope = props?.scope as string | undefined; + const subscriptionName = getNamePart(descriptor.nameParts, 0); // Built-in master subscription has scope ending with the service path (no /apis or /products suffix) // Skip it since APIM doesn't allow updates to built-in subscriptions @@ -158,7 +160,15 @@ export async function publishResource( action: 'noop', }; } - + + if (isAutoGeneratedProductSubscription(subscriptionName, scope)) { + return { + descriptor, + status: 'skipped', + action: 'noop', + }; + } + json = normalizeSubscriptionScope(json, context); } @@ -177,6 +187,7 @@ export async function publishResource( // validation errors in APIM's revision creation. if (descriptor.type === ResourceType.Api) { const apiName = getNamePart(descriptor.nameParts, 0); + json = normalizeMcpToolOperationIds(json, context); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; const props = json.properties as Record | undefined; @@ -185,6 +196,12 @@ export async function publishResource( if (val !== null) cleanProps[key] = val; } cleanProps.sourceApiId = `/subscriptions/${context.subscriptionId}/resourceGroups/${context.resourceGroup}/providers/Microsoft.ApiManagement/service/${context.serviceName}/apis/${baseApiName}`; + // Preserve source current-revision intent. APIM can implicitly promote a + // created revision to current if isCurrent is omitted; default to false + // unless the extracted artifact explicitly set it. + if (!Object.hasOwn(cleanProps, 'isCurrent')) { + cleanProps.isCurrent = false; + } json = { ...json, properties: cleanProps }; } } @@ -442,3 +459,62 @@ function normalizeApiReleaseApiId( return json; } + +/** + * Normalise MCP tool operationIds for MCP APIs. + * + * Extracted artifacts store operationId as full source ARM IDs. Rebuild each + * operationId to target service ARM prefix so APIM validates MCP tools against + * existing operations in the target instance. + */ +function normalizeMcpToolOperationIds( + json: Record, + context: ApimServiceContext +): Record { + const props = json.properties as Record | undefined; + if (!props) return json; + + const mcpTools = props.mcpTools; + if (!Array.isArray(mcpTools) || mcpTools.length === 0) return json; + + const targetArmPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); + const normalizedTools = mcpTools.map((tool): unknown => { + if (!tool || typeof tool !== 'object') { + return tool; + } + const typedTool = tool as Record; + const operationId = typedTool.operationId; + if (typeof operationId !== 'string') { + return typedTool; + } + + const operationsIndex = operationId.indexOf('/apis/'); + if (operationsIndex === -1) { + return typedTool; + } + + return { + ...typedTool, + operationId: `${targetArmPrefix}${operationId.slice(operationsIndex)}`, + }; + }); + + return { + ...json, + properties: { + ...props, + mcpTools: normalizedTools, + }, + }; +} + +function isAutoGeneratedProductSubscription( + subscriptionName: string, + scope?: string +): boolean { + if (!scope || !isAutoGeneratedId(subscriptionName)) { + return false; + } + + return scope.includes('/products/'); +} diff --git a/src/services/workspace-extractor.ts b/src/services/workspace-extractor.ts index 77a1f3a..88d8cae 100644 --- a/src/services/workspace-extractor.ts +++ b/src/services/workspace-extractor.ts @@ -5,7 +5,6 @@ * List workspaces, extract workspace-scoped resources under workspaces/{name}/ * using same resource-extractor with workspace context prefix. */ - import { IApimClient } from '../clients/iapim-client.js'; import { IArtifactStore } from '../clients/iartifact-store.js'; import { ApimServiceContext } from '../models/types.js'; @@ -35,9 +34,6 @@ const WORKSPACE_SUPPORTED_TYPES: ResourceType[] = [ ResourceType.Group, ]; -/** - * Result of extracting a single workspace. - */ export interface WorkspaceExtractionResult { workspaceName: string; resourceCount: number; @@ -46,12 +42,7 @@ export interface WorkspaceExtractionResult { /** * Extract resources from all workspaces. - * - * Note: Workspace listing is not supported through the current IApimClient - * interface (ResourceType doesn't include Workspace). This function accepts - * workspace names from the filter config. If workspaceNames is not specified - * in the filter, workspace extraction is skipped. - * + * @param client - APIM REST client * @param store - Artifact file store * @param context - APIM service context @@ -67,18 +58,40 @@ export async function extractWorkspaces( filter?: FilterConfig ): Promise { const results: WorkspaceExtractionResult[] = []; + let workspaceNames: string[]; + if (filter?.workspaceNames && filter.workspaceNames.length > 0) { + workspaceNames = filter.workspaceNames; + } else { + const discovered: string[] = []; + for await (const item of client.listResources(context, ResourceType.Workspace)) { + const name = item['name']; + if (typeof name === 'string') { + discovered.push(name); + } + } + workspaceNames = discovered; + } - // Workspace names must be explicitly provided via filter config - // since the IApimClient interface doesn't support listing workspaces - const workspaceNames = filter?.workspaceNames; - if (!workspaceNames || workspaceNames.length === 0) { - logger.debug('No workspace names specified in filter — skipping workspace extraction'); + if (workspaceNames.length === 0) { + logger.debug('No workspaces found — skipping workspace extraction'); return results; } logger.info(`Extracting ${workspaceNames.length} workspace(s)...`); for (const wsName of workspaceNames) { + // Persist the workspace container itself so publish can recreate it + // before workspace-scoped children (named values, APIs, products, etc.). + const wsDescriptor = { type: ResourceType.Workspace, nameParts: [wsName] }; + const wsJson = await client.getResource(context, wsDescriptor); + if (!wsJson) { + logger.error( + `Workspace container "${wsName}" was discovered but could not be read. Continuing with workspace child extraction.` + ); + } else { + await store.writeResource(outputDir, wsDescriptor, wsJson); + } + const wsResult = await extractWorkspace( client, store, context, wsName, outputDir, filter ); @@ -88,9 +101,6 @@ export async function extractWorkspaces( return results; } -/** - * Extract all resources from a single workspace. - */ async function extractWorkspace( client: IApimClient, store: IArtifactStore, @@ -111,7 +121,6 @@ async function extractWorkspace( baseUrl: `${context.baseUrl}/workspaces/${encodeURIComponent(workspaceName)}`, }; - // Extract each supported resource type within the workspace for (const type of WORKSPACE_SUPPORTED_TYPES) { try { const result = await extractResourceType( diff --git a/tests/integration/all-resource-types/Compare-ApimInstance.ps1 b/tests/integration/all-resource-types/Compare-ApimInstance.ps1 index 7d41227..f0963e2 100644 --- a/tests/integration/all-resource-types/Compare-ApimInstance.ps1 +++ b/tests/integration/all-resource-types/Compare-ApimInstance.ps1 @@ -168,7 +168,7 @@ function Build-ResourceMap { # from source and target receive the same positional key ({{auto-id-0}}, etc.) if ($autoIdItems.Count -gt 0) { $sorted = $autoIdItems | Sort-Object { - $normVal = Normalize-PropertyValue -Value $_ ` + $normVal = ConvertTo-NormalizedPropertyValue -Value $_ ` -SourceName $SourceName -TargetName $TargetName ` -SourceSub $SourceSub -TargetSub $TargetSub ` -SourceRg $SourceRg -TargetRg $TargetRg @@ -184,7 +184,7 @@ function Build-ResourceMap { return $map } -function Normalize-PropertyValue { +function ConvertTo-NormalizedPropertyValue { <# .SYNOPSIS Recursively normalizes a property value: replaces instance-specific @@ -236,7 +236,7 @@ function Normalize-PropertyValue { # --- Array --- if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string] -and $Value -isnot [System.Collections.IDictionary]) { $normalized = @(foreach ($item in $Value) { - Normalize-PropertyValue -Value $item ` + ConvertTo-NormalizedPropertyValue -Value $item ` -SourceName $SourceName -TargetName $TargetName ` -SourceSub $SourceSub -TargetSub $TargetSub ` -SourceRg $SourceRg -TargetRg $TargetRg @@ -257,7 +257,7 @@ function Normalize-PropertyValue { if ($key -in $StripTimestampProperties) { continue } # Strip timestamps at any level if ($isRequestResponse -and $key -in $RequestResponseIgnoredProperties) { continue } if ($isRepresentation -and $key -in $RepresentationIgnoredProperties) { continue } - $out[$key] = Normalize-PropertyValue -Value $Value[$key] ` + $out[$key] = ConvertTo-NormalizedPropertyValue -Value $Value[$key] ` -SourceName $SourceName -TargetName $TargetName ` -SourceSub $SourceSub -TargetSub $TargetSub ` -SourceRg $SourceRg -TargetRg $TargetRg @@ -276,7 +276,7 @@ function Normalize-PropertyValue { if ($prop.Name -in $StripTimestampProperties) { continue } # Strip timestamps at any level if ($isRequestResponse -and $prop.Name -in $RequestResponseIgnoredProperties) { continue } if ($isRepresentation -and $prop.Name -in $RepresentationIgnoredProperties) { continue } - $out[$prop.Name] = Normalize-PropertyValue -Value $prop.Value ` + $out[$prop.Name] = ConvertTo-NormalizedPropertyValue -Value $prop.Value ` -SourceName $SourceName -TargetName $TargetName ` -SourceSub $SourceSub -TargetSub $TargetSub ` -SourceRg $SourceRg -TargetRg $TargetRg @@ -288,7 +288,7 @@ function Normalize-PropertyValue { return $Value } -function Normalize-Resource { +function ConvertTo-NormalizedResource { <# .SYNOPSIS Strips top-level ARM envelope fields and applies property normalization. @@ -306,7 +306,7 @@ function Normalize-Resource { # Normalize the properties bag (read-only fields, instance names, etc.) if ($clone.Contains('properties')) { - $clone['properties'] = Normalize-PropertyValue -Value $clone['properties'] ` + $clone['properties'] = ConvertTo-NormalizedPropertyValue -Value $clone['properties'] ` -SourceName $SourceApimName -TargetName $TargetApimName ` -SourceSub $SourceSubscriptionId -TargetSub $TargetSubscriptionId ` -SourceRg $SourceResourceGroup -TargetRg $TargetResourceGroup ` @@ -316,7 +316,7 @@ function Normalize-Resource { # Normalize any other top-level bags (e.g., location, sku) foreach ($key in @($clone.Keys)) { if ($key -eq 'properties') { continue } - $clone[$key] = Normalize-PropertyValue -Value $clone[$key] ` + $clone[$key] = ConvertTo-NormalizedPropertyValue -Value $clone[$key] ` -SourceName $SourceApimName -TargetName $TargetApimName ` -SourceSub $SourceSubscriptionId -TargetSub $TargetSubscriptionId ` -SourceRg $SourceResourceGroup -TargetRg $TargetResourceGroup @@ -378,11 +378,7 @@ function Compare-NormalizedResources { } } else { - $svLen = if ($null -ne $svJson) { $svJson.Length } else { 0 } - $tvLen = if ($null -ne $tvJson) { $tvJson.Length } else { 0 } - $svShort = if ($svLen -gt 120) { $svJson.Substring(0, 117) + '...' } else { $svJson } - $tvShort = if ($tvLen -gt 120) { $tvJson.Substring(0, 117) + '...' } else { $tvJson } - $diffs.Add(" DIFF at $currentPath`n source: $svShort`n target: $tvShort") + $diffs.Add(" DIFF at $currentPath`n source: $svJson`n target: $tvJson") } } } @@ -390,17 +386,13 @@ function Compare-NormalizedResources { # Fallback: if JSON differs but no key-level diffs found, report the full diff if ($diffs.Count -eq 0) { $pathPrefix = if ($Path) { "${Path}: " } else { '' } - $srcLen = if ($null -ne $sourceJson) { $sourceJson.Length } else { 0 } - $tgtLen = if ($null -ne $targetJson) { $targetJson.Length } else { 0 } - $srcShort = if ($srcLen -gt 200) { $sourceJson.Substring(0, 197) + '...' } else { $sourceJson } - $tgtShort = if ($tgtLen -gt 200) { $targetJson.Substring(0, 197) + '...' } else { $targetJson } - $diffs.Add(" ${pathPrefix}JSON differs`n source: $srcShort`n target: $tgtShort") + $diffs.Add(" ${pathPrefix}JSON differs`n source: $sourceJson`n target: $targetJson") } return ,$diffs } -function Should-SkipSecretValue { +function Test-SkipSecretValue { <# Returns $true if this resource is a secret named value whose .value should be skipped. #> param($Resource) if (-not $Resource.PSObject.Properties['properties']) { return $false } @@ -410,7 +402,7 @@ function Should-SkipSecretValue { return ($secret -eq $true) } -function Should-SkipLoggerCredentials { +function Test-SkipLoggerCredentials { <# Returns $true if this resource is an Event Hub or App Insights logger (credentials differ per instance). #> param($Resource) if (-not $Resource.PSObject.Properties['properties']) { return $false } @@ -514,11 +506,11 @@ function Compare-ResourceType { $srcResource = $sourceMap[$name] $tgtResource = $targetMap[$name] - $srcNorm = Normalize-Resource -Resource $srcResource - $tgtNorm = Normalize-Resource -Resource $tgtResource + $srcNorm = ConvertTo-NormalizedResource -Resource $srcResource + $tgtNorm = ConvertTo-NormalizedResource -Resource $tgtResource # Skip secret named-value .value - if ($SkipSecretValues -and (Should-SkipSecretValue $srcResource)) { + if ($SkipSecretValues -and (Test-SkipSecretValue $srcResource)) { Write-Verbose " Skipping secret value for: $name" if ($srcNorm.Contains('properties') -and $srcNorm['properties'] -is [System.Collections.IDictionary]) { $srcNorm['properties'].Remove('value') | Out-Null @@ -529,7 +521,7 @@ function Compare-ResourceType { } # Skip Event Hub logger credentials (connection strings differ per instance) - if ($SkipLoggerCreds -and (Should-SkipLoggerCredentials $srcResource)) { + if ($SkipLoggerCreds -and (Test-SkipLoggerCredentials $srcResource)) { Write-Verbose " Skipping logger credentials for: $name" if ($srcNorm.Contains('properties') -and $srcNorm['properties'] -is [System.Collections.IDictionary]) { $srcNorm['properties'].Remove('credentials') | Out-Null @@ -599,7 +591,6 @@ try { @{ Label = 'Loggers'; Suffix = 'loggers'; Exclude = @(); SkipLoggerCreds = $true } @{ Label = 'Diagnostics'; Suffix = 'diagnostics'; Exclude = @() } @{ Label = 'Service Policy'; Suffix = 'policies'; Exclude = @() } - @{ Label = 'Products'; Suffix = 'products'; Exclude = @('starter', 'unlimited') } @{ Label = 'Subscriptions'; Suffix = 'subscriptions'; Exclude = @('master') } @{ Label = 'Workspaces'; Suffix = 'workspaces'; Exclude = @() } @{ Label = 'Documentations'; Suffix = 'documentations'; Exclude = @() } diff --git a/tests/integration/all-resource-types/Deploy-SourceApim.ps1 b/tests/integration/all-resource-types/Deploy-SourceApim.ps1 index 751a2ce..7465573 100644 --- a/tests/integration/all-resource-types/Deploy-SourceApim.ps1 +++ b/tests/integration/all-resource-types/Deploy-SourceApim.ps1 @@ -46,16 +46,34 @@ param( [ValidateSet('Developer', 'Premium', 'StandardV2', 'PremiumV2')] [string]$SkuName = 'StandardV2', - [switch]$Destroy + [switch]$Destroy, + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Info' ) $ErrorActionPreference = 'Stop' +$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' } +$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' } +Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'DeploymentHelpers.psm1') -Force + +# Map this script's LogLevel (Info/Verbose/Debug) to the apiops CLI log level +# values used in the printed example command. +function Get-ApiopsLogLevelLocal([string]$ScriptLogLevel) { + switch ($ScriptLogLevel) { + 'Verbose' { return 'warn' } + 'Debug' { return 'debug' } + default { return 'info' } + } +} +$apiopsLogLevel = Get-ApiopsLogLevelLocal -ScriptLogLevel $LogLevel # --------------------------------------------------------------------------- # Destroy path # --------------------------------------------------------------------------- if ($Destroy) { - Write-Host "🗑️ Deleting resource group '$ResourceGroupName'..." -ForegroundColor Yellow + Write-Host "🗑️ Deleting resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'..." -ForegroundColor Yellow az group delete --name $ResourceGroupName --yes --no-wait Write-Host "✅ Deletion initiated (async). Resource group will be removed shortly." -ForegroundColor Green exit 0 @@ -65,10 +83,14 @@ if ($Destroy) { # Deploy path # --------------------------------------------------------------------------- $bicepFile = Join-Path $PSScriptRoot 'source-apim.bicep' +$postActivationBicepFile = Join-Path $PSScriptRoot 'source-apim-post-activation.bicep' if (-not (Test-Path $bicepFile)) { Write-Error "Bicep file not found at: $bicepFile" } +if (-not (Test-Path $postActivationBicepFile)) { + Write-Error "Bicep file not found at: $postActivationBicepFile" +} # Verify az CLI is authenticated Write-Host "🔐 Verifying Azure CLI authentication..." @@ -78,7 +100,7 @@ if (-not $account) { } $subscriptionId = $account.id -Write-Host " Subscription: $($account.name) ($subscriptionId)" -ForegroundColor Gray +Write-Host " Subscription: $($account.name) ($(Protect-SubscriptionId -Value $subscriptionId))" -ForegroundColor Gray # Register required resource providers Write-Host "📋 Registering required resource providers..." -ForegroundColor Cyan @@ -87,7 +109,8 @@ $requiredProviders = @( 'Microsoft.Insights', 'Microsoft.OperationalInsights', 'Microsoft.EventHub', - 'Microsoft.KeyVault' + 'Microsoft.KeyVault', + 'Microsoft.AlertsManagement' ) foreach ($provider in $requiredProviders) { $state = az provider show --namespace $provider --query "registrationState" --output tsv 2>$null @@ -124,7 +147,7 @@ if (-not $allRegistered) { Write-Host " ✅ Resource providers ready" -ForegroundColor Green # Create resource group if needed -Write-Host "📦 Ensuring resource group '$ResourceGroupName' exists in '$Location'..." -ForegroundColor Cyan +Write-Host "📦 Ensuring resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)' exists in '$Location'..." -ForegroundColor Cyan az group create --name $ResourceGroupName --location $Location --output none # Deploy Bicep template @@ -136,20 +159,67 @@ Write-Host "" $deploymentName = "source-apim-$(Get-Date -Format 'yyyyMMddHHmmss')" -$result = az deployment group create ` - --resource-group $ResourceGroupName ` - --name $deploymentName ` - --template-file $bicepFile ` - --parameters skuName=$SkuName location=$Location publisherEmail=$PublisherEmail ` - --output json | ConvertFrom-Json +$azVerbosity = @() +switch ($LogLevel) { + 'Verbose' { $azVerbosity = @('--verbose') } + 'Debug' { $azVerbosity = @('--debug') } +} + +$azReplacements = @{ + $subscriptionId = Protect-SubscriptionId -Value $subscriptionId + $ResourceGroupName = Protect-ResourceGroupName -Value $ResourceGroupName +} + +$azArgs = @( + 'deployment', 'group', 'create', + '--resource-group', $ResourceGroupName, + '--name', $deploymentName, + '--template-file', $bicepFile, + '--parameters', "skuName=$SkuName", "location=$Location", "publisherEmail=$PublisherEmail", + '--output', 'json' +) + $azVerbosity + +$raw = Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $azArgs if ($LASTEXITCODE -ne 0) { - Write-Error "Deployment failed. Check the Azure portal for details." + Write-DeploymentFailureDetails ` + -ResourceGroupName $ResourceGroupName ` + -DeploymentName $deploymentName ` + -Replacements $azReplacements + throw "Source APIM deployment failed (deployment '$deploymentName' in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'). See failed-operation details above." } +$result = $raw | ConvertFrom-Json + # Extract outputs $outputs = $result.properties.outputs +# Deploy activation-sensitive APIM children after activation. +$apimServiceName = $outputs.apimServiceName.value +Wait-ApimActivation -ResourceGroupName $ResourceGroupName -ApimName $apimServiceName | Out-Null + +$postDeploymentName = "source-apim-post-activation-$(Get-Date -Format 'yyyyMMddHHmmss')" +$postReplacements = $azReplacements.Clone() +$postReplacements[$apimServiceName] = Protect-ApimName -Value $apimServiceName +$postArgs = @( + 'deployment', 'group', 'create', + '--resource-group', $ResourceGroupName, + '--name', $postDeploymentName, + '--template-file', $postActivationBicepFile, + '--parameters', "apimName=$apimServiceName", "skuName=$SkuName", + '--output', 'json' +) + $azVerbosity + +Write-Host "Applying post-activation APIM resources..." -ForegroundColor Cyan +$postRaw = Invoke-MaskedAzCommand -Replacements $postReplacements -Arguments $postArgs +if ($LASTEXITCODE -ne 0) { + Write-DeploymentFailureDetails ` + -ResourceGroupName $ResourceGroupName ` + -DeploymentName $postDeploymentName ` + -Replacements $postReplacements + throw "Source post-activation deployment failed (deployment '$postDeploymentName' in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'). See failed-operation details above." +} + Write-Host "" Write-Host "============================================================" -ForegroundColor Green Write-Host "✅ Kitchen Sink APIM deployed successfully!" -ForegroundColor Green @@ -158,10 +228,11 @@ Write-Host "" Write-Host "APIOps CLI extract command:" -ForegroundColor Cyan Write-Host "" Write-Host " npx apiops extract \" -Write-Host " --subscription-id $($outputs.subscriptionId.value) \" -Write-Host " --resource-group $($outputs.resourceGroupName.value) \" -Write-Host " --service-name $($outputs.apimServiceName.value) \" -Write-Host " --output-dir ./extracted" +Write-Host " --subscription-id $(Protect-SubscriptionId -Value $outputs.subscriptionId.value) \" +Write-Host " --resource-group $(Protect-ResourceGroupName -Value $outputs.resourceGroupName.value) \" +Write-Host " --service-name $(Protect-ApimName -Value $outputs.apimServiceName.value) \" +Write-Host " --output-dir ./extracted \" +Write-Host " --log-level $apiopsLogLevel" Write-Host "" Write-Host "Gateway URL: $($outputs.gatewayUrl.value)" -ForegroundColor Gray Write-Host "Workspace deployed: $($outputs.workspaceDeployed.value)" -ForegroundColor Gray @@ -178,6 +249,7 @@ $outputObj = @{ workspaceDeployed = $outputs.workspaceDeployed.value gatewayDeployed = $outputs.gatewayDeployed.value skuName = $outputs.skuName.value + logLevel = $LogLevel } # Write to GitHub Actions output if running in CI diff --git a/tests/integration/all-resource-types/Deploy-TargetApim.ps1 b/tests/integration/all-resource-types/Deploy-TargetApim.ps1 new file mode 100644 index 0000000..6a66270 --- /dev/null +++ b/tests/integration/all-resource-types/Deploy-TargetApim.ps1 @@ -0,0 +1,111 @@ +<# +.SYNOPSIS + Deploys the target APIM instance for round-trip integration testing. + +.DESCRIPTION + Creates a resource group and deploys the target-apim.bicep template, + provisioning a clean APIM instance used as the publish target. + +.PARAMETER ResourceGroupName + Name of the Azure resource group to create or update. + +.PARAMETER PublisherEmail + Publisher email required by the APIM deployment. + +.PARAMETER Location + Azure region. Default: eastus2. + +.PARAMETER SkuName + APIM SKU. Default: StandardV2. Allowed: Developer, Premium, StandardV2, PremiumV2. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$PublisherEmail, + + [string]$Location = 'eastus2', + + [ValidateSet('Developer', 'Premium', 'StandardV2', 'PremiumV2')] + [string]$SkuName = 'StandardV2', + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Info' +) + +$ErrorActionPreference = 'Stop' +$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' } +$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' } +Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'DeploymentHelpers.psm1') -Force + +$bicepFile = Join-Path $PSScriptRoot 'target-apim.bicep' + +if (-not (Test-Path $bicepFile)) { + Write-Error "Bicep file not found at: $bicepFile" +} + +# Verify az CLI authentication and capture subscription id for masked logging +$account = az account show --output json 2>$null | ConvertFrom-Json +if (-not $account) { + Write-Error "Not logged in to Azure CLI. Run 'az login' first." +} +$subscriptionId = $account.id + +Write-Host "Starting target APIM deployment..." +Write-Host "Subscription: $($account.name) ($(Protect-SubscriptionId -Value $subscriptionId))" +Write-Host "Resource Group: $(Protect-ResourceGroupName -Value $ResourceGroupName)" +Write-Host "SKU: $SkuName" +Write-Host "Location: $Location" +Write-Host "Log Level: $LogLevel" + +Write-Host "Creating resource group..." +az group create --name $ResourceGroupName --location $Location --output none +if ($LASTEXITCODE -ne 0) { + throw "Failed to create target resource group" +} + +Write-Host "Deploying target-apim.bicep (this takes 30-45 minutes)..." +$azVerbosity = @() +switch ($LogLevel) { + 'Verbose' { $azVerbosity = @('--verbose') } + 'Debug' { $azVerbosity = @('--debug') } +} + +$azReplacements = @{ + $subscriptionId = Protect-SubscriptionId -Value $subscriptionId + $ResourceGroupName = Protect-ResourceGroupName -Value $ResourceGroupName +} + +$deploymentName = "target-apim-$(Get-Date -Format 'yyyyMMddHHmmss')" + +$azArgs = @( + 'deployment', 'group', 'create', + '--resource-group', $ResourceGroupName, + '--name', $deploymentName, + '--template-file', $bicepFile, + '--parameters', "skuName=$SkuName", "location=$Location", "publisherEmail=$PublisherEmail", + '--output', 'json' +) + $azVerbosity + +$raw = Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $azArgs + +if ($LASTEXITCODE -ne 0) { + Write-DeploymentFailureDetails ` + -ResourceGroupName $ResourceGroupName ` + -DeploymentName $deploymentName ` + -Replacements $azReplacements + throw "Target APIM deployment failed (deployment '$deploymentName' in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'). See failed-operation details above." +} + +$result = $raw | ConvertFrom-Json +if (-not $result.properties.outputs) { + throw "Target deployment returned no outputs" +} + +Write-Host "✅ Target APIM deployed successfully: $(Protect-ApimName -Value $result.properties.outputs.apimServiceName.value)" + +return $result.properties.outputs \ No newline at end of file diff --git a/tests/integration/all-resource-types/DeploymentHelpers.psm1 b/tests/integration/all-resource-types/DeploymentHelpers.psm1 new file mode 100644 index 0000000..b4ab9c0 --- /dev/null +++ b/tests/integration/all-resource-types/DeploymentHelpers.psm1 @@ -0,0 +1,65 @@ +Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') + +function Write-DeploymentFailureDetails { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$DeploymentName, + [hashtable]$Replacements = @{} + ) + + $maskedRg = Protect-ResourceGroupName -Value $ResourceGroupName + Write-Host "" + Write-Host "========== Deployment failure details ==========" -ForegroundColor Yellow + Write-Host "Resource group: $maskedRg" -ForegroundColor Yellow + Write-Host "Deployment: $DeploymentName" -ForegroundColor Yellow + Write-Host "Querying failed deployment operations (before teardown)..." -ForegroundColor Yellow + + $query = "[?properties.provisioningState=='Failed'].{resource:properties.targetResource.resourceName, type:properties.targetResource.resourceType, code:properties.statusMessage.error.code, message:properties.statusMessage.error.message, details:properties.statusMessage.error.details}" + + try { + Invoke-MaskedProcess -FilePath 'az' -Replacements $Replacements -Arguments @( + 'deployment', 'operation', 'group', 'list', + '--resource-group', $ResourceGroupName, + '--name', $DeploymentName, + '--query', $query, + '--output', 'json' + ) + } catch { + $maskedErr = Protect-LogLine -Line ($_.Exception.Message) -Replacements $Replacements + Write-Host "Failed to retrieve deployment operations: $maskedErr" -ForegroundColor Red + } + + Write-Host "================================================" -ForegroundColor Yellow +} + +function Wait-ApimActivation { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$ApimName, + [int]$TimeoutSeconds = 1800, + [int]$PollIntervalSeconds = 20 + ) + + $maskedApim = Protect-ApimName -Value $ApimName + Write-Host "Waiting for APIM '$maskedApim' to finish Activating (timeout ${TimeoutSeconds}s)..." -ForegroundColor Cyan + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastState = $null + while ((Get-Date) -lt $deadline) { + $state = az apim show --resource-group $ResourceGroupName --name $ApimName --query provisioningState --output tsv 2>$null + if ($state -ne $lastState) { + Write-Host " provisioningState: $state" -ForegroundColor Gray + $lastState = $state + } + if ($state -eq 'Succeeded') { return $true } + if ($state -eq 'Failed') { throw "APIM '$maskedApim' entered Failed state during activation wait" } + Start-Sleep -Seconds $PollIntervalSeconds + } + throw "APIM '$maskedApim' did not reach Succeeded within ${TimeoutSeconds}s (last state: $lastState)" +} + +Export-ModuleMember -Function ` + Write-DeploymentFailureDetails, ` + Wait-ApimActivation diff --git a/tests/integration/all-resource-types/MaskingHelpers.psm1 b/tests/integration/all-resource-types/MaskingHelpers.psm1 new file mode 100644 index 0000000..064dbe1 --- /dev/null +++ b/tests/integration/all-resource-types/MaskingHelpers.psm1 @@ -0,0 +1,255 @@ +# MaskingHelpers — secret-redaction utilities for the round-trip integration test scripts. + +$script:EnableMasking = $true + +$script:BuiltinRedactions = @( + @{ Pattern = '([?&])(t|c|s|h)=[^&''"\s]+' + Replacement = '$1$2=' } + + @{ Pattern = '/subscriptions/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + Replacement = '/subscriptions/' } + + @{ Pattern = '/(operationStatuses|operationResults)/[A-Za-z0-9._-]{10,}' + Replacement = '/$1/' } + + @{ Pattern = "(?i)(['""]?x-ms-(?:correlation-)?(?:request|client-request)-id['""]?\s*[:=]\s*['""]?)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + Replacement = '$1' } + + @{ Pattern = "(?i)(['""]?x-ms-routing-request-id['""]?\s*[:=]\s*['""]?)[A-Z0-9]+:\d{8}T\d{6}Z:[0-9a-fA-F-]{36}" + Replacement = '$1' } + + @{ Pattern = "(?i)(authorization[:\s=]+bearer\s+)[A-Za-z0-9._\-+/=]+" + Replacement = '$1' } + + @{ Pattern = '\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}' + Replacement = '' } + + @{ Pattern = '[A-Za-z0-9](?:[A-Za-z0-9._%+\-]*[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?\.[A-Za-z]{2,}' + Replacement = '' } +) + +function Protect-Identifier { + param( + [string]$Value, + [int]$Prefix = 6, + [int]$Suffix = 4 + ) + + if (-not $script:EnableMasking) { + return $Value + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '' + } + + if ($Value.Length -le ($Prefix + $Suffix)) { + return '' + } + + return "{0}...{1}" -f $Value.Substring(0, $Prefix), $Value.Substring($Value.Length - $Suffix) +} + +function Protect-SubscriptionId { + param([string]$Value) + if (-not $script:EnableMasking) { return $Value } + return '' +} + +function Protect-ResourceGroupName { + param([string]$Value) + return Protect-Identifier -Value $Value -Prefix 3 -Suffix 7 +} + +function Protect-ApimName { + param([string]$Value) + return Protect-Identifier -Value $Value -Prefix 3 -Suffix 8 +} + +function Protect-LogLine { + param( + [string]$Line, + [hashtable]$Replacements + ) + + if (-not $script:EnableMasking -or [string]::IsNullOrEmpty($Line)) { + return $Line + } + + $protectedLine = $Line + + if ($Replacements) { + foreach ($entry in $Replacements.GetEnumerator()) { + if ([string]::IsNullOrEmpty($entry.Key) -or [string]::IsNullOrEmpty($entry.Value)) { + continue + } + + $protectedLine = $protectedLine.Replace($entry.Key, $entry.Value) + } + } + + foreach ($rule in $script:BuiltinRedactions) { + $protectedLine = [System.Text.RegularExpressions.Regex]::Replace( + $protectedLine, + $rule.Pattern, + $rule.Replacement) + } + + return $protectedLine +} + +function Resolve-NativeExecutable { + param([string]$Name) + + $resolved = Get-Command -Name $Name -CommandType Application -ErrorAction Stop | + Select-Object -First 1 + + $exePath = $resolved.Source + $prefix = @() + + if ($IsWindows -and ($exePath -like '*.cmd' -or $exePath -like '*.bat')) { + $prefix = @('/c', $exePath) + $exePath = $env:ComSpec + } + + return [pscustomobject]@{ FilePath = $exePath; Prefix = $prefix } +} + +function Invoke-MaskedProcess { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$Arguments = @(), + [hashtable]$Replacements, + [switch]$CaptureStdout + ) + + $exe = Resolve-NativeExecutable -Name $FilePath + $finalArgs = @() + $exe.Prefix + $Arguments + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $exe.FilePath + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.RedirectStandardInput = $false + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + + foreach ($a in $finalArgs) { + [void]$psi.ArgumentList.Add([string]$a) + } + + $stdoutQueue = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $stderrQueue = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + + $proc = [System.Diagnostics.Process]::new() + $proc.StartInfo = $psi + + $stdoutBuffer = $null + if ($CaptureStdout) { + $stdoutBuffer = [System.Collections.Generic.List[string]]::new() + } + + $readerScript = { + param([System.IO.StreamReader]$Reader, + [System.Collections.Concurrent.ConcurrentQueue[string]]$Queue) + try { + while ($null -ne ($line = $Reader.ReadLine())) { + [void]$Queue.Enqueue($line) + } + } catch { + # IO errors on process teardown are expected; main thread owns exit. + } + } + + $outJob = $null + $errJob = $null + + try { + [void]$proc.Start() + + $outJob = Start-ThreadJob -ScriptBlock $readerScript -ArgumentList $proc.StandardOutput, $stdoutQueue + $errJob = Start-ThreadJob -ScriptBlock $readerScript -ArgumentList $proc.StandardError, $stderrQueue + + $line = $null + while (-not $proc.HasExited -or + $outJob.State -eq 'Running' -or + $errJob.State -eq 'Running') { + Start-Sleep -Milliseconds 100 + while ($stderrQueue.TryDequeue([ref]$line)) { + Write-Host (Protect-LogLine -Line $line -Replacements $Replacements) + } + while ($stdoutQueue.TryDequeue([ref]$line)) { + if ($CaptureStdout) { + [void]$stdoutBuffer.Add($line) + } else { + Write-Host (Protect-LogLine -Line $line -Replacements $Replacements) + } + } + } + + Wait-Job -Job $outJob, $errJob | Out-Null + + while ($stderrQueue.TryDequeue([ref]$line)) { + Write-Host (Protect-LogLine -Line $line -Replacements $Replacements) + } + while ($stdoutQueue.TryDequeue([ref]$line)) { + if ($CaptureStdout) { + [void]$stdoutBuffer.Add($line) + } else { + Write-Host (Protect-LogLine -Line $line -Replacements $Replacements) + } + } + + $global:LASTEXITCODE = $proc.ExitCode + } + finally { + if ($outJob) { Remove-Job -Job $outJob -Force -ErrorAction SilentlyContinue } + if ($errJob) { Remove-Job -Job $errJob -Force -ErrorAction SilentlyContinue } + $proc.Dispose() + } + + if ($CaptureStdout) { + return ($stdoutBuffer -join "`n") + } +} + +function Invoke-MaskedApiopsCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string[]]$Arguments, + [hashtable]$Replacements + ) + + Invoke-MaskedProcess -FilePath 'npx' ` + -Arguments (@('apiops') + $Arguments) ` + -Replacements $Replacements + + return $LASTEXITCODE +} + +function Invoke-MaskedAzCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string[]]$Arguments, + [hashtable]$Replacements + ) + + return Invoke-MaskedProcess -FilePath 'az' ` + -Arguments $Arguments ` + -Replacements $Replacements ` + -CaptureStdout +} + +Export-ModuleMember -Function ` + Protect-Identifier, ` + Protect-SubscriptionId, ` + Protect-ResourceGroupName, ` + Protect-ApimName, ` + Protect-LogLine, ` + Resolve-NativeExecutable, ` + Invoke-MaskedProcess, ` + Invoke-MaskedApiopsCommand, ` + Invoke-MaskedAzCommand diff --git a/tests/integration/all-resource-types/README.md b/tests/integration/all-resource-types/README.md index b42efa8..06bd27f 100644 --- a/tests/integration/all-resource-types/README.md +++ b/tests/integration/all-resource-types/README.md @@ -56,7 +56,7 @@ The kitchen sink APIM instance includes **every resource type and API protocol v ### Deploy ```powershell -# Deploy with defaults (StandardV2 SKU, eastus2, auto-generated resource names) +# Deploy with defaults (StandardV2 SKU, centralus, auto-generated resource names) .\deploy-source.ps1 -ResourceGroupName rg-apiops-bvt -PublisherEmail admin@contoso.com # Deploy with Developer SKU (classic — supports self-hosted gateways, no workspaces) @@ -101,7 +101,8 @@ The round-trip test validates the full extract→publish cycle: The workflow at `.github/workflows/integration-test.yml` provides a manual trigger (`workflow_dispatch`) with: - **SKU selection** (StandardV2, Developer, Premium, PremiumV2) -- **Location** (default: eastus2) +- **Location** (default: centralus) +- **Log level** (Info, Verbose, Debug; default: Verbose) - **Skip teardown** toggle for debugging Requires an `integration-test` environment with secrets: diff --git a/tests/integration/all-resource-types/expected-structure.json b/tests/integration/all-resource-types/expected-structure.json index 49f59b6..bcb8328 100644 --- a/tests/integration/all-resource-types/expected-structure.json +++ b/tests/integration/all-resource-types/expected-structure.json @@ -292,30 +292,13 @@ "files": ["policyRestrictionInformation.json"], "spotChecks": { "policyRestrictionInformation.json": { - "properties.scope": "All", + "properties.scope": "/products/src-product-starter", "properties.requireBase": "true" } } } ] }, - "documentations": { - "minCount": 1, - "skuDependent": true, - "skuFilter": ["Developer", "Premium"], - "expected": [ - { - "name": "src-doc-getting-started", - "files": ["documentationInformation.json"], - "spotChecks": { - "documentationInformation.json": { - "properties.title": "Getting Started", - "properties.content": "exists" - } - } - } - ] - }, "products": { "minCount": 2, "expected": [ diff --git a/tests/integration/all-resource-types/run-roundtrip-test.ps1 b/tests/integration/all-resource-types/run-roundtrip-test.ps1 old mode 100644 new mode 100755 index 7cb3230..e17b599 --- a/tests/integration/all-resource-types/run-roundtrip-test.ps1 +++ b/tests/integration/all-resource-types/run-roundtrip-test.ps1 @@ -35,6 +35,8 @@ .\run-roundtrip-test.ps1 -PublisherEmail admin@contoso.com -HardDelete #> +#requires -Version 7.0 + [CmdletBinding()] param( [Parameter()] @@ -48,10 +50,13 @@ param( [string]$Location = 'eastus2', + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + [Parameter(Mandatory)] [string]$PublisherEmail, - [string]$ExtractOutputDir = './extracted-artifacts', + [string]$ExtractOutputDir = "$PSScriptRoot/extracted-artifacts", [switch]$SkipTeardown, @@ -59,6 +64,10 @@ param( ) $ErrorActionPreference = 'Stop' +$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' } +$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' } +$maskingModule = Join-Path $PSScriptRoot 'MaskingHelpers.psm1' +Import-Module $maskingModule -Force # Default to hard-delete on teardown unless explicitly disabled. if (-not $PSBoundParameters.ContainsKey('HardDelete')) { @@ -90,8 +99,8 @@ if (-not $TargetResourceGroup) { $TargetResourceGroup = "bvt-$UniqueId-tgt-rg" } -Write-Host " Source RG: $SourceResourceGroup" -Write-Host " Target RG: $TargetResourceGroup" +Write-Host " Source RG: $(Protect-ResourceGroupName -Value $SourceResourceGroup)" +Write-Host " Target RG: $(Protect-ResourceGroupName -Value $TargetResourceGroup)" # --------------------------------------------------------------------------- # Helpers @@ -117,6 +126,15 @@ function Write-GithubOutput([string]$key, [string]$value) { } } +function Get-ApiopsLogLevel([string]$ScriptLogLevel) { + switch ($ScriptLogLevel) { + 'Info' { return 'info' } + 'Verbose' { return 'warn' } + 'Debug' { return 'debug' } + default { return 'info' } + } +} + function Invoke-Teardown { if ($SkipTeardown) { Write-Host "⏭️ Teardown skipped (-SkipTeardown)" @@ -134,16 +152,16 @@ function Invoke-Teardown { $targetApimName = az apim list --resource-group $TargetResourceGroup --query "[0].name" -o tsv 2>$null if ($sourceApimName) { - Write-Host " Found source APIM: $sourceApimName" + Write-Host " Found source APIM: $(Protect-ApimName -Value $sourceApimName)" } if ($targetApimName) { - Write-Host " Found target APIM: $targetApimName" + Write-Host " Found target APIM: $(Protect-ApimName -Value $targetApimName)" } } - Write-Host " Deleting $SourceResourceGroup..." + Write-Host " Deleting $(Protect-ResourceGroupName -Value $SourceResourceGroup)..." az group delete --name $SourceResourceGroup --yes --no-wait 2>$null - Write-Host " Deleting $TargetResourceGroup..." + Write-Host " Deleting $(Protect-ResourceGroupName -Value $TargetResourceGroup)..." az group delete --name $TargetResourceGroup --yes --no-wait 2>$null if ($HardDelete) { @@ -170,22 +188,22 @@ function Invoke-Teardown { # Purge soft-deleted APIM instances if ($sourceApimName) { - Write-Host " 🗑️ Purging soft-deleted APIM: $sourceApimName..." + Write-Host " 🗑️ Purging soft-deleted APIM: $(Protect-ApimName -Value $sourceApimName)..." az apim deletedservice purge --service-name $sourceApimName --location $Location 2>$null if ($LASTEXITCODE -eq 0) { - Write-Host " ✅ $sourceApimName purged" + Write-Host " ✅ $(Protect-ApimName -Value $sourceApimName) purged" } else { - Write-Host " ⚠️ Could not purge $sourceApimName (may not exist in soft-delete)" + Write-Host " ⚠️ Could not purge $(Protect-ApimName -Value $sourceApimName) (may not exist in soft-delete)" } } if ($targetApimName) { - Write-Host " 🗑️ Purging soft-deleted APIM: $targetApimName..." + Write-Host " 🗑️ Purging soft-deleted APIM: $(Protect-ApimName -Value $targetApimName)..." az apim deletedservice purge --service-name $targetApimName --location $Location 2>$null if ($LASTEXITCODE -eq 0) { - Write-Host " ✅ $targetApimName purged" + Write-Host " ✅ $(Protect-ApimName -Value $targetApimName) purged" } else { - Write-Host " ⚠️ Could not purge $targetApimName (may not exist in soft-delete)" + Write-Host " ⚠️ Could not purge $(Protect-ApimName -Value $targetApimName) (may not exist in soft-delete)" } } @@ -208,7 +226,7 @@ if (-not $account) { } $subscriptionId = $account.id -Write-Host " Subscription: $($account.name) ($subscriptionId)" +Write-Host " Subscription: $($account.name) ($(Protect-SubscriptionId -Value $subscriptionId))" Write-PhaseEnd "🔐" "Azure CLI authenticated" # --------------------------------------------------------------------------- @@ -216,10 +234,10 @@ Write-PhaseEnd "🔐" "Azure CLI authenticated" # --------------------------------------------------------------------------- $deploySourceScript = Join-Path $PSScriptRoot 'Deploy-SourceApim.ps1' -$targetBicepFile = Join-Path $PSScriptRoot 'target-apim.bicep' +$deployTargetScript = Join-Path $PSScriptRoot 'Deploy-TargetApim.ps1' $compareScript = Join-Path $PSScriptRoot 'Compare-ApimInstance.ps1' -foreach ($requiredFile in @($deploySourceScript, $targetBicepFile, $compareScript)) { +foreach ($requiredFile in @($maskingModule, $deploySourceScript, $deployTargetScript, $compareScript)) { if (-not (Test-Path $requiredFile)) { Write-Error "Required file not found: $requiredFile" exit 2 @@ -255,11 +273,18 @@ try { # --- Source deployment job --- $sourceJob = Start-Job -Name 'DeploySource' -ScriptBlock { - param($script, $rg, $sku, $loc, $email, $transcriptFile) + param($script, $rg, $sku, $loc, $email, $transcriptFile, $logLevel) $ErrorActionPreference = 'Stop' - Start-Transcript -Path $transcriptFile -Force | Out-Null + Start-Transcript -Path $transcriptFile -Force -UseMinimalHeader | Out-Null try { - $result = & $script -ResourceGroupName $rg -SkuName $sku -Location $loc -PublisherEmail $email + $scriptArgs = @{ + ResourceGroupName = $rg + SkuName = $sku + Location = $loc + PublisherEmail = $email + LogLevel = $logLevel + } + $result = & $script @scriptArgs if (-not $result -or -not $result.apimServiceName) { throw "Source deployment returned no outputs" } @@ -267,46 +292,31 @@ try { } finally { Stop-Transcript | Out-Null } - } -ArgumentList $deploySourceScript, $SourceResourceGroup, $SkuName, $Location, $PublisherEmail, $sourceLogFile + } -ArgumentList $deploySourceScript, $SourceResourceGroup, $SkuName, $Location, $PublisherEmail, $sourceLogFile, $LogLevel Write-Host " ▶ Source deployment started" # --- Target deployment job --- $targetJob = Start-Job -Name 'DeployTarget' -ScriptBlock { - param($rg, $sku, $loc, $email, $bicep, $transcriptFile) + param($script, $rg, $sku, $loc, $email, $transcriptFile, $logLevel) $ErrorActionPreference = 'Stop' - Start-Transcript -Path $transcriptFile -Force | Out-Null + Start-Transcript -Path $transcriptFile -Force -UseMinimalHeader | Out-Null try { - Write-Host "Starting target APIM deployment..." - Write-Host "Resource Group: $rg" - Write-Host "SKU: $sku" - Write-Host "Location: $loc" - - Write-Host "Creating resource group..." - az group create --name $rg --location $loc --output none - if ($LASTEXITCODE -ne 0) { - throw "Failed to create target resource group" - } - - Write-Host "Deploying target-apim.bicep (this takes 30-45 minutes)..." - $raw = az deployment group create ` - --resource-group $rg ` - --name "target-apim-$(Get-Date -Format 'yyyyMMddHHmmss')" ` - --template-file $bicep ` - --parameters skuName=$sku location=$loc publisherEmail=$email ` - --output json - if ($LASTEXITCODE -ne 0) { - throw "Target APIM deployment failed" + $scriptArgs = @{ + ResourceGroupName = $rg + SkuName = $sku + Location = $loc + PublisherEmail = $email + LogLevel = $logLevel } - $result = $raw | ConvertFrom-Json - if (-not $result.properties.outputs) { + $result = & $script @scriptArgs + if (-not $result -or -not $result.apimServiceName -or -not $result.apimServiceName.value) { throw "Target deployment returned no outputs" } - Write-Host "✅ Target APIM deployed successfully: $($result.properties.outputs.apimServiceName.value)" - return $result.properties.outputs + return $result } finally { Stop-Transcript | Out-Null } - } -ArgumentList $TargetResourceGroup, $SkuName, $Location, $PublisherEmail, $targetBicepFile, $targetLogFile + } -ArgumentList $deployTargetScript, $TargetResourceGroup, $SkuName, $Location, $PublisherEmail, $targetLogFile, $LogLevel Write-Host " ▶ Target deployment started" # --- Wait for jobs with real-time log streaming --- @@ -382,7 +392,7 @@ try { $exitCode = 2 } else { $sourceOutputs = Receive-Job $sourceJob - Write-Host " ✅ Source deployed: $($sourceOutputs.apimServiceName)" + Write-Host " ✅ Source deployed: $(Protect-ApimName -Value $sourceOutputs.apimServiceName)" } Remove-Job $sourceJob @@ -399,7 +409,7 @@ try { $exitCode = 2 } else { $targetOutputs = Receive-Job $targetJob - Write-Host " ✅ Target deployed: $($targetOutputs.apimServiceName.value)" + Write-Host " ✅ Target deployed: $(Protect-ApimName -Value $targetOutputs.apimServiceName.value)" } Remove-Job $targetJob @@ -422,7 +432,7 @@ try { # Query APIM instance from resource group $apimName = az apim list --resource-group $SourceResourceGroup --query "[0].name" -o tsv 2>$null if (-not $apimName) { - Write-Host "❌ No APIM instance found in resource group $SourceResourceGroup" + Write-Host "❌ No APIM instance found in resource group $(Protect-ResourceGroupName -Value $SourceResourceGroup)" exit 2 } $apimName @@ -435,12 +445,33 @@ try { # Query APIM instance from resource group $apimName = az apim list --resource-group $TargetResourceGroup --query "[0].name" -o tsv 2>$null if (-not $apimName) { - Write-Host "❌ No APIM instance found in resource group $TargetResourceGroup" + Write-Host "❌ No APIM instance found in resource group $(Protect-ResourceGroupName -Value $TargetResourceGroup)" exit 2 } $apimName } + # Ensure post-activation deployment completed before extract/structure validation. + if ($SkuName -in @('Developer', 'Premium')) { + $postActivationState = az deployment group list ` + --resource-group $sourceRg ` + --query "sort_by([?starts_with(name, 'source-apim-post-activation-')], &properties.timestamp)[-1].properties.provisioningState" ` + -o tsv 2>$null + if (-not $postActivationState) { + Write-Host "❌ Could not find source-apim-post-activation deployment in $(Protect-ResourceGroupName -Value $sourceRg)" + $exitCode = 2 + Invoke-Teardown + exit $exitCode + } + if ($postActivationState -ne 'Succeeded') { + Write-Host "❌ source-apim-post-activation deployment state is '$postActivationState' (expected 'Succeeded')" + $exitCode = 2 + Invoke-Teardown + exit $exitCode + } + Write-Host " ✅ source-apim-post-activation deployment confirmed" + } + # ----------------------------------------------------------------------- # Phase 2: Extract # ----------------------------------------------------------------------- @@ -453,17 +484,31 @@ try { Write-Host " Cleaned previous extract output" } - Write-Host " Source: $sourceName (sub: $sourceSubId, rg: $sourceRg)" + Write-Host " Source: $(Protect-ApimName -Value $sourceName) (sub: $(Protect-SubscriptionId -Value $sourceSubId), rg: $(Protect-ResourceGroupName -Value $sourceRg))" Write-Host " Output: $ExtractOutputDir" - npx apiops extract ` - --subscription-id $sourceSubId ` - --resource-group $sourceRg ` - --service-name $sourceName ` - --output $ExtractOutputDir - - if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Extract failed (exit code $LASTEXITCODE)" + $apiopsLogLevel = Get-ApiopsLogLevel -ScriptLogLevel $LogLevel + + # For SKUs that support workspaces (Premium / PremiumV2), pass a filter file + # with the workspace name so the extractor includes workspace-scoped resources. + # Without this, workspace extraction is silently skipped. + $extractArgs = @( + 'extract', + '--subscription-id', $sourceSubId, + '--resource-group', $sourceRg, + '--service-name', $sourceName, + '--output', $ExtractOutputDir, + '--log-level', $apiopsLogLevel + ) + + $extractExitCode = Invoke-MaskedApiopsCommand -Replacements @{ + $sourceSubId = Protect-SubscriptionId -Value $sourceSubId + $sourceRg = Protect-ResourceGroupName -Value $sourceRg + $sourceName = Protect-ApimName -Value $sourceName + } -Arguments $extractArgs + + if ($extractExitCode -ne 0) { + Write-Host "❌ Extract failed (exit code $extractExitCode)" $exitCode = 2 Invoke-Teardown exit $exitCode @@ -492,10 +537,16 @@ try { $manifestFile = Join-Path $PSScriptRoot 'expected-structure.json' $validateScript = Join-Path $PSScriptRoot 'Test-ExtractedArtifact.ps1' - & $validateScript ` - -ExtractedDir $ExtractOutputDir ` - -ManifestFile $manifestFile ` - -SkuName $SkuName + $validateArgs = @{ + ExtractedDir = $ExtractOutputDir + ManifestFile = $manifestFile + SkuName = $SkuName + } + switch ($LogLevel) { + 'Verbose' { $validateArgs.Verbose = $true } + 'Debug' { $validateArgs.Debug = $true } + } + & $validateScript @validateArgs $validateExitCode = $LASTEXITCODE $validateTimer.Stop() @@ -548,7 +599,7 @@ try { # Target Event Hub entity name (hardcoded in target-apim.bicep) $targetEhName = 'tgt-eh-logs' - $overrideFile = Join-Path $ExtractOutputDir '.overrides.yaml' + $overrideFile = [System.IO.Path]::GetFullPath((Join-Path $ExtractOutputDir '.overrides.yaml')) $overrideYaml = @" namedValues: src-nv-keyvault: @@ -576,18 +627,25 @@ loggers: Write-Phase "📤" "PHASE 3 — Publish to target APIM" $publishTimer = [System.Diagnostics.Stopwatch]::StartNew() - Write-Host " Target: $targetName (sub: $targetSubId, rg: $targetRg)" + Write-Host " Target: $(Protect-ApimName -Value $targetName) (sub: $(Protect-SubscriptionId -Value $targetSubId), rg: $(Protect-ResourceGroupName -Value $targetRg))" Write-Host " Input: $ExtractOutputDir" - npx apiops publish ` - --subscription-id $targetSubId ` - --resource-group $targetRg ` - --service-name $targetName ` - --source $ExtractOutputDir ` - --overrides $overrideFile - - if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Publish failed (exit code $LASTEXITCODE)" + $publishExitCode = Invoke-MaskedApiopsCommand -Replacements @{ + $targetSubId = Protect-SubscriptionId -Value $targetSubId + $targetRg = Protect-ResourceGroupName -Value $targetRg + $targetName = Protect-ApimName -Value $targetName + } -Arguments @( + 'publish', + '--subscription-id', $targetSubId, + '--resource-group', $targetRg, + '--service-name', $targetName, + '--source', $ExtractOutputDir, + '--overrides', $overrideFile, + '--log-level', $apiopsLogLevel + ) + + if ($publishExitCode -ne 0) { + Write-Host "❌ Publish failed (exit code $publishExitCode)" $exitCode = 2 Invoke-Teardown exit $exitCode @@ -604,13 +662,19 @@ loggers: Write-Phase "🔍" "PHASE 4 — Compare source and target APIM instances" $verifyTimer = [System.Diagnostics.Stopwatch]::StartNew() - & $compareScript ` - -SourceSubscriptionId $sourceSubId ` - -SourceResourceGroup $sourceRg ` - -SourceApimName $sourceName ` - -TargetSubscriptionId $targetSubId ` - -TargetResourceGroup $targetRg ` - -TargetApimName $targetName + $compareArgs = @{ + SourceSubscriptionId = $sourceSubId + SourceResourceGroup = $sourceRg + SourceApimName = $sourceName + TargetSubscriptionId = $targetSubId + TargetResourceGroup = $targetRg + TargetApimName = $targetName + } + switch ($LogLevel) { + 'Verbose' { $compareArgs.Verbose = $true } + 'Debug' { $compareArgs.Debug = $true } + } + & $compareScript @compareArgs $verifyExitCode = $LASTEXITCODE $verifyTimer.Stop() diff --git a/tests/integration/all-resource-types/source-apim-post-activation.bicep b/tests/integration/all-resource-types/source-apim-post-activation.bicep new file mode 100644 index 0000000..2152065 --- /dev/null +++ b/tests/integration/all-resource-types/source-apim-post-activation.bicep @@ -0,0 +1,106 @@ +@description('Existing APIM name to apply activation-sensitive child resources to.') +param apimName string + +@description('APIM SKU name. Classic SKUs support docs/wiki/policyRestriction.') +@allowed(['Developer', 'Premium', 'StandardV2', 'PremiumV2']) +param skuName string + +var isClassicSku = skuName == 'Developer' || skuName == 'Premium' + +var servicePolicyXml = ''' + + + + + https://developer.contoso.com + + GETPOST +
Content-Type
Authorization
+
+
+ + + +
+''' + +var apiPolicyXml = ''' + + + + + true + + + + + + +''' + +var productPolicyXml = ''' + + + + + + + + + +''' + +resource apim 'Microsoft.ApiManagement/service@2025-09-01-preview' existing = { + name: apimName +} + +resource productStarter 'Microsoft.ApiManagement/service/products@2025-09-01-preview' existing = { + parent: apim + name: 'src-product-starter' +} + +resource productPremium 'Microsoft.ApiManagement/service/products@2025-09-01-preview' existing = { + parent: apim + name: 'src-product-premium' +} + +resource apiRestOpenapi 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' existing = { + parent: apim + name: 'src-rest-openapi' +} + +resource servicePolicy 'Microsoft.ApiManagement/service/policies@2025-09-01-preview' = { + parent: apim + name: 'policy' + properties: { + format: 'rawxml' + value: servicePolicyXml + } +} + +resource productPremiumPolicy 'Microsoft.ApiManagement/service/products/policies@2025-09-01-preview' = { + parent: productPremium + name: 'policy' + properties: { + format: 'rawxml' + value: productPolicyXml + } +} + +resource apiRestPolicy 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = { + parent: apiRestOpenapi + name: 'policy' + properties: { + format: 'rawxml' + value: apiPolicyXml + } +} + +resource policyRestriction 'Microsoft.ApiManagement/service/policyRestrictions@2025-09-01-preview' = if (isClassicSku) { + parent: apim + name: 'src-restriction-ip' + properties: { + scope: '/products/${productStarter.name}' + requireBase: 'true' + } +} diff --git a/tests/integration/all-resource-types/source-apim.bicep b/tests/integration/all-resource-types/source-apim.bicep index f715e7a..99f5c3f 100644 --- a/tests/integration/all-resource-types/source-apim.bicep +++ b/tests/integration/all-resource-types/source-apim.bicep @@ -208,38 +208,6 @@ paths: description: OK ''' -// Service-level policy XML (no allowed at service level) -var servicePolicyXml = ''' - - - - - https://developer.contoso.com - - GETPOST -
Content-Type
Authorization
-
-
- - - -
-''' - -// API-level policy XML -var apiPolicyXml = ''' - - - - - true - - - - - - -''' // Operation-level policy XML var operationPolicyXml = ''' @@ -254,19 +222,6 @@ var operationPolicyXml = ''' ''' -// Product policy XML (renewal-period max is 300 seconds) -var productPolicyXml = ''' - - - - - - - - - -''' - // GraphQL resolver policy XML var resolverPolicyXml = ''' @@ -662,25 +617,7 @@ resource globalSchema 'Microsoft.ApiManagement/service/schemas@2025-09-01-previe } } -// --- Policy Restriction (classic SKUs only - not supported in V2 tiers) --- -resource policyRestriction 'Microsoft.ApiManagement/service/policyRestrictions@2025-09-01-preview' = if (isClassicSku) { - parent: apim - name: 'src-restriction-ip' - properties: { - scope: 'All' - requireBase: 'true' - } -} - -// --- Documentation (classic SKUs only - V2 tiers use different documentation mechanism) --- -resource documentation 'Microsoft.ApiManagement/service/documentations@2025-09-01-preview' = if (isClassicSku) { - parent: apim - name: 'src-doc-getting-started' - properties: { - title: 'Getting Started' - content: '# Getting Started\n\nThis is the kitchen sink APIM instance for BVT testing.\n\n## Quick Start\n\nUse the APIOps CLI to extract and publish configurations.' - } -} +// Activation-sensitive resources are deployed post-activation by Deploy-SourceApim.ps1. // --------------------------------------------------------------------------- // TIER 2: Resources with Dependencies @@ -701,16 +638,7 @@ resource diagnostic 'Microsoft.ApiManagement/service/diagnostics@2025-09-01-prev } } -// --- Service Policy --- -resource servicePolicy 'Microsoft.ApiManagement/service/policies@2025-09-01-preview' = { - parent: apim - name: 'policy' - dependsOn: [nvPlain, fragmentCors, fragmentRateLimit] - properties: { - format: 'rawxml' - value: servicePolicyXml - } -} +// Service policy is deployed post-activation by Deploy-SourceApim.ps1. // --- Products --- resource productStarter 'Microsoft.ApiManagement/service/products@2025-09-01-preview' = { @@ -977,15 +905,7 @@ resource apiMcpFromExternal 'Microsoft.ApiManagement/service/apis@2025-09-01-pre // TIER 3: Child Resources // --------------------------------------------------------------------------- -// --- Product Policy --- -resource productPremiumPolicy 'Microsoft.ApiManagement/service/products/policies@2025-09-01-preview' = { - parent: productPremium - name: 'policy' - properties: { - format: 'rawxml' - value: productPolicyXml - } -} +// Product policy is deployed post-activation by Deploy-SourceApim.ps1. // --- Product API Associations --- resource productStarterApiRest 'Microsoft.ApiManagement/service/products/apis@2025-09-01-preview' = { @@ -1031,29 +951,9 @@ resource productStarterTag 'Microsoft.ApiManagement/service/products/tags@2025-0 dependsOn: [tagEnv] } -// --- Product Wiki (classic SKUs only - documentation dependency) --- -resource productStarterWiki 'Microsoft.ApiManagement/service/products/wikis@2025-09-01-preview' = if (isClassicSku) { - parent: productStarter - name: 'default' - properties: { - documents: [ - { - documentationId: 'src-doc-getting-started' - } - ] - } - dependsOn: [documentation] -} +// Product wiki is deployed post-activation by Deploy-SourceApim.ps1. -// --- API Policy --- -resource apiRestPolicy 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = { - parent: apiRestOpenapi - name: 'policy' - properties: { - format: 'rawxml' - value: apiPolicyXml - } -} +// API policy is deployed post-activation by Deploy-SourceApim.ps1. // --- API Tags --- resource apiRestTagEnv 'Microsoft.ApiManagement/service/apis/tags@2025-09-01-preview' = { @@ -1129,19 +1029,7 @@ resource apiRestTagDescEnv 'Microsoft.ApiManagement/service/apis/tagDescriptions } } -// --- API Wiki (classic SKUs only - documentation dependency) --- -resource apiRestWiki 'Microsoft.ApiManagement/service/apis/wikis@2025-09-01-preview' = if (isClassicSku) { - parent: apiRestOpenapi - name: 'default' - properties: { - documents: [ - { - documentationId: 'src-doc-getting-started' - } - ] - } - dependsOn: [documentation] -} +// API wiki is deployed post-activation by Deploy-SourceApim.ps1. // --- API Release (on revisioned API) --- resource apiRelease 'Microsoft.ApiManagement/service/apis/releases@2025-09-01-preview' = { diff --git a/tests/integration/all-resource-types/source-apim.json b/tests/integration/all-resource-types/source-apim.json deleted file mode 100644 index 0da2149..0000000 --- a/tests/integration/all-resource-types/source-apim.json +++ /dev/null @@ -1,1336 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.43.8.12551", - "templateHash": "2503074933062083704" - } - }, - "parameters": { - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Azure region for all resources." - } - }, - "apimName": { - "type": "string", - "defaultValue": "[format('bvt-{0}-src-apim', uniqueString(resourceGroup().id))]", - "metadata": { - "description": "Unique name for the APIM instance." - } - }, - "publisherEmail": { - "type": "string", - "metadata": { - "description": "Publisher email shown in the developer portal." - } - }, - "publisherName": { - "type": "string", - "defaultValue": "APIOps BVT", - "metadata": { - "description": "Publisher name shown in the developer portal." - } - }, - "skuName": { - "type": "string", - "defaultValue": "StandardV2", - "allowedValues": [ - "Developer", - "Premium", - "StandardV2", - "PremiumV2" - ], - "metadata": { - "description": "APIM SKU name. Use StandardV2/PremiumV2 for v2 tiers, or Developer/Premium for classic." - } - }, - "appInsightsName": { - "type": "string", - "defaultValue": "[format('bvt-{0}-src-ai', uniqueString(resourceGroup().id))]", - "metadata": { - "description": "Application Insights name for logger/diagnostic testing." - } - }, - "eventHubNamespaceName": { - "type": "string", - "defaultValue": "[format('bvt-{0}-src-eh', uniqueString(resourceGroup().id))]", - "metadata": { - "description": "Event Hub namespace name for Event Hub logger testing." - } - }, - "keyVaultName": { - "type": "string", - "defaultValue": "[format('bvt-{0}-src-kv', uniqueString(resourceGroup().id))]", - "metadata": { - "description": "Key Vault name for NamedValue KeyVault reference testing." - } - }, - "logAnalyticsName": { - "type": "string", - "defaultValue": "[format('bvt-{0}-src-law', uniqueString(resourceGroup().id))]", - "metadata": { - "description": "Log Analytics workspace name for Application Insights." - } - } - }, - "variables": { - "isClassicSku": "[or(equals(parameters('skuName'), 'Developer'), equals(parameters('skuName'), 'Premium'))]", - "apimSkuCapacity": "[if(variables('isClassicSku'), 1, 1)]", - "supportsSelfHostedGateway": "[variables('isClassicSku')]", - "supportsWorkspaces": "[or(equals(parameters('skuName'), 'Premium'), equals(parameters('skuName'), 'PremiumV2'))]", - "openApiSpec": "openapi: \"3.0.1\"\ninfo:\n title: Kitchen Sink REST API\n version: \"1.0\"\npaths:\n /healthz:\n get:\n operationId: healthCheck\n summary: Health check endpoint\n responses:\n \"200\":\n description: OK\n /items:\n get:\n operationId: listItems\n summary: List all items\n responses:\n \"200\":\n description: OK\n post:\n operationId: createItem\n summary: Create an item\n requestBody:\n content:\n application/json:\n schema:\n type: object\n properties:\n name:\n type: string\n responses:\n \"201\":\n description: Created\n /items/{id}:\n get:\n operationId: getItem\n summary: Get item by ID\n parameters:\n - name: id\n in: path\n required: true\n schema:\n type: string\n responses:\n \"200\":\n description: OK\n", - "graphqlSchema": "type Query {\n hero(episode: Episode): Character\n reviews(episode: Episode!): [Review]\n}\n\ntype Mutation {\n createReview(episode: Episode!, review: ReviewInput!): Review\n}\n\nenum Episode {\n NEWHOPE\n EMPIRE\n JEDI\n}\n\ntype Character {\n id: ID!\n name: String!\n appearsIn: [Episode]!\n}\n\ntype Review {\n episode: Episode\n stars: Int!\n commentary: String\n}\n\ninput ReviewInput {\n stars: Int!\n commentary: String\n}\n", - "wsdlSpec": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "openApiSpecV1": "openapi: \"3.0.1\"\ninfo:\n title: Kitchen Sink Versioned API\n version: \"v1\"\npaths:\n /status:\n get:\n operationId: getStatus-v1\n summary: Get status (v1)\n responses:\n \"200\":\n description: OK\n", - "servicePolicyXml": "\n \n \n \n https://developer.contoso.com\n \n GETPOST\n
Content-Type
Authorization
\n
\n
\n \n \n \n
\n", - "apiPolicyXml": "\n \n \n \n true\n \n \n \n \n \n\n", - "operationPolicyXml": "\n \n \n \n \n \n \n \n\n", - "productPolicyXml": "\n \n \n \n \n \n \n \n\n", - "resolverPolicyXml": "\n \n POST\n https://src-graphql-backend.example.com/api/hero\n \n application/json\n \n {\"query\":\"{ countries { name code } }\"}\n \n\n", - "corsFragmentXml": "\n \n \n *\n \n \n *\n \n \n
*
\n
\n
\n
\n", - "rateLimitFragmentXml": "\n \n\n" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2022-10-01", - "name": "[parameters('logAnalyticsName')]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "name": "PerGB2018" - }, - "retentionInDays": 30 - } - }, - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[parameters('appInsightsName')]", - "location": "[parameters('location')]", - "kind": "web", - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsName'))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsName'))]" - ] - }, - { - "type": "Microsoft.EventHub/namespaces", - "apiVersion": "2024-01-01", - "name": "[parameters('eventHubNamespaceName')]", - "location": "[parameters('location')]", - "sku": { - "name": "Basic", - "tier": "Basic", - "capacity": 1 - } - }, - { - "type": "Microsoft.EventHub/namespaces/eventhubs", - "apiVersion": "2024-01-01", - "name": "[format('{0}/{1}', parameters('eventHubNamespaceName'), 'src-apim-logs')]", - "properties": { - "messageRetentionInDays": 1, - "partitionCount": 2 - }, - "dependsOn": [ - "[resourceId('Microsoft.EventHub/namespaces', parameters('eventHubNamespaceName'))]" - ] - }, - { - "type": "Microsoft.EventHub/namespaces/authorizationRules", - "apiVersion": "2024-01-01", - "name": "[format('{0}/{1}', parameters('eventHubNamespaceName'), 'src-apim-send')]", - "properties": { - "rights": [ - "Send" - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.EventHub/namespaces', parameters('eventHubNamespaceName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2023-07-01", - "name": "[parameters('keyVaultName')]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "family": "A", - "name": "standard" - }, - "tenantId": "[subscription().tenantId]", - "enableRbacAuthorization": false, - "accessPolicies": [], - "enableSoftDelete": true, - "softDeleteRetentionInDays": 7 - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'src-secret-value')]", - "properties": { - "value": "all-resources-secret-value" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service", - "apiVersion": "2025-09-01-preview", - "name": "[parameters('apimName')]", - "location": "[parameters('location')]", - "sku": { - "name": "[parameters('skuName')]", - "capacity": "[variables('apimSkuCapacity')]" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publisherEmail": "[parameters('publisherEmail')]", - "publisherName": "[parameters('publisherName')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/accessPolicies", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'add')]", - "properties": { - "accessPolicies": [ - { - "tenantId": "[subscription().tenantId]", - "objectId": "[reference(resourceId('Microsoft.ApiManagement/service', parameters('apimName')), '2025-09-01-preview', 'full').identity.principalId]", - "permissions": { - "secrets": [ - "get" - ] - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/namedValues", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-nv-plain')]", - "properties": { - "displayName": "src-nv-plain", - "value": "plain-text-value", - "tags": [ - "all-resources", - "plain" - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/namedValues", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-nv-secret')]", - "properties": { - "displayName": "src-nv-secret", - "value": "secret-value-redacted", - "secret": true, - "tags": [ - "all-resources", - "secret" - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/namedValues", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-nv-keyvault')]", - "properties": { - "displayName": "src-nv-keyvault", - "keyVault": { - "secretIdentifier": "[format('{0}secrets/src-secret-value', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri)]" - }, - "secret": true, - "tags": [ - "all-resources", - "keyvault" - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "[resourceId('Microsoft.KeyVault/vaults/accessPolicies', parameters('keyVaultName'), 'add')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-tag-env')]", - "properties": { - "displayName": "src-tag-env" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-tag-team')]", - "properties": { - "displayName": "src-tag-team" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "condition": "[variables('supportsSelfHostedGateway')]", - "type": "Microsoft.ApiManagement/service/gateways", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-gateway-onprem')]", - "properties": { - "description": "Kitchen sink self-hosted gateway for BVT", - "locationData": { - "name": "On-Premises DC", - "city": "Seattle", - "countryOrRegion": "US" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apiVersionSets", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-versionset-urlpath')]", - "properties": { - "displayName": "Kitchen Sink Versioned API", - "versioningScheme": "Segment" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-http')]", - "properties": { - "description": "Simple HTTP backend", - "url": "https://src-backend.example.com/api", - "protocol": "http", - "tls": { - "validateCertificateChain": true, - "validateCertificateName": true - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-function')]", - "properties": { - "description": "Azure Function backend stub", - "url": "https://src-func-app.azurewebsites.net/api", - "protocol": "http", - "resourceId": "[format('{0}subscriptions/{1}/resourceGroups/{2}/providers/Microsoft.Web/sites/src-func-app', environment().resourceManager, subscription().subscriptionId, resourceGroup().name)]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-logicapp')]", - "properties": { - "description": "Logic App backend stub", - "url": "https://src-logic-app.azurewebsites.net/api", - "protocol": "http", - "resourceId": "[format('{0}subscriptions/{1}/resourceGroups/{2}/providers/Microsoft.Logic/workflows/src-logic-app', environment().resourceManager, subscription().subscriptionId, resourceGroup().name)]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-circuit-breaker')]", - "properties": { - "description": "Backend with circuit breaker configuration", - "url": "https://src-cb-backend.example.com/api", - "protocol": "http", - "circuitBreaker": { - "rules": [ - { - "name": "src-breaker-rule", - "failureCondition": { - "count": 5, - "interval": "PT1M", - "statusCodeRanges": [ - { - "min": 500, - "max": 599 - } - ] - }, - "tripDuration": "PT30S", - "acceptRetryAfter": true - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-pool')]", - "properties": { - "description": "Backend pool referencing multiple backends", - "type": "Pool", - "pool": { - "services": [ - { - "id": "[resourceId('Microsoft.ApiManagement/service/backends', parameters('apimName'), 'src-backend-http')]", - "priority": 1, - "weight": 80 - }, - { - "id": "[resourceId('Microsoft.ApiManagement/service/backends', parameters('apimName'), 'src-backend-circuit-breaker')]", - "priority": 1, - "weight": 20 - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/backends', parameters('apimName'), 'src-backend-circuit-breaker')]", - "[resourceId('Microsoft.ApiManagement/service/backends', parameters('apimName'), 'src-backend-http')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/loggers", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-logger-appinsights')]", - "properties": { - "loggerType": "applicationInsights", - "description": "Application Insights logger for BVT", - "resourceId": "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]", - "credentials": { - "instrumentationKey": "[reference(resourceId('Microsoft.Insights/components', parameters('appInsightsName')), '2020-02-02').InstrumentationKey]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/loggers", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-logger-eventhub')]", - "properties": { - "loggerType": "azureEventHub", - "description": "Event Hub logger for BVT", - "credentials": { - "name": "src-apim-logs", - "connectionString": "[listKeys(resourceId('Microsoft.EventHub/namespaces/authorizationRules', parameters('eventHubNamespaceName'), 'src-apim-send'), '2024-01-01').primaryConnectionString]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.EventHub/namespaces/eventhubs', parameters('eventHubNamespaceName'), 'src-apim-logs')]", - "[resourceId('Microsoft.EventHub/namespaces/authorizationRules', parameters('eventHubNamespaceName'), 'src-apim-send')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/groups", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-group-internal')]", - "properties": { - "displayName": "Kitchen Sink Internal Group", - "description": "Custom group for BVT testing", - "type": "custom" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/policyFragments", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-fragment-cors')]", - "properties": { - "description": "CORS policy fragment", - "format": "rawxml", - "value": "[variables('corsFragmentXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/policyFragments", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-fragment-ratelimit')]", - "properties": { - "description": "Rate limit policy fragment", - "format": "rawxml", - "value": "[variables('rateLimitFragmentXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/schemas", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-schema-json')]", - "properties": { - "schemaType": "json", - "description": "Kitchen sink JSON schema", - "document": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "active", - "inactive" - ] - } - }, - "required": [ - "id", - "name" - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "condition": "[variables('isClassicSku')]", - "type": "Microsoft.ApiManagement/service/policyRestrictions", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-restriction-ip')]", - "properties": { - "scope": "All", - "requireBase": "true" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "condition": "[variables('isClassicSku')]", - "type": "Microsoft.ApiManagement/service/documentations", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-doc-getting-started')]", - "properties": { - "title": "Getting Started", - "content": "# Getting Started\n\nThis is the kitchen sink APIM instance for BVT testing.\n\n## Quick Start\n\nUse the APIOps CLI to extract and publish configurations." - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/diagnostics", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'applicationinsights')]", - "properties": { - "loggerId": "[resourceId('Microsoft.ApiManagement/service/loggers', parameters('apimName'), 'src-logger-appinsights')]", - "alwaysLog": "allErrors", - "sampling": { - "samplingType": "fixed", - "percentage": 100 - }, - "logClientIp": true - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/loggers', parameters('apimName'), 'src-logger-appinsights')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/policies", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'policy')]", - "properties": { - "format": "rawxml", - "value": "[variables('servicePolicyXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/policyFragments', parameters('apimName'), 'src-fragment-cors')]", - "[resourceId('Microsoft.ApiManagement/service/policyFragments', parameters('apimName'), 'src-fragment-ratelimit')]", - "[resourceId('Microsoft.ApiManagement/service/namedValues', parameters('apimName'), 'src-nv-plain')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-product-starter')]", - "properties": { - "displayName": "Kitchen Sink Starter", - "description": "Starter product for BVT — limited access", - "subscriptionRequired": true, - "approvalRequired": false, - "state": "published", - "terms": "By subscribing you agree to the terms of use." - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-product-premium')]", - "properties": { - "displayName": "Kitchen Sink Premium", - "description": "Premium product for BVT — full access", - "subscriptionRequired": true, - "approvalRequired": true, - "subscriptionsLimit": 10, - "state": "published" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-rest-openapi')]", - "properties": { - "displayName": "KS REST OpenAPI", - "description": "Kitchen sink REST API imported from OpenAPI spec", - "path": "ks/rest", - "protocols": [ - "https" - ], - "format": "openapi", - "value": "[variables('openApiSpec')]", - "serviceUrl": "https://src-backend.example.com/api", - "subscriptionRequired": false, - "apiType": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-soap-passthrough')]", - "properties": { - "displayName": "KS SOAP Pass-Through", - "description": "Kitchen sink SOAP pass-through API from WSDL", - "path": "ks/soap", - "protocols": [ - "https" - ], - "format": "wsdl", - "value": "[variables('wsdlSpec')]", - "serviceUrl": "https://src-soap-backend.example.com/calculator", - "apiType": "soap", - "wsdlSelector": { - "wsdlServiceName": "CalculatorService", - "wsdlEndpointName": "CalculatorSoapPort" - }, - "type": "soap" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-graphql-synthetic')]", - "properties": { - "displayName": "KS GraphQL Synthetic", - "description": "Kitchen sink synthetic GraphQL API with inline schema", - "path": "ks/graphql", - "protocols": [ - "https" - ], - "serviceUrl": "https://src-graphql-backend.example.com/graphql", - "type": "graphql", - "apiType": "graphql" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/schemas", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-graphql-synthetic', 'graphql')]", - "properties": { - "contentType": "application/vnd.ms-azure-apim.graphql.schema", - "document": { - "value": "[variables('graphqlSchema')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-graphql-synthetic')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-graphql-passthrough')]", - "properties": { - "displayName": "KS GraphQL Pass-Through", - "description": "Kitchen sink pass-through GraphQL API", - "path": "ks/graphql-pt", - "protocols": [ - "https" - ], - "serviceUrl": "https://src-graphql-pt-backend.example.com/graphql", - "type": "graphql", - "apiType": "graphql" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/schemas", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-graphql-passthrough', 'graphql')]", - "properties": { - "contentType": "application/vnd.ms-azure-apim.graphql.schema", - "document": { - "value": "[variables('graphqlSchema')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-graphql-passthrough')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-websocket')]", - "properties": { - "displayName": "KS WebSocket", - "description": "Kitchen sink WebSocket API", - "path": "ks/ws", - "protocols": [ - "wss" - ], - "serviceUrl": "wss://echo.websocket.events", - "type": "websocket", - "apiType": "websocket" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-rest-versioned-v1')]", - "properties": { - "displayName": "KS REST Versioned", - "description": "Kitchen sink versioned REST API (v1)", - "path": "ks/versioned", - "protocols": [ - "https" - ], - "format": "openapi", - "value": "[variables('openApiSpecV1')]", - "serviceUrl": "https://src-versioned-backend.example.com/api", - "apiVersion": "v1", - "apiVersionSetId": "[resourceId('Microsoft.ApiManagement/service/apiVersionSets', parameters('apimName'), 'src-versionset-urlpath')]", - "subscriptionRequired": false, - "apiType": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/apiVersionSets', parameters('apimName'), 'src-versionset-urlpath')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-rest-revisioned')]", - "properties": { - "displayName": "KS REST Revisioned", - "description": "Kitchen sink REST API with revisions", - "path": "ks/revisioned", - "protocols": [ - "https" - ], - "format": "openapi", - "value": "[variables('openApiSpec')]", - "serviceUrl": "https://src-revisioned-backend.example.com/api", - "subscriptionRequired": false, - "apiType": "http", - "isCurrent": true - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-rest-revisioned;rev=2')]", - "properties": { - "path": "ks/revisioned", - "protocols": [ - "https" - ], - "serviceUrl": "https://src-revisioned-backend-v2.example.com/api", - "apiRevisionDescription": "Second revision for BVT testing", - "sourceApiId": "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-revisioned')]", - "apiType": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-revisioned')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-mcp-from-api')]", - "properties": { - "displayName": "KS MCP from Existing API", - "description": "MCP server created by exposing an existing REST API in the instance as MCP tools", - "path": "ks/mcp-from-api", - "protocols": [ - "https" - ], - "subscriptionRequired": false, - "type": "mcp", - "mcpTools": [ - { - "name": "healthCheck", - "description": "Health check endpoint", - "operationId": "[resourceId('Microsoft.ApiManagement/service/apis/operations', parameters('apimName'), 'src-rest-openapi', 'healthCheck')]" - }, - { - "name": "listItems", - "description": "List all items", - "operationId": "[resourceId('Microsoft.ApiManagement/service/apis/operations', parameters('apimName'), 'src-rest-openapi', 'listItems')]" - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-backend-mcp-external')]", - "properties": { - "description": "External MCP server backend (GitHub Copilot)", - "url": "https://api.githubcopilot.com/mcp", - "protocol": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-mcp-from-external')]", - "properties": { - "displayName": "KS MCP from External Server", - "description": "MCP server repackaging a public external MCP server via APIM", - "path": "", - "protocols": [ - "https" - ], - "subscriptionRequired": false, - "type": "mcp", - "backendId": "src-backend-mcp-external", - "mcpProperties": { - "endpoints": { - "mcp": { - "uriTemplate": "/mcp" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/backends', parameters('apimName'), 'src-backend-mcp-external')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/policies", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-premium', 'policy')]", - "properties": { - "format": "rawxml", - "value": "[variables('productPolicyXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-premium')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-starter', 'src-rest-openapi')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-starter', 'src-soap-passthrough')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-soap-passthrough')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-premium', 'src-rest-openapi')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-premium')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-premium', 'src-graphql-synthetic')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-graphql-synthetic')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-premium')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/groups", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-starter', 'developers')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/groups", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-premium', 'src-group-internal')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/groups', parameters('apimName'), 'src-group-internal')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-premium')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/products/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-starter', 'src-tag-env')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]", - "[resourceId('Microsoft.ApiManagement/service/tags', parameters('apimName'), 'src-tag-env')]" - ] - }, - { - "condition": "[variables('isClassicSku')]", - "type": "Microsoft.ApiManagement/service/products/wikis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-product-starter', 'default')]", - "properties": { - "documents": [ - { - "documentationId": "src-doc-getting-started" - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/documentations', parameters('apimName'), 'src-doc-getting-started')]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/policies", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'policy')]", - "properties": { - "format": "rawxml", - "value": "[variables('apiPolicyXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'src-tag-env')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/tags', parameters('apimName'), 'src-tag-env')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'src-tag-team')]", - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/tags', parameters('apimName'), 'src-tag-team')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/diagnostics", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'applicationinsights')]", - "properties": { - "loggerId": "[resourceId('Microsoft.ApiManagement/service/loggers', parameters('apimName'), 'src-logger-appinsights')]", - "alwaysLog": "allErrors", - "sampling": { - "samplingType": "fixed", - "percentage": 50 - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/loggers', parameters('apimName'), 'src-logger-appinsights')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/operations/policies", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/src-rest-openapi/healthCheck/policy', parameters('apimName'))]", - "properties": { - "format": "rawxml", - "value": "[variables('operationPolicyXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/schemas", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'src-rest-schema-item')]", - "properties": { - "contentType": "application/vnd.oai.openapi.components+json", - "document": { - "components": { - "schemas": { - "Item": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } - } - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/tagDescriptions", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'src-tag-env')]", - "properties": { - "description": "Environment tag — indicates deployment environment", - "externalDocsDescription": "Environment tagging guide", - "externalDocsUrl": "https://docs.contoso.com/tags/env" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/apis/tags', parameters('apimName'), 'src-rest-openapi', 'src-tag-env')]", - "[resourceId('Microsoft.ApiManagement/service/tags', parameters('apimName'), 'src-tag-env')]" - ] - }, - { - "condition": "[variables('isClassicSku')]", - "type": "Microsoft.ApiManagement/service/apis/wikis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-openapi', 'default')]", - "properties": { - "documents": [ - { - "documentationId": "src-doc-getting-started" - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/documentations', parameters('apimName'), 'src-doc-getting-started')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/releases", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-rest-revisioned', 'src-release-1')]", - "properties": { - "apiId": "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-revisioned')]", - "notes": "Initial release for BVT testing" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-revisioned')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/resolvers", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-graphql-synthetic', 'src-resolver-hero')]", - "properties": { - "displayName": "Hero Resolver", - "description": "Resolves hero query via HTTP data source", - "path": "Query/hero" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-graphql-synthetic')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/apis/resolvers/policies", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}/{3}', parameters('apimName'), 'src-graphql-synthetic', 'src-resolver-hero', 'policy')]", - "properties": { - "format": "rawxml", - "value": "[variables('resolverPolicyXml')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis/resolvers', parameters('apimName'), 'src-graphql-synthetic', 'src-resolver-hero')]" - ] - }, - { - "condition": "[variables('supportsSelfHostedGateway')]", - "type": "Microsoft.ApiManagement/service/gateways/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-gateway-onprem', 'src-rest-openapi')]", - "properties": { - "provisioningState": "created" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/apis', parameters('apimName'), 'src-rest-openapi')]", - "[resourceId('Microsoft.ApiManagement/service/gateways', parameters('apimName'), 'src-gateway-onprem')]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/subscriptions", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-sub-all-apis')]", - "properties": { - "displayName": "Kitchen Sink All APIs Subscription", - "scope": "/apis", - "state": "active" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "type": "Microsoft.ApiManagement/service/subscriptions", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-sub-product')]", - "properties": { - "displayName": "Kitchen Sink Product Subscription", - "scope": "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]", - "state": "active" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]", - "[resourceId('Microsoft.ApiManagement/service/products', parameters('apimName'), 'src-product-starter')]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}', parameters('apimName'), 'src-workspace')]", - "properties": { - "displayName": "Kitchen Sink Workspace", - "description": "Workspace for BVT testing workspace-scoped extraction" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service', parameters('apimName'))]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces/backends", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-workspace', 'src-ws-backend-http')]", - "properties": { - "description": "Workspace-scoped HTTP backend", - "url": "https://src-ws-backend.example.com/api", - "protocol": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/workspaces', parameters('apimName'), 'src-workspace')]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces/namedValues", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-workspace', 'src-ws-nv-plain')]", - "properties": { - "displayName": "src-ws-nv-plain", - "value": "workspace-scoped-value", - "tags": [ - "workspace" - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/workspaces', parameters('apimName'), 'src-workspace')]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces/tags", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-workspace', 'src-ws-tag')]", - "properties": { - "displayName": "src-ws-tag" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/workspaces', parameters('apimName'), 'src-workspace')]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces/products", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-workspace', 'src-ws-product')]", - "properties": { - "displayName": "Workspace Product", - "description": "Product scoped to workspace", - "subscriptionRequired": false, - "state": "published" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/workspaces', parameters('apimName'), 'src-workspace')]" - ] - }, - { - "condition": "[variables('supportsWorkspaces')]", - "type": "Microsoft.ApiManagement/service/workspaces/apis", - "apiVersion": "2025-09-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('apimName'), 'src-workspace', 'src-ws-api-rest')]", - "properties": { - "displayName": "Workspace REST API", - "path": "ks/ws/rest", - "protocols": [ - "https" - ], - "serviceUrl": "https://src-ws-api-backend.example.com/api", - "apiType": "http" - }, - "dependsOn": [ - "[resourceId('Microsoft.ApiManagement/service/workspaces', parameters('apimName'), 'src-workspace')]" - ] - } - ], - "outputs": { - "apimServiceName": { - "type": "string", - "metadata": { - "description": "APIM service name — use as --service-name for APIOps CLI" - }, - "value": "[parameters('apimName')]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "Resource group name — use as --resource-group for APIOps CLI" - }, - "value": "[resourceGroup().name]" - }, - "subscriptionId": { - "type": "string", - "metadata": { - "description": "Azure subscription ID — use as --subscription-id for APIOps CLI" - }, - "value": "[subscription().subscriptionId]" - }, - "gatewayUrl": { - "type": "string", - "metadata": { - "description": "APIM gateway URL" - }, - "value": "[reference(resourceId('Microsoft.ApiManagement/service', parameters('apimName')), '2025-09-01-preview').gatewayUrl]" - }, - "workspaceDeployed": { - "type": "bool", - "metadata": { - "description": "Whether workspace resources were deployed" - }, - "value": "[variables('supportsWorkspaces')]" - }, - "gatewayDeployed": { - "type": "bool", - "metadata": { - "description": "Whether self-hosted gateway was deployed" - }, - "value": "[variables('supportsSelfHostedGateway')]" - }, - "skuName": { - "type": "string", - "metadata": { - "description": "APIM SKU used" - }, - "value": "[parameters('skuName')]" - } - } -} \ No newline at end of file diff --git a/tests/integration/all-resource-types/target-apim.bicep b/tests/integration/all-resource-types/target-apim.bicep index cc3dc03..97b5da5 100644 --- a/tests/integration/all-resource-types/target-apim.bicep +++ b/tests/integration/all-resource-types/target-apim.bicep @@ -117,7 +117,8 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { name: 'standard' } tenantId: subscription().tenantId - enableRbacAuthorization: true + enableRbacAuthorization: false + accessPolicies: [] enableSoftDelete: true softDeleteRetentionInDays: 7 } @@ -151,14 +152,19 @@ resource apim 'Microsoft.ApiManagement/service@2025-09-01-preview' = { } } -// Grant APIM identity Key Vault Secrets User role for NamedValue KV ref -resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, apim.id, '4633458b-17de-408a-b874-0445c86b69e6') - scope: keyVault +resource kvAccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = { + name: 'add' + parent: keyVault properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') - principalId: apim.identity.principalId - principalType: 'ServicePrincipal' + accessPolicies: [ + { + tenantId: subscription().tenantId + objectId: apim.identity.principalId + permissions: { + secrets: ['get'] + } + } + ] } } diff --git a/tests/unit/lib/dependency-graph.test.ts b/tests/unit/lib/dependency-graph.test.ts index e7938c3..d58ec1a 100644 --- a/tests/unit/lib/dependency-graph.test.ts +++ b/tests/unit/lib/dependency-graph.test.ts @@ -17,13 +17,13 @@ import { ResourceType } from '../../../src/models/resource-types.js'; describe('dependency-graph', () => { describe('tier constants', () => { - it('should have 34 total resources across all tiers', () => { + it('should include all resource types across tiers', () => { const total = TIER_1_RESOURCES.length + TIER_2_RESOURCES.length + TIER_3_RESOURCES.length + TIER_4_RESOURCES.length; - expect(total).toBe(34); + expect(total).toBe(Object.values(ResourceType).length); }); it('should not have duplicate resources across tiers', () => { @@ -38,6 +38,7 @@ describe('dependency-graph', () => { }); it('should place independent resources in tier 1', () => { + expect(TIER_1_RESOURCES).toContain(ResourceType.Workspace); expect(TIER_1_RESOURCES).toContain(ResourceType.NamedValue); expect(TIER_1_RESOURCES).toContain(ResourceType.Tag); expect(TIER_1_RESOURCES).toContain(ResourceType.Backend); @@ -55,6 +56,7 @@ describe('dependency-graph', () => { expect(TIER_3_RESOURCES).toContain(ResourceType.ApiPolicy); expect(TIER_3_RESOURCES).toContain(ResourceType.ProductApi); expect(TIER_3_RESOURCES).toContain(ResourceType.ApiOperation); + expect(TIER_3_RESOURCES).toContain(ResourceType.PolicyRestriction); }); it('should place grandchild resources in tier 4', () => { @@ -64,9 +66,9 @@ describe('dependency-graph', () => { }); describe('getTopologicalOrder', () => { - it('should return all 34 resource types', () => { + it('should return all resource types', () => { const order = getTopologicalOrder(); - expect(order).toHaveLength(34); + expect(order).toHaveLength(Object.values(ResourceType).length); }); it('should return tier-1 resources before tier-2', () => { diff --git a/tests/unit/lib/resource-path.test.ts b/tests/unit/lib/resource-path.test.ts index 7963b4c..0dca029 100644 --- a/tests/unit/lib/resource-path.test.ts +++ b/tests/unit/lib/resource-path.test.ts @@ -285,6 +285,20 @@ describe('parseArtifactPath', () => { expect(result!.nameParts[0]).toBe('starter'); }); + it('should parse GatewayApi association file so gateway API links can be published', () => { + const filePath = path.join(baseDir, 'gateways', 'gw1', 'apis.json'); + const result = parseArtifactPath(baseDir, filePath); + expect(result).toBeDefined(); + expect(result!.type).toBe(ResourceType.GatewayApi); + expect(result!.nameParts).toEqual(['gw1']); + }); + + it('should ignore ProductApi association files because product publisher handles them', () => { + const filePath = path.join(baseDir, 'products', 'starter', 'apis.json'); + const result = parseArtifactPath(baseDir, filePath); + expect(result).toBeUndefined(); + }); + it('should parse workspace-scoped resource', () => { const filePath = path.join(baseDir, 'workspaces', 'ws1', 'apis', 'ws-api', 'apiInformation.json'); const result = parseArtifactPath(baseDir, filePath); @@ -293,6 +307,15 @@ describe('parseArtifactPath', () => { expect(result!.nameParts[0]).toBe('ws-api'); }); + it('should parse workspace container descriptor', () => { + const filePath = path.join(baseDir, 'workspaces', 'ws1', 'workspaceInformation.json'); + const result = parseArtifactPath(baseDir, filePath); + expect(result).toBeDefined(); + expect(result!.type).toBe(ResourceType.Workspace); + expect(result!.nameParts).toEqual(['ws1']); + expect(result!.workspace).toBeUndefined(); + }); + it('should parse ApiDiagnostic info file (nameParts[0]=apiName, nameParts[1]=diagName)', () => { const filePath = path.join(baseDir, 'apis', 'my-api', 'diagnostics', 'applicationinsights', 'diagnosticInformation.json'); const result = parseArtifactPath(baseDir, filePath); diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 7753f02..d2bb7f2 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -207,6 +207,107 @@ describe('api-publisher', () => { expect(calls[0][3].nameParts[0]).toBe('orders-api;rev=1'); expect(calls[1][3].nameParts[0]).toBe('orders-api;rev=2'); expect(calls[2][3].nameParts[0]).toBe('orders-api;rev=3'); + + // Root API is only replayed when source marks it current (isCurrent=true). + // Default mock root payload has no isCurrent flag, so only the initial PUT runs. + expect(client.putResource).toHaveBeenCalledTimes(1); + }); + + it('should replay root API without re-importing specification after revisions', async () => { + const client = createMockClient(); + const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; + const store = createMockStore(revisions); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { path: 'orders', isCurrent: true }, + }); + store.readContent.mockResolvedValue({ + content: 'openapi: "3.0.0"', + format: 'yaml', + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + // Spec is only read/injected on the first root publish. + expect(store.readContent).toHaveBeenCalledTimes(1); + expect(client.putResource).toHaveBeenCalledTimes(2); + + const firstPayload = client.putResource.mock.calls[0][2] as Record; + const secondPayload = client.putResource.mock.calls[1][2] as Record; + const firstProps = firstPayload.properties as Record; + const secondProps = secondPayload.properties as Record; + + expect(firstProps).toHaveProperty('format', 'openapi'); + expect(firstProps).toHaveProperty('value', 'openapi: "3.0.0"'); + expect(secondProps).not.toHaveProperty('format'); + expect(secondProps).not.toHaveProperty('value'); + }); + + it('should not replay root API when source root is not current', async () => { + const client = createMockClient(); + const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; + const store = createMockStore(revisions); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + isCurrent: false, + apiRevision: '2', + serviceUrl: 'https://src-revisioned-backend-v2.example.com/api', + }, + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + // Root is not current in source, so no root alignment replay is performed. + expect(client.putResource).toHaveBeenCalledTimes(1); + const alignedPayload = client.putResource.mock.calls[0][2] as Record; + const alignedProps = alignedPayload.properties as Record; + + expect(alignedProps).toHaveProperty('apiRevision', '2'); + expect(alignedProps).toHaveProperty('serviceUrl', 'https://src-revisioned-backend-v2.example.com/api'); + expect(alignedProps).not.toHaveProperty('format'); + expect(alignedProps).not.toHaveProperty('value'); + }); + + it('should align active revision from source when active revision is 1', async () => { + const client = createMockClient(); + const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; + const store = createMockStore(revisions); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + isCurrent: true, + apiRevision: '1', + serviceUrl: 'https://src-revisioned-backend.example.com/api', + }, + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + // Second root PUT is the explicit active-revision alignment pass. + expect(client.putResource).toHaveBeenCalledTimes(2); + const alignedPayload = client.putResource.mock.calls[1][2] as Record; + const alignedProps = alignedPayload.properties as Record; + + expect(alignedProps).toHaveProperty('apiRevision', '1'); + expect(alignedProps).toHaveProperty('serviceUrl', 'https://src-revisioned-backend.example.com/api'); + expect(alignedProps).not.toHaveProperty('format'); + expect(alignedProps).not.toHaveProperty('value'); }); it('should skip non-matching revisions when filtering by API name', async () => { diff --git a/tests/unit/services/product-publisher.test.ts b/tests/unit/services/product-publisher.test.ts index bf1b7d8..241c9df 100644 --- a/tests/unit/services/product-publisher.test.ts +++ b/tests/unit/services/product-publisher.test.ts @@ -22,9 +22,10 @@ function createMockClient() { listResources: async function* () {}, getResource: vi.fn(), putResource: vi.fn().mockResolvedValue(undefined), - deleteResource: vi.fn(), + deleteResource: vi.fn().mockResolvedValue(true), listApiRevisions: async function* () {}, getApiSpecification: vi.fn(), + validatePreFlight: vi.fn(), }; } @@ -62,6 +63,10 @@ const productDescriptor: ResourceDescriptor = { nameParts: ['my-product'], }; +function generatedSubscriptionId(fill: string): string { + return fill.repeat(24); +} + describe('product-publisher', () => { describe('publishProduct', () => { beforeEach(() => { @@ -277,5 +282,90 @@ describe('product-publisher', () => { expect(result.error).toBeInstanceOf(Error); expect(result.error?.message).toBe('Unexpected store error'); }); + + it('does not delete auto-generated product subscriptions after product publish', async () => { + const client = createMockClient(); + const store = createMockStore(); + const autoGeneratedId = generatedSubscriptionId('c'); + client.getResource.mockResolvedValue(undefined); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue(undefined); + + client.listResources = async function* () { + yield { + id: `${testContext.baseUrl}/subscriptions/${autoGeneratedId}`, + name: autoGeneratedId, + properties: { + scope: `${testContext.baseUrl}/products/my-product`, + displayName: null, + }, + }; + }; + + const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.deleteResource).not.toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.Subscription, + nameParts: [autoGeneratedId], + }) + ); + }); + + it('does not delete product-scoped subscriptions on first product creation', async () => { + const client = createMockClient(); + const store = createMockStore(); + client.getResource.mockResolvedValue(undefined); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue(undefined); + + client.listResources = async function* () { + yield { + id: `${testContext.baseUrl}/subscriptions/src-sub-product`, + name: 'src-sub-product', + properties: { + scope: `${testContext.baseUrl}/products/my-product`, + displayName: 'Kitchen Sink Product Subscription', + }, + }; + }; + + const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.deleteResource).not.toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.Subscription, + nameParts: ['src-sub-product'], + }) + ); + }); + + it('does not run cleanup when product already exists', async () => { + const client = createMockClient(); + const store = createMockStore(); + client.getResource.mockResolvedValue({ name: 'my-product' }); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue(undefined); + + client.listResources = async function* () { + yield { + id: `${testContext.baseUrl}/subscriptions/${generatedSubscriptionId('d')}`, + name: generatedSubscriptionId('d'), + properties: { + scope: `${testContext.baseUrl}/products/my-product`, + displayName: null, + }, + }; + }; + + const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 324623c..fe1ec6f 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -207,6 +207,46 @@ describe('publish-service', () => { expect(publishApi).toHaveBeenCalled(); }); + it('should publish regular APIs before MCP APIs in tier 2', async () => { + const resources: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['src-rest-openapi'] }, + { type: ResourceType.Api, nameParts: ['src-mcp-from-api'] }, + ]; + + const client = createMockClient(); + const store = createMockStore(resources); + + store.readResource.mockImplementation(async (_sourceDir: string, descriptor: ResourceDescriptor) => { + const name = descriptor.nameParts[descriptor.nameParts.length - 1] ?? ''; + if (name === 'src-mcp-from-api') { + return { name, properties: { mcpTools: [{ operationId: '/apis/src-rest-openapi/operations/get' }] } }; + } + return { name, properties: {} }; + }); + + const apiCallOrder: string[] = []; + vi.mocked(publishApi).mockImplementation(async (_client, _store, _context, descriptor) => { + apiCallOrder.push(descriptor.nameParts[0] ?? ''); + return { + descriptor, + status: 'success', + action: 'put', + }; + }); + + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }; + + await runPublish(client, store, config); + + expect(apiCallOrder).toEqual(['src-rest-openapi', 'src-mcp-from-api']); + }); + it('should call generateDryRunReport in dry-run mode', async () => { const resources = [ { type: ResourceType.Tag, nameParts: ['tag1'] }, diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index b899c38..595c0d6 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -64,6 +64,10 @@ const testConfig: PublishConfig = { logLevel: LogLevel.INFO, }; +function generatedSubscriptionId(fill: string): string { + return fill.repeat(24); +} + describe('resource-publisher', () => { describe('publishResource', () => { beforeEach(() => { @@ -641,6 +645,96 @@ describe('resource-publisher', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + it('should skip auto-generated product subscription with empty displayName', async () => { + const client = createMockClient(); + const store = createMockStore(); + const autoGeneratedId = generatedSubscriptionId('a'); + + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + + const subscriptionJson = { + name: autoGeneratedId, + properties: { + ownerId: `${armScopePrefix}/users/1`, + scope: `${armScopePrefix}/products/starter`, + displayName: null, + state: 'active', + }, + }; + store.readResource.mockResolvedValue(subscriptionJson); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: [autoGeneratedId], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('skipped'); + expect(result.action).toBe('noop'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should skip auto-generated product subscription when displayName is set', async () => { + const client = createMockClient(); + const store = createMockStore(); + const autoGeneratedId = generatedSubscriptionId('b'); + + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + + const subscriptionJson = { + name: autoGeneratedId, + properties: { + scope: `${armScopePrefix}/products/starter`, + displayName: 'Starter access', + state: 'active', + }, + }; + store.readResource.mockResolvedValue(subscriptionJson); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: [autoGeneratedId], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('skipped'); + expect(result.action).toBe('noop'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should publish user-defined product subscription', async () => { + const client = createMockClient(); + const store = createMockStore(); + + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + + const subscriptionJson = { + name: 'team-a-product-sub', + properties: { + scope: `${armScopePrefix}/products/starter`, + displayName: 'Team A starter product', + state: 'active', + }, + }; + store.readResource.mockResolvedValue(subscriptionJson); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['team-a-product-sub'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('success'); + expect(result.action).toBe('put'); + expect(client.putResource).toHaveBeenCalledTimes(1); + }); + describe('API revision handling', () => { it('injects sourceApiId for revision APIs', async () => { const client = createMockClient(); @@ -731,6 +825,48 @@ describe('resource-publisher', () => { `/subscriptions/${testContext.subscriptionId}/resourceGroups/${testContext.resourceGroup}/providers/Microsoft.ApiManagement/service/${testContext.serviceName}/apis/orders-api`; expect(props.sourceApiId).toBe(expectedSourceApiId); }); + + it('defaults revision isCurrent to false when missing', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'orders-api;rev=2', + properties: { path: '/orders' }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const putCall = client.putResource.mock.calls[0]; + const putJson = putCall[2] as Record; + const props = putJson.properties as Record; + expect(props).toHaveProperty('isCurrent', false); + }); + + it('preserves revision isCurrent when explicitly provided', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'orders-api;rev=2', + properties: { path: '/orders', isCurrent: true }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const putCall = client.putResource.mock.calls[0]; + const putJson = putCall[2] as Record; + const props = putJson.properties as Record; + expect(props).toHaveProperty('isCurrent', true); + }); }); }); From fe996f4be16c056959481d5b0edb3d41d4da1d0c Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 22 May 2026 07:09:53 +0000 Subject: [PATCH 2/3] fixing failing tests --- tests/unit/models/resource-types.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/models/resource-types.test.ts b/tests/unit/models/resource-types.test.ts index 8b4d43f..9626644 100644 --- a/tests/unit/models/resource-types.test.ts +++ b/tests/unit/models/resource-types.test.ts @@ -4,9 +4,9 @@ import { describe, it, expect } from 'vitest'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../../../src/models/resource-types.js'; describe('ResourceType enum', () => { - it('should have exactly 34 resource types', () => { + it('should have exactly 35 resource types', () => { const values = Object.values(ResourceType); - expect(values).toHaveLength(34); + expect(values).toHaveLength(35); }); it('should have unique enum values', () => { @@ -17,10 +17,10 @@ describe('ResourceType enum', () => { }); describe('RESOURCE_TYPE_METADATA', () => { - it('should have metadata for all 34 resource types', () => { + it('should have metadata for all 35 resource types', () => { const metadataKeys = Object.keys(RESOURCE_TYPE_METADATA); const enumValues = Object.values(ResourceType); - expect(metadataKeys).toHaveLength(34); + expect(metadataKeys).toHaveLength(35); for (const val of enumValues) { expect(RESOURCE_TYPE_METADATA[val]).toBeDefined(); } From dbcb8f7ec48cb4178467ac4d2c4620b0b3a4b827 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Fri, 22 May 2026 07:25:01 +0000 Subject: [PATCH 3/3] removing unused json --- .../expected-structure.json | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/tests/integration/all-resource-types/expected-structure.json b/tests/integration/all-resource-types/expected-structure.json index bcb8328..5a323f7 100644 --- a/tests/integration/all-resource-types/expected-structure.json +++ b/tests/integration/all-resource-types/expected-structure.json @@ -714,37 +714,5 @@ } } ] - }, - "validationRules": { - "globalRules": [ - "All JSON files must be valid JSON", - "All policy.xml files must be valid XML", - "All specification files must be valid for their format (YAML/JSON/GraphQL/WSDL/WADL)", - "File names must match resource type conventions from resource-types.ts", - "Directory structure must match artifactDirectory patterns from resource-types.ts" - ], - "secretRedactionRules": [ - "NamedValue with secret=true: properties.value must be null or '[REDACTED]'", - "Logger credentials: instrumentationKey and connectionString must be redacted", - "Subscription keys: primaryKey and secondaryKey must be redacted" - ], - "protocolValidation": [ - "REST APIs: protocols array should include 'https' or 'http'", - "SOAP APIs: type should be 'soap'", - "GraphQL APIs: type should be 'graphql'", - "WebSocket APIs: protocols should include 'wss' or 'ws', type should be 'websocket'" - ] - }, - "notes": [ - "This manifest is generated from all-resources.bicep and extraction service code", - "Actual counts may vary by SKU (Developer, Premium, StandardV2, PremiumV2)", - "Some resources (workspaces, self-hosted gateways) are SKU-dependent", - "Revision naming follows pattern: {apiName};rev={N}", - "Operations may not have individual info files (ApiOperation has infoFile: null)", - "ProductTag is embedded in productInformation.json, not separate files", - "ServicePolicy is stored at root as policy.xml, not in a policies/ directory", - "Wiki resources use wiki.md filename, not wikiInformation.json", - "Association files (apis.json, groups.json) contain arrays of {name: string} objects", - "Specification formats: yaml (default), json, graphql, wsdl, wadl" - ] + } }