diff --git a/package-lock.json b/package-lock.json index 336113a3..d290f280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1694,16 +1694,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer-equal-constant-time": { @@ -2328,9 +2328,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -2771,9 +2771,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2903,9 +2903,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2923,7 +2923,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 09f4d6bb..5d9eaf36 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -57,6 +57,10 @@ export class ApimClient implements IApimClient { private static readonly ASYNC_POLL_TIMEOUT_MS = 7.5 * 60 * 1000; /** Default interval between async operation polls when no Retry-After header. */ private static readonly ASYNC_POLL_INTERVAL_MS = 5000; + /** Max DELETE attempts when APIM reports an optimistic-concurrency conflict. */ + private static readonly DELETE_CONFLICT_RETRIES = 3; + /** Base delay between DELETE conflict retries (multiplied by attempt number). */ + private static readonly DELETE_CONFLICT_RETRY_DELAY_MS = 2000; /** Known ARM management plane host suffixes for URL validation. */ private static readonly ARM_HOSTS = [ 'management.azure.com', @@ -451,32 +455,49 @@ export class ApimClient implements IApimClient { descriptor: ResourceDescriptor ): Promise { const url = buildArmUri(context, descriptor); - - try { - const response = await this.request(url, { method: 'DELETE' }); - - if (response.status === 404) { - return false; // Already deleted - } - // Poll for long-running operations - if (response.status === 202) { - const asyncUrl = this.extractAsyncOperationUrl(response); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true }); - } else { - await this.pollProvisioningState(context, descriptor, { - treatMissingAsSuccess: true, - }); + for (let attempt = 1; ; attempt++) { + try { + const response = await this.request(url, { method: 'DELETE' }); + + if (response.status === 404) { + return false; // Already deleted } - } - return true; - } catch (error) { - if ((error as Error).message.includes('404')) { - return false; + // Poll for long-running operations + if (response.status === 202) { + const asyncUrl = this.extractAsyncOperationUrl(response); + if (asyncUrl) { + await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true }); + } else { + await this.pollProvisioningState(context, descriptor, { + treatMissingAsSuccess: true, + }); + } + } + + return true; + } catch (error) { + const message = (error as Error).message; + if (message.includes('404')) { + return false; + } + // Transient optimistic-concurrency conflict: cascade deletes of related + // resources (subscriptions, product/gateway associations) can modify + // the resource while its async DELETE is in flight. Retry the DELETE. + const isConflict = + message.includes('[PreconditionFailed]') || + (error instanceof HttpError && error.status === 412); + if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) { + logger.warn( + `Delete conflict for ${buildResourceLabel(descriptor)} ` + + `(attempt ${attempt}/${ApimClient.DELETE_CONFLICT_RETRIES}), retrying...` + ); + await this.delay(ApimClient.DELETE_CONFLICT_RETRY_DELAY_MS * attempt); + continue; + } + throw error; } - throw error; } } diff --git a/src/lib/wsdl-normalizer.ts b/src/lib/wsdl-normalizer.ts new file mode 100644 index 00000000..1c242467 --- /dev/null +++ b/src/lib/wsdl-normalizer.ts @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * WSDL normalizer + * + * APIM's WSDL export regenerates the document from its internal API model and + * has a known defect: `wsdl:part element="..."` references are qualified with + * the WSDL targetNamespace prefix (`tns`) even when the element is declared in + * a different inline schema namespace. APIM's own importer then rejects the + * document with "Could not resolve type '{ns}Element'", breaking the + * extract → publish round trip for multi-namespace WSDLs. + * + * This module rewrites such unresolvable part references to the prefix of the + * inline schema that actually declares the element. Only references that are + * (a) unresolvable as-is and (b) declared in exactly one inline schema are + * rewritten; everything else is left untouched. + */ + +import { logger } from './logger.js'; + +const NAME = String.raw`[\w.-]+`; + +/** + * Apply all WSDL export-defect normalizations needed for the extract → + * publish round trip. + */ +export function normalizeWsdl(wsdl: string): string { + return normalizeWsdlServicePorts(normalizeWsdlPartReferences(wsdl)); +} + +/** + * Normalize `wsdl:part` element references so they resolve against the inline + * schemas of the document. Returns the input unchanged when no fix is needed. + */ +export function normalizeWsdlPartReferences(wsdl: string): string { + const rootMatch = new RegExp(`<(?:${NAME}:)?definitions\\b[^>]*>`).exec(wsdl); + if (!rootMatch) { + return wsdl; + } + + const prefixToNs = parseXmlnsDeclarations(rootMatch[0]); + const globalElements = collectGlobalSchemaElements(wsdl); + if (globalElements.size === 0) { + return wsdl; + } + + // First declared prefix wins for each namespace + const nsToPrefix = new Map(); + for (const [prefix, ns] of prefixToNs) { + if (!nsToPrefix.has(ns)) { + nsToPrefix.set(ns, prefix); + } + } + + const newDeclarations: string[] = []; + let generatedPrefixCounter = 0; + + const partRef = new RegExp( + `(<(?:${NAME}:)?part\\b[^>]*?\\belement=")(?:(${NAME}):)?(${NAME})(")`, + 'g' + ); + + const rewritten = wsdl.replace( + partRef, + (full, before: string, prefix: string | undefined, localName: string, after: string) => { + // Only fix prefixed references resolvable at the root — anything else + // (local xmlns declarations, unprefixed refs) is left untouched. + if (!prefix) { + return full; + } + const referencedNs = prefixToNs.get(prefix); + if (!referencedNs) { + return full; + } + + const declaredIn = globalElements.get(localName); + // Skip unknown or ambiguous elements (declared in several schemas) + if (!declaredIn || declaredIn.size !== 1) { + return full; + } + const actualNs = [...declaredIn][0]; + if (actualNs === undefined || referencedNs === actualNs) { + return full; // already resolvable + } + + let fixedPrefix = nsToPrefix.get(actualNs); + if (!fixedPrefix) { + fixedPrefix = `apiopsns${generatedPrefixCounter++}`; + nsToPrefix.set(actualNs, fixedPrefix); + newDeclarations.push(`xmlns:${fixedPrefix}="${actualNs}"`); + } + + logger.debug( + `WSDL normalizer: rewriting wsdl:part reference ${prefix}:${localName} → ` + + `${fixedPrefix}:${localName} (element is declared in "${actualNs}")` + ); + return `${before}${fixedPrefix}:${localName}${after}`; + } + ); + + if (rewritten === wsdl) { + return wsdl; + } + + if (newDeclarations.length > 0) { + const rootTag = rootMatch[0]; + const patchedRoot = `${rootTag.slice(0, -1)} ${newDeclarations.join(' ')}>`; + return rewritten.replace(rootTag, patchedRoot); + } + + return rewritten; +} + +/** + * APIM's WSDL export emits one `wsdl:port` per configured proxy hostname, but + * its importer only accepts a single service endpoint ("Multiple service + * endpoints available, only one can be imported at a time"). Keep the first + * port of each `wsdl:service` and drop the rest. + */ +export function normalizeWsdlServicePorts(wsdl: string): string { + const serviceRe = new RegExp( + `<(${NAME}:)?service\\b[^>]*>[\\s\\S]*?`, + 'g' + ); + + return wsdl.replace(serviceRe, (serviceBlock) => { + const portRe = new RegExp( + `\\s*<(${NAME}:)?port\\b[^>]*(?:/>|>[\\s\\S]*?)`, + 'g' + ); + const ports = serviceBlock.match(portRe); + if (!ports || ports.length <= 1) { + return serviceBlock; + } + + logger.debug( + `WSDL normalizer: keeping first of ${ports.length} wsdl:port endpoints` + ); + let first = true; + return serviceBlock.replace(portRe, (port) => { + if (first) { + first = false; + return port; + } + return ''; + }); + }); +} + +/** Parse `xmlns:prefix="ns"` declarations from a single tag string. */ +function parseXmlnsDeclarations(tag: string): Map { const map = new Map(); + for (const m of tag.matchAll(new RegExp(`xmlns:(${NAME})="([^"]*)"`, 'g'))) { + map.set(m[1], m[2]); + } + return map; +} + +/** + * Collect global (top-level) `xs:element` declarations from every inline + * schema, keyed by element name → set of schema targetNamespaces. + * Uses a depth-tracking tag scanner so nested local elements are ignored. + */ +function collectGlobalSchemaElements(wsdl: string): Map> { + const map = new Map>(); + const tagRe = /|<[^>]+>/g; + + let schemaNs: string | undefined; + let depth = 0; // open-ancestor count relative to the current schema + + for (const m of wsdl.matchAll(tagRe)) { + const raw = m[0]; + if (raw.startsWith('