From 3c68b53ef28d4f9d90793dcb067ed5e36b00386d Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 15:31:24 +0400 Subject: [PATCH 1/4] fix: skip missing API/group links in gateway/product associations instead of aborting --- src/services/resource-publisher.ts | 16 ++++++++-- .../unit/services/resource-publisher.test.ts | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 23dc51a..b6ab8f5 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -527,9 +527,21 @@ async function publishAssociation( await client.putResource(context, assocDescriptor, {}); } catch (error) { // 409 means the link already exists — desired state is in place. - if (!isLinkAlreadyExistsError(error)) { - throw error; + if (isLinkAlreadyExistsError(error)) { + continue; } + // The referenced API/group is absent on the target (filtered out or + // failed to publish). Skip this single link with a warning instead of + // aborting the whole association, so other present entries still link. + const message = error instanceof Error ? error.message : String(error); + if (message.includes('API not found') || message.includes('Group not found')) { + logger.warn( + `Skipping ${associationType} association '${entry.name}' on ` + + `'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target` + ); + continue; + } + throw error; } } diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 0431a11..1584499 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -354,6 +354,36 @@ describe('resource-publisher', () => { ); }); + it('skips a GatewayApi link when the referenced API is not on the target and keeps going', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'missing-api' }, { name: 'present-api' }]); + client.putResource = vi.fn().mockImplementation(async (_ctx: unknown, d: ResourceDescriptor) => { + if (d.nameParts[1] === 'missing-api') { + throw new Error( + 'HTTP 400: {"error":{"code":"ValidationError","details":[{"target":"aid","message":"API not found"}]}}' + ); + } + return {}; + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + // A missing API must not abort the whole association. + expect(result.status).toBe('success'); + // The present API is still linked. + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.GatewayApi, nameParts: ['my-gateway', 'present-api'] }), + {} + ); + }); + it('should strip properties.value from KeyVault-backed NamedValue PUT payload', async () => { const client = createMockClient(); const store = createMockStore(); From 065d9be3bd6868da8cae3dcab8c54495d5dc5e89 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 19:07:13 +0400 Subject: [PATCH 2/4] feat(gateway): reconcile managed gateway API assignments The built-in 'managed' gateway is not returned by GET /gateways, so its per-API assignments were never extracted, published, or reconciled. This left dev/prod divergence (e.g. an API removed from managed in dev remained assigned in prod). - extract: query gateways/managed/apis and write gateways/managed/apis.json (only when non-empty) - publish: skip PUT of the built-in managed Gateway resource (its GatewayApi associations still publish) - delete-unmatched: reconcile GatewayApi assignments (incl. managed), scoped to gateways that own a local assignment, so untracked gateways are never touched - add MANAGED_GATEWAY_NAME constant; tests for extract/publish/delete-unmatched --- src/models/resource-types.ts | 7 ++ src/services/delete-unmatched-service.ts | 102 ++++++++++++++++++ src/services/extract-service.ts | 30 +++++- src/services/resource-publisher.ts | 9 +- .../services/delete-unmatched-service.test.ts | 37 +++++++ tests/unit/services/extract-service.test.ts | 50 +++++++++ .../unit/services/resource-publisher.test.ts | 18 ++++ 7 files changed, 251 insertions(+), 2 deletions(-) diff --git a/src/models/resource-types.ts b/src/models/resource-types.ts index e87fbcc..6686edb 100644 --- a/src/models/resource-types.ts +++ b/src/models/resource-types.ts @@ -45,6 +45,13 @@ export enum ResourceType { Workspace = 'Workspace', } +/** + * The built-in "managed" gateway id. It is not returned by `GET /gateways` + * (which lists only self-hosted/custom gateways), but its per-API assignments + * are queryable/manageable at `gateways/managed/apis`. + */ +export const MANAGED_GATEWAY_NAME = 'managed'; + /** * Pure-data descriptor for a single APIM resource type. * diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index 0c2c784..dcf40ae 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -123,6 +123,11 @@ export async function computeDeleteActions( // For each resource type in reverse dependency order for (const resourceType of reverseOrder) { + // GatewayApi assignments are reconciled by computeGatewayApiDeleteActions + // below (they require a parent gateway and cannot be listed generically). + if (resourceType === ResourceType.GatewayApi) { + continue; + } try { // List all resources of this type in APIM const apimResources = client.listResources(context, resourceType); @@ -177,9 +182,106 @@ export async function computeDeleteActions( } } + // Gateway → API assignments cannot be enumerated by the generic loop above + // (GatewayApi requires a parent gateway and GET is not supported at the + // collection root). Reconcile them explicitly, scoped to the gateways that + // the local artifacts actually track (including the built-in "managed" + // gateway). This keeps the blast radius limited: gateways with no local + // apis.json are never touched. + const gatewayApiDeletes = await computeGatewayApiDeleteActions( + client, + context, + config, + localDescriptors, + localSet + ); + // Run association removals first (children before parents). + deleteDescriptors.unshift(...gatewayApiDeletes); + return deleteDescriptors; } +/** + * Reconcile per-gateway API assignments (ResourceType.GatewayApi). + * + * Only gateways that appear as a parent of at least one local GatewayApi + * artifact are considered, so a workspace that does not track gateway + * associations is left completely untouched. For each such gateway the deployed + * assignments are listed and any assignment missing from the local artifacts is + * queued for deletion (i.e. the API is un-assigned from that gateway). + */ +async function computeGatewayApiDeleteActions( + client: IApimClient, + context: ApimServiceContext, + config: PublishConfig, + localDescriptors: ResourceDescriptor[], + localSet: Set +): Promise { + const { envMapping } = config; + + // Distinct gateway names that own at least one local GatewayApi artifact. + const gatewayNames = new Set(); + for (const descriptor of localDescriptors) { + if (descriptor.type === ResourceType.GatewayApi) { + const gatewayName = getNamePart(descriptor.nameParts, 0); + if (gatewayName) { + gatewayNames.add(gatewayName); + } + } + } + + if (gatewayNames.size === 0) { + return []; + } + + const deletes: ResourceDescriptor[] = []; + + for (const gatewayName of gatewayNames) { + const gatewayDescriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: [gatewayName], + }; + + try { + for await (const apiJson of client.listResources( + context, + ResourceType.GatewayApi, + gatewayDescriptor + )) { + const apiName = extractResourceName(apiJson); + if (!apiName) { + continue; + } + + const deployedDescriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: [gatewayName, apiName], + }; + + if (envMapping !== undefined) { + const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping); + if (canonicalDescriptor === null) { + // Belongs to another environment — do not touch. + continue; + } + if (!localSet.has(getResourceKey(canonicalDescriptor))) { + deletes.push(deployedDescriptor); + } + } else if (!localSet.has(getResourceKey(deployedDescriptor))) { + deletes.push(deployedDescriptor); + } + } + } catch (error) { + logger.debug( + `[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation: ${(error as Error).message}` + ); + continue; + } + } + + return deletes; +} + /** * Create a set of resource keys from descriptors for fast lookup */ diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index 30225d2..0b34153 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -11,7 +11,7 @@ import { IApimClient } from '../clients/iapim-client.js'; import { IArtifactStore } from '../clients/iartifact-store.js'; import { ExtractConfig, FilterConfig } from '../models/config.js'; import { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; -import { ResourceType } from '../models/resource-types.js'; +import { ResourceType, MANAGED_GATEWAY_NAME } from '../models/resource-types.js'; import { TIER_1_RESOURCES, TIER_2_RESOURCES, @@ -470,6 +470,34 @@ async function extractGatewayAssociations( } } } + + // The built-in "managed" gateway is not returned by GET /gateways, so extract + // its API assignments explicitly. Recording them lets publish/delete-unmatched + // reconcile managed membership (e.g. an API intentionally removed from managed). + const managedDescriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: [MANAGED_GATEWAY_NAME], + }; + try { + const apiNames: string[] = []; + for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) { + const name = apiJson.name as string | undefined; + if (name) { + apiNames.push(name); + } + } + // Only record the managed gateway when it actually has assignments. An empty + // apis.json would neither help publish nor delete-unmatched reconciliation + // (which scopes to gateways that own at least one local assignment). + if (apiNames.length > 0) { + await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames); + result.totalExtracted++; + logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`); + } + } catch (error) { + logger.warn(`Failed to extract API associations for managed gateway: ${(error as Error).message}`); + result.totalErrors++; + } } /** diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index b6ab8f5..ed16f2c 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -11,7 +11,7 @@ import type { IApimClient } from '../clients/iapim-client.js'; import type { IArtifactStore } from '../clients/iartifact-store.js'; import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; -import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; +import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js'; import { applyOverrides } from './override-merger.js'; import { checkKeyVaultSecretAccess } from './keyvault-checker.js'; import { getNamePart } from '../lib/resource-path.js'; @@ -208,6 +208,13 @@ export async function publishResource( config: PublishConfig ): Promise { try { + // The built-in "managed" gateway cannot be created/updated as a resource; + // only its API assignments are manageable. Skip any managed Gateway resource + // PUT (its GatewayApi associations are still published via the branch below). + if (descriptor.type === ResourceType.Gateway && getNamePart(descriptor.nameParts, 0) === MANAGED_GATEWAY_NAME) { + return { descriptor, status: 'skipped', action: 'noop' }; + } + // Handle association types (ProductApi, ProductGroup, GatewayApi) const associationType = ASSOCIATION_TYPES.get(descriptor.type); if (associationType) { diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index 881534e..3f63ba2 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -229,6 +229,43 @@ describe('delete-unmatched-service', () => { nameParts: ['api1'], }); }); + + it('should un-assign a stale managed gateway API not present in artifacts', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'api-keep' }, { name: 'api-stale' }]], + ]); + + // Local artifacts track the managed gateway with only api-keep assigned. + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['managed', 'api-keep'] }, + ]; + + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + const gatewayApiDeletes = result.filter((d) => d.type === ResourceType.GatewayApi); + expect(gatewayApiDeletes).toHaveLength(1); + expect(gatewayApiDeletes[0]).toMatchObject({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'api-stale'], + }); + }); + + it('should not touch any gateway assignments when artifacts track no gateways', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]], + ]); + + // No local GatewayApi artifacts → reconciliation must be a no-op. + const client = createMockClient(apimResources); + const store = createMockStore([]); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0); + }); }); describe('filterRevisionDeletesHandledByBaseApi', () => { diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index f846aff..7a3117c 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -604,6 +604,56 @@ describe('extract-service', () => { ); }); + it('should extract managed gateway API associations', async () => { + // Managed gateway is NOT returned by GET /gateways, only its apis are queryable. + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') { + yield { name: 'managed-api-1' }; + yield { name: 'managed-api-2' }; + } + }; + + const store = createMockStore(); + + const config: ExtractConfig = { + service: testContext, + outputDir: '/output', + includeTransitive: false, + logLevel: LogLevel.INFO, + }; + + await runExtraction(client, store, config); + + expect(store.writeAssociation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: ResourceType.Gateway, nameParts: ['managed'] }), + 'apis', + expect.arrayContaining(['managed-api-1', 'managed-api-2']) + ); + }); + + it('should not write a managed gateway association when it has no apis', async () => { + // Empty managed gateway must not produce an artifact or inflate the count. + const client = createMockClient(); + const store = createMockStore(); + + const result = await runExtraction(client, store, { + service: testContext, + outputDir: '/output', + includeTransitive: false, + logLevel: LogLevel.INFO, + }); + + expect(store.writeAssociation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nameParts: ['managed'] }), + 'apis', + expect.anything() + ); + expect(result.totalExtracted).toBe(0); + }); + it('should handle gateway association extraction error gracefully', async () => { const client = createMockClient({ [ResourceType.Gateway]: [ diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 1584499..c91d9fa 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -98,6 +98,24 @@ describe('resource-publisher', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + it('should skip the built-in managed gateway resource without a PUT', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ name: 'managed', properties: {} }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: ['managed'], + }; + + 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 return success and call putResource on success', async () => { const client = createMockClient(); const store = createMockStore(); From 2756791f439ca0e0feb63a93db4194d06627bc3d Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 19:27:44 +0400 Subject: [PATCH 3/4] fix(gateway): read desired apis from apis.json for delete-unmatched computeGatewayApiDeleteActions compared deployed [gateway,api] descriptors against localSet, which only holds aggregate GatewayApi [gateway] keys (api names live in apis.json content, not the path). Every deployed assignment therefore missed the set and was deleted regardless of desired state - e.g. an API present in the gateway's apis.json was both PUT and DELETEd. Read the desired API set per gateway via store.readAssociation and delete only deployed assignments absent from it. Update tests to use aggregate local descriptors + readAssociation. --- src/services/delete-unmatched-service.ts | 45 +++++++++++++------ .../services/delete-unmatched-service.test.ts | 28 +++++++++++- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index dcf40ae..635faf2 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -190,10 +190,10 @@ export async function computeDeleteActions( // apis.json are never touched. const gatewayApiDeletes = await computeGatewayApiDeleteActions( client, + store, context, config, - localDescriptors, - localSet + localDescriptors ); // Run association removals first (children before parents). deleteDescriptors.unshift(...gatewayApiDeletes); @@ -204,22 +204,24 @@ export async function computeDeleteActions( /** * Reconcile per-gateway API assignments (ResourceType.GatewayApi). * - * Only gateways that appear as a parent of at least one local GatewayApi - * artifact are considered, so a workspace that does not track gateway - * associations is left completely untouched. For each such gateway the deployed - * assignments are listed and any assignment missing from the local artifacts is - * queued for deletion (i.e. the API is un-assigned from that gateway). + * Only gateways that appear as a local GatewayApi artifact are considered, so a + * workspace that does not track gateway associations is left completely + * untouched. The desired API set for each gateway is read from its + * `gateways/{gw}/apis.json` (the artifact store surfaces GatewayApi only as an + * aggregate `nameParts = [gateway]` descriptor, with the API names living in the + * file content), then any deployed assignment not in that desired set is queued + * for deletion (i.e. the API is un-assigned from that gateway). */ async function computeGatewayApiDeleteActions( client: IApimClient, + store: IArtifactStore, context: ApimServiceContext, config: PublishConfig, - localDescriptors: ResourceDescriptor[], - localSet: Set + localDescriptors: ResourceDescriptor[] ): Promise { const { envMapping } = config; - // Distinct gateway names that own at least one local GatewayApi artifact. + // Distinct gateway names that own a local GatewayApi artifact. const gatewayNames = new Set(); for (const descriptor of localDescriptors) { if (descriptor.type === ResourceType.GatewayApi) { @@ -242,6 +244,18 @@ async function computeGatewayApiDeleteActions( nameParts: [gatewayName], }; + // Desired API set (canonical names) from the gateway's apis.json artifact. + let desiredApis: Set; + try { + const entries = await store.readAssociation(config.sourceDir, gatewayDescriptor, 'apis'); + desiredApis = new Set(entries.map((entry) => entry.name)); + } catch (error) { + logger.debug( + `[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation (cannot read desired apis): ${(error as Error).message}` + ); + continue; + } + try { for await (const apiJson of client.listResources( context, @@ -258,16 +272,19 @@ async function computeGatewayApiDeleteActions( nameParts: [gatewayName, apiName], }; + // Compare the deployed API against the desired set using canonical names + // so env-affixed deployments still match the un-affixed artifacts. + let canonicalApiName = apiName; if (envMapping !== undefined) { const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping); if (canonicalDescriptor === null) { // Belongs to another environment — do not touch. continue; } - if (!localSet.has(getResourceKey(canonicalDescriptor))) { - deletes.push(deployedDescriptor); - } - } else if (!localSet.has(getResourceKey(deployedDescriptor))) { + canonicalApiName = getNamePart(canonicalDescriptor.nameParts, 1); + } + + if (!desiredApis.has(canonicalApiName)) { deletes.push(deployedDescriptor); } } diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index 3f63ba2..0850923 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -235,13 +235,15 @@ describe('delete-unmatched-service', () => { [ResourceType.GatewayApi, [{ name: 'api-keep' }, { name: 'api-stale' }]], ]); - // Local artifacts track the managed gateway with only api-keep assigned. + // The store surfaces GatewayApi as an aggregate descriptor (nameParts = + // [gateway]); the desired API names live in apis.json (readAssociation). const localDescriptors: ResourceDescriptor[] = [ - { type: ResourceType.GatewayApi, nameParts: ['managed', 'api-keep'] }, + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, ]; const client = createMockClient(apimResources); const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([{ name: 'api-keep' }]); const result = await computeDeleteActions(client, store, testContext, testConfig); @@ -253,6 +255,28 @@ describe('delete-unmatched-service', () => { }); }); + it('should keep a desired gateway API even when it is also being published', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'webapitest' }]], + ]); + + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['shgw-UAE-01'] }, + ]; + + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([ + { name: 'customermanagementservice' }, + { name: 'swagger-petstore' }, + { name: 'webapitest' }, + ]); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0); + }); + it('should not touch any gateway assignments when artifacts track no gateways', async () => { const apimResources = new Map[]>([ [ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]], From 4679b3ee20a0fb2d296c6045ca60ad55eb34cb1d Mon Sep 17 00:00:00 2001 From: Alexander Zaslonov Date: Fri, 4 Sep 2026 15:18:28 -0700 Subject: [PATCH 4/4] fix(gateway): address review feedback --- src/clients/apim-client.ts | 28 +++++++- src/services/delete-unmatched-service.ts | 17 +++-- src/services/env-mapper.ts | 9 ++- src/services/extract-service.ts | 11 +-- src/services/resource-publisher.ts | 5 +- .../services/delete-unmatched-service.test.ts | 58 ++++++++++++++++ tests/unit/services/env-mapper.test.ts | 16 +++++ tests/unit/services/extract-service.test.ts | 33 ++++++++- .../unit/services/resource-publisher.test.ts | 69 ++++++++++++++++++- 9 files changed, 225 insertions(+), 21 deletions(-) diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index f1d85bd..2e167ba 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -24,7 +24,8 @@ export class HttpError extends Error { constructor( public readonly status: number, message: string, - public readonly code?: string // APIM error code, e.g. "MethodNotAllowedInPricingTier" + public readonly code?: string, // APIM error code, e.g. "MethodNotAllowedInPricingTier" + public readonly body?: unknown ) { super(message); this.name = 'HttpError'; @@ -41,6 +42,26 @@ export function isLinkAlreadyExistsError(error: unknown): boolean { return error instanceof HttpError && error.status === 409; } +/** Returns true when APIM reports that an association's referenced API/group is absent. */ +export function isAssociationReferenceNotFoundError(error: unknown): boolean { + if ( + !(error instanceof HttpError) || + ![400, 404].includes(error.status) || + !['ValidationError', 'ResourceNotFound'].includes(error.code ?? '') + ) { + return false; + } + + const body = error.body as Record | undefined; + const armError = body?.error as Record | undefined; + const details = Array.isArray(armError?.details) ? armError.details : []; + return details.some((detail) => { + if (typeof detail !== 'object' || detail === null) return false; + const target = (detail as Record).target; + return typeof target === 'string' && ['aid', 'gid'].includes(target.toLowerCase()); + }); +} + export class ApimClient implements IApimClient { private credential: DefaultAzureCredential; private readonly authScope: string; @@ -187,8 +208,9 @@ export class ApimClient implements IApimClient { if (!response.ok) { const errorText = await response.text(); let errorCode: string | undefined; + let errorBody: unknown; try { - const errorBody: unknown = JSON.parse(errorText); + errorBody = JSON.parse(errorText); if ( typeof errorBody === 'object' && errorBody !== null && 'error' in errorBody && @@ -202,7 +224,7 @@ export class ApimClient implements IApimClient { } catch { // Response body is not JSON — no error code available } - throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode); + throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode, errorBody); } return response; diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index 635faf2..bbb7769 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -36,7 +36,7 @@ import { isApiRevisionName, } from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; -import { toCanonicalDescriptor } from './env-mapper.js'; +import { mapDescriptor, toCanonicalDescriptor, toCanonicalName } from './env-mapper.js'; /** * Drop ;rev=N API deletes whose base API (same workspace) is also queued for @@ -243,6 +243,9 @@ async function computeGatewayApiDeleteActions( type: ResourceType.Gateway, nameParts: [gatewayName], }; + const deployedGatewayDescriptor = envMapping !== undefined + ? mapDescriptor(gatewayDescriptor, envMapping) + : gatewayDescriptor; // Desired API set (canonical names) from the gateway's apis.json artifact. let desiredApis: Set; @@ -260,7 +263,7 @@ async function computeGatewayApiDeleteActions( for await (const apiJson of client.listResources( context, ResourceType.GatewayApi, - gatewayDescriptor + deployedGatewayDescriptor )) { const apiName = extractResourceName(apiJson); if (!apiName) { @@ -269,7 +272,7 @@ async function computeGatewayApiDeleteActions( const deployedDescriptor: ResourceDescriptor = { type: ResourceType.GatewayApi, - nameParts: [gatewayName, apiName], + nameParts: [getNamePart(deployedGatewayDescriptor.nameParts, 0), apiName], }; // Compare the deployed API against the desired set using canonical names @@ -281,7 +284,13 @@ async function computeGatewayApiDeleteActions( // Belongs to another environment — do not touch. continue; } - canonicalApiName = getNamePart(canonicalDescriptor.nameParts, 1); + const canonicalChildName = toCanonicalName(apiName, ResourceType.Api, envMapping); + if (canonicalChildName === undefined) { + // The gateway can be shared (for example, "managed"), so the child + // API must independently belong to this environment's namespace. + continue; + } + canonicalApiName = canonicalChildName; } if (!desiredApis.has(canonicalApiName)) { diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts index 47cc78a..4ce59e7 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ResourceType } from '../models/resource-types.js'; +import { MANAGED_GATEWAY_NAME, ResourceType } from '../models/resource-types.js'; import type { ResourceDescriptor } from '../models/types.js'; import type { EnvironmentOverride, OverrideConfig } from '../models/config.js'; @@ -180,7 +180,12 @@ function splitRevisionSuffix(name: string, type: ResourceType): { base: string; : { base: name.slice(0, idx), revSuffix: name.slice(idx) }; } +function isManagedGatewayName(name: string, type: ResourceType): boolean { + return type === ResourceType.Gateway && name === MANAGED_GATEWAY_NAME; +} + export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { + if (isManagedGatewayName(name, type)) return name; if (!m.appliesTo.has(type)) return name; const { base, revSuffix } = splitRevisionSuffix(name, type); return `${m.prefix}${base}${m.suffix}${revSuffix}`; @@ -192,6 +197,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): * Returns input unchanged when type ∉ appliesTo. */ export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined { + if (isManagedGatewayName(deployedName, type)) return deployedName; if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; @@ -207,6 +213,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env * When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type). */ export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean { + if (isManagedGatewayName(deployedName, type)) return true; if (!m.appliesTo.has(type)) return true; const { base } = splitRevisionSuffix(deployedName, type); if (base.length < m.prefix.length + m.suffix.length) return false; diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index 0b34153..ca5106d 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -439,7 +439,7 @@ async function extractGatewayAssociations( store: IArtifactStore, context: ApimServiceContext, outputDir: string, - _filter: FilterConfig | undefined, + filter: FilterConfig | undefined, result: ExtractionResult ): Promise { const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway); @@ -478,6 +478,10 @@ async function extractGatewayAssociations( type: ResourceType.Gateway, nameParts: [MANAGED_GATEWAY_NAME], }; + if (!shouldIncludeResource(managedDescriptor, filter)) { + return; + } + try { const apiNames: string[] = []; for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) { @@ -486,11 +490,8 @@ async function extractGatewayAssociations( apiNames.push(name); } } - // Only record the managed gateway when it actually has assignments. An empty - // apis.json would neither help publish nor delete-unmatched reconciliation - // (which scopes to gateways that own at least one local assignment). + await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames); if (apiNames.length > 0) { - await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames); result.totalExtracted++; logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`); } diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index ed16f2c..417b122 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -19,7 +19,7 @@ import { isAutoGeneratedId } from '../lib/auto-generated.js'; import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js'; import { logger } from '../lib/logger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; -import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; +import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js'; import type { OverrideConfig, OverrideSection } from '../models/config.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; import { mapDescriptor, toDeployedName } from './env-mapper.js'; @@ -540,8 +540,7 @@ async function publishAssociation( // The referenced API/group is absent on the target (filtered out or // failed to publish). Skip this single link with a warning instead of // aborting the whole association, so other present entries still link. - const message = error instanceof Error ? error.message : String(error); - if (message.includes('API not found') || message.includes('Group not found')) { + if (isAssociationReferenceNotFoundError(error)) { logger.warn( `Skipping ${associationType} association '${entry.name}' on ` + `'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target` diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index 0850923..615b567 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -13,6 +13,7 @@ import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { buildEnvMapping } from '../../../src/services/env-mapper.js'; function createMockClient(apimResources: Map[]> = new Map()) { return { @@ -277,6 +278,63 @@ describe('delete-unmatched-service', () => { expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0); }); + it('should list custom gateway APIs using the deployed parent name', async () => { + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['custom-gateway'] }, + ]; + const listedParents: Array = []; + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi) { + listedParents.push(parent); + if (parent?.nameParts[0] === 'dev-custom-gateway') { + yield { name: 'dev-api-stale' }; + } + } + }; + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([]); + const envMapping = buildEnvMapping({ + namePrefix: 'dev-', + appliesTo: [ResourceType.Gateway, ResourceType.Api], + }); + + const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping }); + + expect(listedParents).toContainEqual({ + type: ResourceType.Gateway, + nameParts: ['dev-custom-gateway'], + }); + expect(result).toContainEqual({ + type: ResourceType.GatewayApi, + nameParts: ['dev-custom-gateway', 'dev-api-stale'], + }); + }); + + it('should delete all in-scope managed gateway APIs for an empty desired set without touching other environments', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [ + { name: 'dev-api-one' }, + { name: 'dev-api-two' }, + { name: 'prod-api' }, + ]], + ]); + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, + ]; + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([]); + const envMapping = buildEnvMapping({ namePrefix: 'dev-' }); + + const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping }); + + expect(result.filter((descriptor) => descriptor.type === ResourceType.GatewayApi)).toEqual([ + { type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-one'] }, + { type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-two'] }, + ]); + }); + it('should not touch any gateway assignments when artifacts track no gateways', async () => { const apimResources = new Map[]>([ [ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]], diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index 12533cd..df1cda8 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -12,6 +12,7 @@ import { buildEnvMappingFromOverrides, toDeployedName, toCanonicalName, + toCanonicalDescriptor, isInEnvNamespace, mapDescriptor, type EnvironmentOverride, @@ -393,6 +394,21 @@ describe('env-mapper', () => { expect(result).toEqual({ type: ResourceType.GatewayApi, nameParts: ['my-gw', 'dev-petstore'] }); }); + it('GatewayApi: preserves the managed gateway when Gateway is explicitly affixed', () => { + const mapping = prefixMapping('dev-', [ResourceType.Gateway, ResourceType.Api]); + const descriptor: ResourceDescriptor = { type: ResourceType.GatewayApi, nameParts: ['managed', 'petstore'] }; + + expect(mapDescriptor(descriptor, mapping)).toEqual({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'dev-petstore'], + }); + expect(toCanonicalDescriptor({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'dev-petstore'], + }, mapping)).toEqual(descriptor); + expect(isInEnvNamespace('managed', ResourceType.Gateway, mapping)).toBe(true); + }); + it('ServicePolicy: no nameParts, returned unchanged', () => { const d: ResourceDescriptor = { type: ResourceType.ServicePolicy, nameParts: [] }; expect(mapDescriptor(d, m)).toEqual(d); diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index 7a3117c..53c23e8 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -633,8 +633,7 @@ describe('extract-service', () => { ); }); - it('should not write a managed gateway association when it has no apis', async () => { - // Empty managed gateway must not produce an artifact or inflate the count. + it('should write an empty managed gateway association when it has no apis', async () => { const client = createMockClient(); const store = createMockStore(); @@ -645,13 +644,41 @@ describe('extract-service', () => { logLevel: LogLevel.INFO, }); + expect(store.writeAssociation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nameParts: ['managed'] }), + 'apis', + [] + ); + expect(result.totalExtracted).toBe(0); + }); + + it('should not extract the managed gateway when excluded by the gateway filter', async () => { + let managedGatewayListed = false; + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') { + managedGatewayListed = true; + yield { name: 'managed-api' }; + } + }; + const store = createMockStore(); + + await runExtraction(client, store, { + service: testContext, + outputDir: '/output', + filter: { gateways: [] }, + includeTransitive: false, + logLevel: LogLevel.INFO, + }); + + expect(managedGatewayListed).toBe(false); expect(store.writeAssociation).not.toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ nameParts: ['managed'] }), 'apis', expect.anything() ); - expect(result.totalExtracted).toBe(0); }); it('should handle gateway association extraction error gracefully', async () => { diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index c91d9fa..fe76173 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -16,6 +16,8 @@ import { PublishConfig } from '../../../src/models/config.js'; import { KeyVaultAccessError } from '../../../src/services/keyvault-checker.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; +import { buildEnvMapping } from '../../../src/services/env-mapper.js'; +import { HttpError } from '../../../src/clients/apim-client.js'; // Mock keyvault-checker so resource-publisher tests don't need an Azure environment const mockCheckKeyVaultSecretAccess = vi.fn().mockResolvedValue(undefined); @@ -372,14 +374,48 @@ describe('resource-publisher', () => { ); }); + it('should preserve the managed GatewayApi parent under environment mapping', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'api-1' }]); + const envMapping = buildEnvMapping({ + namePrefix: 'dev-', + appliesTo: [ResourceType.Gateway, ResourceType.Api], + }); + + const result = await publishResource( + client, + store, + testContext, + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, + { ...testConfig, envMapping } + ); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-1'] }), + {} + ); + }); + it('skips a GatewayApi link when the referenced API is not on the target and keeps going', async () => { const client = createMockClient(); const store = createMockStore(); store.readAssociation.mockResolvedValue([{ name: 'missing-api' }, { name: 'present-api' }]); client.putResource = vi.fn().mockImplementation(async (_ctx: unknown, d: ResourceDescriptor) => { if (d.nameParts[1] === 'missing-api') { - throw new Error( - 'HTTP 400: {"error":{"code":"ValidationError","details":[{"target":"aid","message":"API not found"}]}}' + const body = { + error: { + code: 'ValidationError', + details: [{ target: 'aid', message: 'The referenced API does not exist' }], + }, + }; + throw new HttpError( + 400, + `HTTP 400: ${JSON.stringify(body)}`, + 'ValidationError', + body ); } return {}; @@ -402,6 +438,35 @@ describe('resource-publisher', () => { ); }); + it('should fail a GatewayApi association for an unrelated validation error', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'invalid-api' }]); + const body = { + error: { + code: 'ValidationError', + details: [{ target: 'gatewayId', message: 'The gateway is invalid' }], + }, + }; + client.putResource.mockRejectedValue(new HttpError( + 400, + `HTTP 400: ${JSON.stringify(body)}`, + 'ValidationError', + body + )); + + const result = await publishResource( + client, + store, + testContext, + { type: ResourceType.GatewayApi, nameParts: ['my-gateway'] }, + testConfig + ); + + expect(result.status).toBe('failed'); + expect(result.error).toBeInstanceOf(HttpError); + }); + it('should strip properties.value from KeyVault-backed NamedValue PUT payload', async () => { const client = createMockClient(); const store = createMockStore();