@@ -36,7 +36,7 @@ import {
3636 isApiRevisionName ,
3737} from '../lib/resource-path.js' ;
3838import { 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 */
0 commit comments