Skip to content

Commit 065d9be

Browse files
Alexey-ZheltovAlexander Zaslonov
authored andcommitted
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
1 parent 3c68b53 commit 065d9be

7 files changed

Lines changed: 251 additions & 2 deletions

File tree

‎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: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,106 @@ 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+
context,
194+
config,
195+
localDescriptors,
196+
localSet
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 parent of at least one local GatewayApi
208+
* artifact are considered, so a workspace that does not track gateway
209+
* associations is left completely untouched. For each such gateway the deployed
210+
* assignments are listed and any assignment missing from the local artifacts is
211+
* queued for deletion (i.e. the API is un-assigned from that gateway).
212+
*/
213+
async function computeGatewayApiDeleteActions(
214+
client: IApimClient,
215+
context: ApimServiceContext,
216+
config: PublishConfig,
217+
localDescriptors: ResourceDescriptor[],
218+
localSet: Set<string>
219+
): Promise<ResourceDescriptor[]> {
220+
const { envMapping } = config;
221+
222+
// Distinct gateway names that own at least one local GatewayApi artifact.
223+
const gatewayNames = new Set<string>();
224+
for (const descriptor of localDescriptors) {
225+
if (descriptor.type === ResourceType.GatewayApi) {
226+
const gatewayName = getNamePart(descriptor.nameParts, 0);
227+
if (gatewayName) {
228+
gatewayNames.add(gatewayName);
229+
}
230+
}
231+
}
232+
233+
if (gatewayNames.size === 0) {
234+
return [];
235+
}
236+
237+
const deletes: ResourceDescriptor[] = [];
238+
239+
for (const gatewayName of gatewayNames) {
240+
const gatewayDescriptor: ResourceDescriptor = {
241+
type: ResourceType.Gateway,
242+
nameParts: [gatewayName],
243+
};
244+
245+
try {
246+
for await (const apiJson of client.listResources(
247+
context,
248+
ResourceType.GatewayApi,
249+
gatewayDescriptor
250+
)) {
251+
const apiName = extractResourceName(apiJson);
252+
if (!apiName) {
253+
continue;
254+
}
255+
256+
const deployedDescriptor: ResourceDescriptor = {
257+
type: ResourceType.GatewayApi,
258+
nameParts: [gatewayName, apiName],
259+
};
260+
261+
if (envMapping !== undefined) {
262+
const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping);
263+
if (canonicalDescriptor === null) {
264+
// Belongs to another environment — do not touch.
265+
continue;
266+
}
267+
if (!localSet.has(getResourceKey(canonicalDescriptor))) {
268+
deletes.push(deployedDescriptor);
269+
}
270+
} else if (!localSet.has(getResourceKey(deployedDescriptor))) {
271+
deletes.push(deployedDescriptor);
272+
}
273+
}
274+
} catch (error) {
275+
logger.debug(
276+
`[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation: ${(error as Error).message}`
277+
);
278+
continue;
279+
}
280+
}
281+
282+
return deletes;
283+
}
284+
183285
/**
184286
* Create a set of resource keys from descriptors for fast lookup
185287
*/

‎src/services/extract-service.ts‎

