Skip to content

Commit 19687f6

Browse files
author
Alexander Zaslonov
committed
chore: merge latest main into feat/publish-filtering
2 parents 6f5d3b1 + b694e4e commit 19687f6

10 files changed

Lines changed: 551 additions & 10 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

@@ -192,8 +192,13 @@ function splitRevisionSuffix(name: string, type: ResourceType): { base: string;
192192
: { base: name.slice(0, idx), revSuffix: name.slice(idx) };
193193
}
194194

195+
function isManagedGatewayName(name: string, type: ResourceType): boolean {
196+
return type === ResourceType.Gateway && name === MANAGED_GATEWAY_NAME;
197+
}
198+
195199
export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string {
196200
if (type === ResourceType.Group && isBuiltInGroup(name)) return name;
201+
if (isManagedGatewayName(name, type)) return name;
197202
if (!m.appliesTo.has(type)) return name;
198203
const { base, revSuffix } = splitRevisionSuffix(name, type);
199204
return `${m.prefix}${base}${m.suffix}${revSuffix}`;
@@ -206,6 +211,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping):
206211
*/
207212
export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined {
208213
if (type === ResourceType.Group && isBuiltInGroup(deployedName)) return deployedName;
214+
if (isManagedGatewayName(deployedName, type)) return deployedName;
209215
if (!m.appliesTo.has(type)) return deployedName;
210216
if (!isInEnvNamespace(deployedName, type, m)) return undefined;
211217

@@ -222,6 +228,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env
222228
*/
223229
export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean {
224230
if (type === ResourceType.Group && isBuiltInGroup(deployedName)) return true;
231+
if (isManagedGatewayName(deployedName, type)) return true;
225232
if (!m.appliesTo.has(type)) return true;
226233
const { base } = splitRevisionSuffix(deployedName, type);
227234
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,
@@ -445,7 +445,7 @@ async function extractGatewayAssociations(
445445
store: IArtifactStore,
446446
context: ApimServiceContext,
447447
outputDir: string,
448-
_filter: FilterConfig | undefined,
448+
filter: FilterConfig | undefined,
449449
result: ExtractionResult
450450
): Promise<void> {
451451
const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway);
@@ -476,6 +476,35 @@ async function extractGatewayAssociations(
476476
}
477477
}
478478
}
479+
480+
// The built-in "managed" gateway is not returned by GET /gateways, so extract
481+
// its API assignments explicitly. Recording them lets publish/delete-unmatched
482+
// reconcile managed membership (e.g. an API intentionally removed from managed).
483+
const managedDescriptor: ResourceDescriptor = {
484+
type: ResourceType.Gateway,
485+
nameParts: [MANAGED_GATEWAY_NAME],
486+
};
487+
if (!shouldIncludeResource(managedDescriptor, filter)) {
488+
return;
489+
}
490+
491+
try {
492+
const apiNames: string[] = [];
493+
for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) {
494+
const name = apiJson.name as string | undefined;
495+
if (name) {
496+
apiNames.push(name);
497+
}
498+
}
499+
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
500+
if (apiNames.length > 0) {
501+
result.totalExtracted++;
502+
logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`);
503+
}
504+
} catch (error) {
505+
logger.warn(`Failed to extract API associations for managed gateway: ${(error as Error).message}`);
506+
result.totalErrors++;
507+
}
479508
}
480509

481510
/**

‎src/services/resource-publisher.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
ResourceDescriptor,
1616
} from '../models/types.js';
1717
import type { PublishConfig } from '../models/config.js';
18-
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
18+
import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js';
1919
import { applyOverrides } from './override-merger.js';
2020
import { checkKeyVaultSecretAccess } from './keyvault-checker.js';
2121
import {
@@ -27,7 +27,7 @@ import { isAutoGeneratedId } from '../lib/auto-generated.js';
2727
import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js';
2828
import { logger } from '../lib/logger.js';
2929
import { REDACTION_MARKER } from './secret-redactor.js';
30-
import { isLinkAlreadyExistsError } from '../clients/apim-client.js';
30+
import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js';
3131
import type { OverrideConfig, OverrideSection } from '../models/config.js';
3232
import { buildResourceLabel } from '../lib/resource-uri.js';
3333
import { hasExplicitTypeFilter, resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js';
@@ -234,6 +234,13 @@ export async function publishResource(
234234
): Promise<ResourcePublishResult> {
235235
let attemptedPut = false;
236236
try {
237+
// The built-in "managed" gateway cannot be created/updated as a resource;
238+
// only its API assignments are manageable. Skip any managed Gateway resource
239+
// PUT (its GatewayApi associations are still published via the branch below).
240+
if (descriptor.type === ResourceType.Gateway && getNamePart(descriptor.nameParts, 0) === MANAGED_GATEWAY_NAME) {
241+
return { descriptor, status: 'skipped', action: 'noop' };
242+
}
243+
237244
// Handle association types (ProductApi, ProductGroup, GatewayApi)
238245
const associationType = ASSOCIATION_TYPES.get(descriptor.type);
239246
if (associationType) {
@@ -580,6 +587,21 @@ async function publishAssociation(
580587
});
581588
continue;
582589
}
590+
// The referenced API/group is absent on the target (filtered out or
591+
// failed to publish). Skip this single link with a warning instead of
592+
// aborting the whole association, so other present entries still link.
593+
if (isAssociationReferenceNotFoundError(error)) {
594+
logger.warn(
595+
`Skipping ${associationType} association '${getNamePart(plan.descriptor.nameParts, 1)}' on ` +
596+
`'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target`
597+
);
598+
relatedResults.push({
599+
descriptor: plan.descriptor,
600+
status: 'skipped',
601+
action: 'noop',
602+
});
603+
continue;
604+
}
583605
relatedResults.push({
584606
descriptor: plan.descriptor,
585607
status: 'failed',
@@ -590,10 +612,12 @@ async function publishAssociation(
590612
}
591613
}
592614

615+
const failedResult = relatedResults.find((result) => result.status === 'failed');
593616
return {
594617
descriptor,
595-
status: relatedResults.some((result) => result.status === 'failed') ? 'failed' : 'success',
618+
status: failedResult ? 'failed' : 'success',
596619
action: 'noop',
620+
error: failedResult?.error,
597621
relatedResults,
598622
suppressPrimaryResult: true,
599623
};

0 commit comments

Comments
 (0)