Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown> | undefined;
const armError = body?.error as Record<string, unknown> | 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<string, unknown>).target;
return typeof target === 'string' && ['aid', 'gid'].includes(target.toLowerCase());
});
}

export class ApimClient implements IApimClient {
private credential: DefaultAzureCredential;
private readonly authScope: string;
Expand Down Expand Up @@ -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 &&
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/models/resource-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
azaslonov marked this conversation as resolved.

/**
* Pure-data descriptor for a single APIM resource type.
*
Expand Down
130 changes: 129 additions & 1 deletion src/services/delete-unmatched-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -177,9 +182,132 @@ 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,
store,
context,
config,
localDescriptors
);
// 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 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[]
): Promise<ResourceDescriptor[]> {
const { envMapping } = config;

// Distinct gateway names that own a local GatewayApi artifact.
const gatewayNames = new Set<string>();
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],
};
const deployedGatewayDescriptor = envMapping !== undefined
? mapDescriptor(gatewayDescriptor, envMapping)
: gatewayDescriptor;

// Desired API set (canonical names) from the gateway's apis.json artifact.
let desiredApis: Set<string>;
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,
ResourceType.GatewayApi,
deployedGatewayDescriptor
)) {
const apiName = extractResourceName(apiJson);
if (!apiName) {
continue;
}

const deployedDescriptor: ResourceDescriptor = {
type: ResourceType.GatewayApi,
nameParts: [getNamePart(deployedGatewayDescriptor.nameParts, 0), 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;
}
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)) {
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
*/
Expand Down
9 changes: 8 additions & 1 deletion src/services/env-mapper.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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}`;
Expand All @@ -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;

Expand All @@ -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;
Expand Down
33 changes: 31 additions & 2 deletions src/services/extract-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -439,7 +439,7 @@ async function extractGatewayAssociations(
store: IArtifactStore,
context: ApimServiceContext,
outputDir: string,
_filter: FilterConfig | undefined,
filter: FilterConfig | undefined,
result: ExtractionResult
): Promise<void> {
const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway);
Expand Down Expand Up @@ -470,6 +470,35 @@ 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],
};
if (!shouldIncludeResource(managedDescriptor, filter)) {
return;
}

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);
}
}
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
if (apiNames.length > 0) {
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++;
}
}

/**
Expand Down
26 changes: 22 additions & 4 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ 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';
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';
Expand Down Expand Up @@ -208,6 +208,13 @@ export async function publishResource(
config: PublishConfig
): Promise<ResourcePublishResult> {
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) {
Expand Down Expand Up @@ -527,9 +534,20 @@ 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.
if (isAssociationReferenceNotFoundError(error)) {
logger.warn(
`Skipping ${associationType} association '${entry.name}' on ` +
`'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target`
);
continue;
}
throw error;
}
}

Expand Down
Loading