Skip to content

Commit b694e4e

Browse files
authored
Merge pull request #268 from azaslonov/fix/gateway-association-skip-missing-v2
fix: reconcile gateway APIs and skip missing association targets
2 parents 42f182d + 4679b3e commit b694e4e

10 files changed

Lines changed: 547 additions & 11 deletions

‎src/clients/apim-client.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export class HttpError extends Error {
2424
constructor(
2525
public readonly status: number,
2626
message: string,
27-
public readonly code?: string // APIM error code, e.g. "MethodNotAllowedInPricingTier"
27+
public readonly code?: string, // APIM error code, e.g. "MethodNotAllowedInPricingTier"
28+
public readonly body?: unknown
2829
) {
2930
super(message);
3031
this.name = 'HttpError';
@@ -41,6 +42,26 @@ export function isLinkAlreadyExistsError(error: unknown): boolean {
4142
return error instanceof HttpError && error.status === 409;
4243
}
4344

45+
/** Returns true when APIM reports that an association's referenced API/group is absent. */
46+
export function isAssociationReferenceNotFoundError(error: unknown): boolean {
47+
if (
48+
!(error instanceof HttpError) ||
49+
![400, 404].includes(error.status) ||
50+
!['ValidationError', 'ResourceNotFound'].includes(error.code ?? '')
51+
) {
52+
return false;
53+
}
54+
55+
const body = error.body as Record<string, unknown> | undefined;
56+
const armError = body?.error as Record<string, unknown> | undefined;
57+
const details = Array.isArray(armError?.details) ? armError.details : [];
58+
return details.some((detail) => {
59+
if (typeof detail !== 'object' || detail === null) return false;
60+
const target = (detail as Record<string, unknown>).target;
61+
return typeof target === 'string' && ['aid', 'gid'].includes(target.toLowerCase());
62+
});
63+
}
64+
4465
export class ApimClient implements IApimClient {
4566
private credential: DefaultAzureCredential;
4667
private readonly authScope: string;
@@ -187,8 +208,9 @@ export class ApimClient implements IApimClient {
187208
if (!response.ok) {
188209
const errorText = await response.text();
189210
let errorCode: string | undefined;
211+
let errorBody: unknown;
190212
try {
191-
const errorBody: unknown = JSON.parse(errorText);
213+
errorBody = JSON.parse(errorText);
192214
if (
193215
typeof errorBody === 'object' && errorBody !== null &&
194216
'error' in errorBody &&
@@ -202,7 +224,7 @@ export class ApimClient implements IApimClient {
202224
} catch {
203225
// Response body is not JSON — no error code available
204226
}
205-
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode);
227+
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode, errorBody);
206228
}
207229

208230
return response;

‎src/models/resource-types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export enum ResourceType {
4545
Workspace = 'Workspace',
4646
}
4747

48+
/**
49+
* The built-in "managed" gateway id. It is not returned by `GET /gateways`
50+
* (which lists only self-hosted/custom gateways), but its per-API assignments
51+
* are queryable/manageable at `gateways/managed/apis`.
52+
*/
53+
export const MANAGED_GATEWAY_NAME = 'managed';
54+
4855
/**
4956
* Pure-data descriptor for a single APIM resource type.
5057
*

‎src/services/delete-unmatched-service.ts‎

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
isApiRevisionName,
3737
} from '../lib/resource-path.js';
3838
import { logger } from '../lib/logger.js';
39-
import { toCanonicalDescriptor } from './env-mapper.js';
39+
import { mapDescriptor, toCanonicalDescriptor, toCanonicalName } from './env-mapper.js';
4040

4141
/**
4242
* Drop ;rev=N API deletes whose base API (same workspace) is also queued for
@@ -123,6 +123,11 @@ export async function computeDeleteActions(
123123

124124
// For each resource type in reverse dependency order
125125
for (const resourceType of reverseOrder) {
126+
// GatewayApi assignments are reconciled by computeGatewayApiDeleteActions
127+
// below (they require a parent gateway and cannot be listed generically).
128+
if (resourceType === ResourceType.GatewayApi) {
129+
continue;
130+
}
126131
try {
127132
// List all resources of this type in APIM
128133
const apimResources = client.listResources(context, resourceType);
@@ -177,9 +182,132 @@ export async function computeDeleteActions(
177182
}
178183
}
179184

185+
// Gateway → API assignments cannot be enumerated by the generic loop above
186+
// (GatewayApi requires a parent gateway and GET is not supported at the
187+
// collection root). Reconcile them explicitly, scoped to the gateways that
188+
// the local artifacts actually track (including the built-in "managed"
189+
// gateway). This keeps the blast radius limited: gateways with no local
190+
// apis.json are never touched.
191+
const gatewayApiDeletes = await computeGatewayApiDeleteActions(
192+
client,
193+
store,
194+
context,
195+
config,
196+
localDescriptors
197+
);
198+
// Run association removals first (children before parents).
199+
deleteDescriptors.unshift(...gatewayApiDeletes);
200+
180201
return deleteDescriptors;
181202
}
182203

204+
/**
205+
* Reconcile per-gateway API assignments (ResourceType.GatewayApi).
206+
*
207+
* Only gateways that appear as a local GatewayApi artifact are considered, so a
208+
* workspace that does not track gateway associations is left completely
209+
* untouched. The desired API set for each gateway is read from its
210+
* `gateways/{gw}/apis.json` (the artifact store surfaces GatewayApi only as an
211+
* aggregate `nameParts = [gateway]` descriptor, with the API names living in the
212+
* file content), then any deployed assignment not in that desired set is queued
213+
* for deletion (i.e. the API is un-assigned from that gateway).
214+
*/
215+
async function computeGatewayApiDeleteActions(
216+
client: IApimClient,
217+
store: IArtifactStore,
218+
context: ApimServiceContext,
219+
config: PublishConfig,
220+
localDescriptors: ResourceDescriptor[]
221+
): Promise<ResourceDescriptor[]> {
222+
const { envMapping } = config;
223+
224+
// Distinct gateway names that own a local GatewayApi artifact.
225+
const gatewayNames = new Set<string>();
226+
for (const descriptor of localDescriptors) {
227+
if (descriptor.type === ResourceType.GatewayApi) {
228+
const gatewayName = getNamePart(descriptor.nameParts, 0);
229+
if (gatewayName) {
230+
gatewayNames.add(gatewayName);
231+
}
232+
}
233+
}
234+
235+
if (gatewayNames.size === 0) {
236+
return [];
237+
}
238+
239+
const deletes: ResourceDescriptor[] = [];
240+
241+
for (const gatewayName of gatewayNames) {
242+
const gatewayDescriptor: ResourceDescriptor = {
243+
type: ResourceType.Gateway,
244+
nameParts: [gatewayName],
245+
};
246+
const deployedGatewayDescriptor = envMapping !== undefined
247+
? mapDescriptor(gatewayDescriptor, envMapping)
248+
: gatewayDescriptor;
249+
250+
// Desired API set (canonical names) from the gateway's apis.json artifact.
251+
let desiredApis: Set<string>;
252+
try {
253+
const entries = await store.readAssociation(config.sourceDir, gatewayDescriptor, 'apis');
254+
desiredApis = new Set(entries.map((entry) => entry.name));
255+
} catch (error) {
256+
logger.debug(
257+
`[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation (cannot read desired apis): ${(error as Error).message}`
258+
);
259+
continue;
260+
}
261+
262+
try {
263+
for await (const apiJson of client.listResources(
264+
context,
265+
ResourceType.GatewayApi,
266+
deployedGatewayDescriptor
267+
)) {
268+
const apiName = extractResourceName(apiJson);
269+
if (!apiName) {
270+
continue;
271+
}
272+
273+
const deployedDescriptor: ResourceDescriptor = {
274+
type: ResourceType.GatewayApi,
275+
nameParts: [getNamePart(deployedGatewayDescriptor.nameParts, 0), apiName],
276+
};
277+
278+
// Compare the deployed API against the desired set using canonical names
279+
// so env-affixed deployments still match the un-affixed artifacts.
280+
let canonicalApiName = apiName;
281+
if (envMapping !== undefined) {
282+
const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping);
283+
if (canonicalDescriptor === null) {
284+
// Belongs to another environment — do not touch.
285+
continue;
286+
}
287+
const canonicalChildName = toCanonicalName(apiName, ResourceType.Api, envMapping);
288+
if (canonicalChildName === undefined) {
289+
// The gateway can be shared (for example, "managed"), so the child
290+
// API must independently belong to this environment's namespace.
291+
continue;
292+
}
293+
canonicalApiName = canonicalChildName;
294+
}
295+
296+
if (!desiredApis.has(canonicalApiName)) {
297+
deletes.push(deployedDescriptor);
298+
}
299+
}
300+
} catch (error) {
301+
logger.debug(
302+
`[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation: ${(error as Error).message}`
303+
);
304+
continue;
305+
}
306+
}
307+
308+
return deletes;
309+
}
310+
183311
/**
184312
* Create a set of resource keys from descriptors for fast lookup
185313
*/

‎src/services/env-mapper.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { ResourceType } from '../models/resource-types.js';
4+
import { MANAGED_GATEWAY_NAME, ResourceType } from '../models/resource-types.js';
55
import type { ResourceDescriptor } from '../models/types.js';
66
import type { EnvironmentOverride, OverrideConfig } from '../models/config.js';
77

@@ -180,7 +180,12 @@ function splitRevisionSuffix(name: string, type: ResourceType): { base: string;
180180
: { base: name.slice(0, idx), revSuffix: name.slice(idx) };
181181
}
182182

183+
function isManagedGatewayName(name: string, type: ResourceType): boolean {
184+
return type === ResourceType.Gateway && name === MANAGED_GATEWAY_NAME;
185+
}
186+
183187
export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string {
188+
if (isManagedGatewayName(name, type)) return name;
184189
if (!m.appliesTo.has(type)) return name;
185190
const { base, revSuffix } = splitRevisionSuffix(name, type);
186191
return `${m.prefix}${base}${m.suffix}${revSuffix}`;
@@ -192,6 +197,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping):
192197
* Returns input unchanged when type ∉ appliesTo.
193198
*/
194199
export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined {
200+
if (isManagedGatewayName(deployedName, type)) return deployedName;
195201
if (!m.appliesTo.has(type)) return deployedName;
196202
if (!isInEnvNamespace(deployedName, type, m)) return undefined;
197203

@@ -207,6 +213,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env
207213
* When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type).
208214
*/
209215
export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean {
216+
if (isManagedGatewayName(deployedName, type)) return true;
210217
if (!m.appliesTo.has(type)) return true;
211218
const { base } = splitRevisionSuffix(deployedName, type);
212219
if (base.length < m.prefix.length + m.suffix.length) return false;

‎src/services/extract-service.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { IApimClient } from '../clients/iapim-client.js';
1111
import { IArtifactStore } from '../clients/iartifact-store.js';
1212
import { ExtractConfig, FilterConfig } from '../models/config.js';
1313
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
14-
import { ResourceType } from '../models/resource-types.js';
14+
import { ResourceType, MANAGED_GATEWAY_NAME } from '../models/resource-types.js';
1515
import {
1616
TIER_1_RESOURCES,
1717
TIER_2_RESOURCES,
@@ -439,7 +439,7 @@ async function extractGatewayAssociations(
439439
store: IArtifactStore,
440440
context: ApimServiceContext,
441441
outputDir: string,
442-
_filter: FilterConfig | undefined,
442+
filter: FilterConfig | undefined,
443443
result: ExtractionResult
444444
): Promise<void> {
445445
const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway);
@@ -470,6 +470,35 @@ async function extractGatewayAssociations(
470470
}
471471
}
472472
}
473+
474+
// The built-in "managed" gateway is not returned by GET /gateways, so extract
475+
// its API assignments explicitly. Recording them lets publish/delete-unmatched
476+
// reconcile managed membership (e.g. an API intentionally removed from managed).
477+
const managedDescriptor: ResourceDescriptor = {
478+
type: ResourceType.Gateway,
479+
nameParts: [MANAGED_GATEWAY_NAME],
480+
};
481+
if (!shouldIncludeResource(managedDescriptor, filter)) {
482+
return;
483+
}
484+
485+
try {
486+
const apiNames: string[] = [];
487+
for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) {
488+
const name = apiJson.name as string | undefined;
489+
if (name) {
490+
apiNames.push(name);
491+
}
492+
}
493+
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
494+
if (apiNames.length > 0) {
495+
result.totalExtracted++;
496+
logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`);
497+
}
498+
} catch (error) {
499+
logger.warn(`Failed to extract API associations for managed gateway: ${(error as Error).message}`);
500+
result.totalErrors++;
501+
}
473502
}
474503

475504
/**

‎src/services/resource-publisher.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ import type { IApimClient } from '../clients/iapim-client.js';
1111
import type { IArtifactStore } from '../clients/iartifact-store.js';
1212
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
1313
import type { PublishConfig } from '../models/config.js';
14-
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
14+
import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js';
1515
import { applyOverrides } from './override-merger.js';
1616
import { checkKeyVaultSecretAccess } from './keyvault-checker.js';
1717
import { getNamePart } from '../lib/resource-path.js';
1818
import { isAutoGeneratedId } from '../lib/auto-generated.js';
1919
import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js';
2020
import { logger } from '../lib/logger.js';
2121
import { REDACTION_MARKER } from './secret-redactor.js';
22-
import { isLinkAlreadyExistsError } from '../clients/apim-client.js';
22+
import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js';
2323
import type { OverrideConfig, OverrideSection } from '../models/config.js';
2424
import { buildResourceLabel } from '../lib/resource-uri.js';
2525
import { mapDescriptor, toDeployedName } from './env-mapper.js';
@@ -208,6 +208,13 @@ export async function publishResource(
208208
config: PublishConfig
209209
): Promise<ResourcePublishResult> {
210210
try {
211+
// The built-in "managed" gateway cannot be created/updated as a resource;
212+
// only its API assignments are manageable. Skip any managed Gateway resource
213+
// PUT (its GatewayApi associations are still published via the branch below).
214+
if (descriptor.type === ResourceType.Gateway && getNamePart(descriptor.nameParts, 0) === MANAGED_GATEWAY_NAME) {
215+
return { descriptor, status: 'skipped', action: 'noop' };
216+
}
217+
211218
// Handle association types (ProductApi, ProductGroup, GatewayApi)
212219
const associationType = ASSOCIATION_TYPES.get(descriptor.type);
213220
if (associationType) {
@@ -527,9 +534,20 @@ async function publishAssociation(
527534
await client.putResource(context, assocDescriptor, {});
528535
} catch (error) {
529536
// 409 means the link already exists — desired state is in place.
530-
if (!isLinkAlreadyExistsError(error)) {
531-
throw error;
537+
if (isLinkAlreadyExistsError(error)) {
538+
continue;
539+
}
540+
// The referenced API/group is absent on the target (filtered out or
541+
// failed to publish). Skip this single link with a warning instead of
542+
// aborting the whole association, so other present entries still link.
543+
if (isAssociationReferenceNotFoundError(error)) {
544+
logger.warn(
545+
`Skipping ${associationType} association '${entry.name}' on ` +
546+
`'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target`
547+
);
548+
continue;
532549
}
550+
throw error;
533551
}
534552
}
535553

0 commit comments

Comments
 (0)