Lines changed: 29 additions & 1 deletion
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,
@@ -470,6 +470,34 @@ 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+
try {
482+
const apiNames: string[] = [];
483+
for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) {
484+
const name = apiJson.name as string | undefined;
485+
if (name) {
486+
apiNames.push(name);
487+
}
488+
}
489+
// Only record the managed gateway when it actually has assignments. An empty
490+
// apis.json would neither help publish nor delete-unmatched reconciliation
491+
// (which scopes to gateways that own at least one local assignment).
492+
if (apiNames.length > 0) {
493+
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
494+
result.totalExtracted++;
495+
logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`);
496+
}
497+
} catch (error) {
498+
logger.warn(`Failed to extract API associations for managed gateway: ${(error as Error).message}`);
499+
result.totalErrors++;
500+
}
473501
}
474502

475503
/**

‎src/services/resource-publisher.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ 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';
@@ -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) {

‎tests/unit/services/delete-unmatched-service.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,43 @@ describe('delete-unmatched-service', () => {
229229
nameParts: ['api1'],
230230
});
231231
});
232+
233+
it('should un-assign a stale managed gateway API not present in artifacts', async () => {
234+
const apimResources = new Map<ResourceType, Record<string, unknown>[]>([
235+
[ResourceType.GatewayApi, [{ name: 'api-keep' }, { name: 'api-stale' }]],
236+
]);
237+
238+
// Local artifacts track the managed gateway with only api-keep assigned.
239+
const localDescriptors: ResourceDescriptor[] = [
240+
{ type: ResourceType.GatewayApi, nameParts: ['managed', 'api-keep'] },
241+
];
242+
243+
const client = createMockClient(apimResources);
244+
const store = createMockStore(localDescriptors);
245+
246+
const result = await computeDeleteActions(client, store, testContext, testConfig);
247+
248+
const gatewayApiDeletes = result.filter((d) => d.type === ResourceType.GatewayApi);
249+
expect(gatewayApiDeletes).toHaveLength(1);
250+
expect(gatewayApiDeletes[0]).toMatchObject({
251+
type: ResourceType.GatewayApi,
252+
nameParts: ['managed', 'api-stale'],
253+
});
254+
});
255+
256+
it('should not touch any gateway assignments when artifacts track no gateways', async () => {
257+
const apimResources = new Map<ResourceType, Record<string, unknown>[]>([
258+
[ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]],
259+
]);
260+
261+
// No local GatewayApi artifacts → reconciliation must be a no-op.
262+
const client = createMockClient(apimResources);
263+
const store = createMockStore([]);
264+
265+
const result = await computeDeleteActions(client, store, testContext, testConfig);
266+
267+
expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0);
268+
});
232269
});
233270

234271
describe('filterRevisionDeletesHandledByBaseApi', () => {

‎tests/unit/services/extract-service.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,56 @@ describe('extract-service', () => {
604604
);
605605
});
606606

607+
it('should extract managed gateway API associations', async () => {
608+
// Managed gateway is NOT returned by GET /gateways, only its apis are queryable.
609+
const client = createMockClient();
610+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) {
611+
if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') {
612+
yield { name: 'managed-api-1' };
613+
yield { name: 'managed-api-2' };
614+
}
615+
};
616+
617+
const store = createMockStore();
618+
619+
const config: ExtractConfig = {
620+
service: testContext,
621+
outputDir: '/output',
622+
includeTransitive: false,
623+
logLevel: LogLevel.INFO,
624+
};
625+
626+
await runExtraction(client, store, config);
627+
628+
expect(store.writeAssociation).toHaveBeenCalledWith(
629+
expect.anything(),
630+
expect.objectContaining({ type: ResourceType.Gateway, nameParts: ['managed'] }),
631+
'apis',
632+
expect.arrayContaining(['managed-api-1', 'managed-api-2'])
633+
);
634+
});
635+
636+
it('should not write a managed gateway association when it has no apis', async () => {
637+
// Empty managed gateway must not produce an artifact or inflate the count.
638+
const client = createMockClient();
639+
const store = createMockStore();
640+
641+
const result = await runExtraction(client, store, {
642+
service: testContext,
643+
outputDir: '/output',
644+
includeTransitive: false,
645+
logLevel: LogLevel.INFO,
646+
});
647+
648+
expect(store.writeAssociation).not.toHaveBeenCalledWith(
649+
expect.anything(),
650+
expect.objectContaining({ nameParts: ['managed'] }),
651+
'apis',
652+
expect.anything()
653+
);
654+
expect(result.totalExtracted).toBe(0);
655+
});
656+
607657
it('should handle gateway association extraction error gracefully', async () => {
608658
const client = createMockClient({
609659
[ResourceType.Gateway]: [

‎tests/unit/services/resource-publisher.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ describe('resource-publisher', () => {
9898
expect(client.putResource).not.toHaveBeenCalled();
9999
});
100100

101+
it('should skip the built-in managed gateway resource without a PUT', async () => {
102+
const client = createMockClient();
103+
const store = createMockStore();
104+
store.readResource.mockResolvedValue({ name: 'managed', properties: {} });
105+
106+
const descriptor: ResourceDescriptor = {
107+
type: ResourceType.Gateway,
108+
nameParts: ['managed'],
109+
};
110+
111+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
112+
113+
expect(result.status).toBe('skipped');
114+
expect(result.action).toBe('noop');
115+
expect(client.putResource).not.toHaveBeenCalled();
116+
});
117+
118+
101119
it('should return success and call putResource on success', async () => {
102120
const client = createMockClient();
103121
const store = createMockStore();

0 commit comments

Comments
 (0)