Skip to content

Commit 6922bec

Browse files
committed
add cleanup to auto-generated subscriptions. matching apiops behavior
1 parent e4ae04c commit 6922bec

5 files changed

Lines changed: 283 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Desktop.ini
4040

4141
# Files for integration tests
4242
tests/integration/all-resource-types/logs/**
43-
tests/integration/all-resource-types/extracted-artifacts/**
43+
tests/integration/all-resource-types/extracted-artifacts*/**
4444
tests/integration/all-resource-types/target-apim.json
4545
tests/integration/all-resource-types/source-apim.json
4646
tests/integration/all-resource-types/source-apim-post-activation.bicep

src/services/product-publisher.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ResourceType } from '../models/resource-types.js';
1313
import { publishResource, type ResourcePublishResult } from './resource-publisher.js';
1414
import { logger } from '../lib/logger.js';
1515
import { getNamePart } from '../lib/resource-path.js';
16+
import { parseArmUri } from '../lib/resource-uri.js';
1617

1718
/**
1819
* Publish a Product with all its associations (APIs, Groups, Tags).
@@ -27,13 +28,18 @@ export async function publishProduct(
2728
): Promise<ResourcePublishResult> {
2829
try {
2930
const productName = getNamePart(descriptor.nameParts, 0);
31+
const productExisted = (await client.getResource(context, descriptor)) !== undefined;
3032

3133
// Step 1: Publish the Product itself
3234
const productResult = await publishResource(client, store, context, descriptor, config);
3335
if (productResult.status !== 'success') {
3436
return productResult;
3537
}
3638

39+
if (!productExisted) {
40+
await cleanupAutoCreatedProductResources(client, context, descriptor);
41+
}
42+
3743
// Step 2: Publish ProductApi associations
3844
await publishProductAssociations(
3945
client,
@@ -88,6 +94,125 @@ export async function publishProduct(
8894
}
8995
}
9096

97+
async function cleanupAutoCreatedProductResources(
98+
client: IApimClient,
99+
context: ApimServiceContext,
100+
productDescriptor: ResourceDescriptor
101+
): Promise<void> {
102+
await cleanupProductSubscriptions(client, context, productDescriptor);
103+
await cleanupProductGroups(client, context, productDescriptor);
104+
}
105+
106+
async function cleanupProductSubscriptions(
107+
client: IApimClient,
108+
context: ApimServiceContext,
109+
productDescriptor: ResourceDescriptor
110+
): Promise<void> {
111+
const productName = getNamePart(productDescriptor.nameParts, 0);
112+
const expectedScopeSuffix = `/products/${productName}`;
113+
114+
let deleted = 0;
115+
116+
for await (const subscription of client.listResources(context, ResourceType.Subscription)) {
117+
const descriptor = parseSubscriptionDescriptor(subscription, context);
118+
if (!descriptor || descriptor.workspace !== productDescriptor.workspace) {
119+
continue;
120+
}
121+
122+
const props = subscription.properties as Record<string, unknown> | undefined;
123+
const scope = typeof props?.scope === 'string' ? props.scope : '';
124+
if (!scope.endsWith(expectedScopeSuffix)) {
125+
continue;
126+
}
127+
128+
try {
129+
const removed = await client.deleteResource(context, descriptor);
130+
if (removed) {
131+
deleted++;
132+
logger.debug(`Deleted APIM auto-created product subscription: ${descriptor.nameParts[0]}`);
133+
}
134+
} catch (error) {
135+
logger.warn(
136+
`Failed to delete APIM auto-created product subscription ${descriptor.nameParts[0]}: ${String(error)}`
137+
);
138+
}
139+
}
140+
141+
if (deleted > 0) {
142+
logger.info(`Deleted ${deleted} auto-created subscription(s) for product: ${productName}`);
143+
}
144+
}
145+
146+
async function cleanupProductGroups(
147+
client: IApimClient,
148+
context: ApimServiceContext,
149+
productDescriptor: ResourceDescriptor
150+
): Promise<void> {
151+
const productName = getNamePart(productDescriptor.nameParts, 0);
152+
let deleted = 0;
153+
154+
for await (const productGroup of client.listResources(
155+
context,
156+
ResourceType.ProductGroup,
157+
productDescriptor
158+
)) {
159+
const descriptor = parseProductGroupDescriptor(productGroup, context);
160+
if (!descriptor || descriptor.workspace !== productDescriptor.workspace) {
161+
continue;
162+
}
163+
164+
try {
165+
const removed = await client.deleteResource(context, descriptor);
166+
if (removed) {
167+
deleted++;
168+
}
169+
} catch (error) {
170+
logger.warn(
171+
`Failed to delete auto-created product group ${descriptor.nameParts.join('/')}: ${String(error)}`
172+
);
173+
}
174+
}
175+
176+
if (deleted > 0) {
177+
logger.info(`Deleted ${deleted} auto-created product group(s) for product: ${productName}`);
178+
}
179+
}
180+
181+
function parseSubscriptionDescriptor(
182+
subscription: Record<string, unknown>,
183+
context: ApimServiceContext
184+
): ResourceDescriptor | undefined {
185+
if (typeof subscription.id === 'string') {
186+
const parsed = parseArmUri(subscription.id, context);
187+
if (parsed?.type === ResourceType.Subscription) {
188+
return parsed;
189+
}
190+
}
191+
192+
if (typeof subscription.name === 'string' && subscription.name.length > 0) {
193+
return {
194+
type: ResourceType.Subscription,
195+
nameParts: [subscription.name],
196+
};
197+
}
198+
199+
return undefined;
200+
}
201+
202+
function parseProductGroupDescriptor(
203+
productGroup: Record<string, unknown>,
204+
context: ApimServiceContext
205+
): ResourceDescriptor | undefined {
206+
if (typeof productGroup.id === 'string') {
207+
const parsed = parseArmUri(productGroup.id, context);
208+
if (parsed?.type === ResourceType.ProductGroup) {
209+
return parsed;
210+
}
211+
}
212+
213+
return undefined;
214+
}
215+
91216
/**
92217
* Publish associations (ProductApi or ProductGroup) for a product
93218
*/

src/services/resource-publisher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export async function publishResource(
158158
action: 'noop',
159159
};
160160
}
161-
161+
162162
json = normalizeSubscriptionScope(json, context);
163163
}
164164

tests/unit/services/product-publisher.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ function createMockClient() {
2222
listResources: async function* () {},
2323
getResource: vi.fn(),
2424
putResource: vi.fn().mockResolvedValue(undefined),
25-
deleteResource: vi.fn(),
25+
deleteResource: vi.fn().mockResolvedValue(true),
2626
listApiRevisions: async function* () {},
2727
getApiSpecification: vi.fn(),
28+
validatePreFlight: vi.fn(),
2829
};
2930
}
3031

@@ -62,6 +63,10 @@ const productDescriptor: ResourceDescriptor = {
6263
nameParts: ['my-product'],
6364
};
6465

66+
function generatedSubscriptionId(fill: string): string {
67+
return fill.repeat(24);
68+
}
69+
6570
describe('product-publisher', () => {
6671
describe('publishProduct', () => {
6772
beforeEach(() => {
@@ -277,5 +282,90 @@ describe('product-publisher', () => {
277282
expect(result.error).toBeInstanceOf(Error);
278283
expect(result.error?.message).toBe('Unexpected store error');
279284
});
285+
286+
it('deletes auto-generated product subscriptions after product publish', async () => {
287+
const client = createMockClient();
288+
const store = createMockStore();
289+
const autoGeneratedId = generatedSubscriptionId('c');
290+
client.getResource.mockResolvedValue(undefined);
291+
store.readAssociation.mockResolvedValue([]);
292+
store.readContent.mockResolvedValue(undefined);
293+
294+
client.listResources = async function* () {
295+
yield {
296+
id: `${testContext.baseUrl}/subscriptions/${autoGeneratedId}`,
297+
name: autoGeneratedId,
298+
properties: {
299+
scope: `${testContext.baseUrl}/products/my-product`,
300+
displayName: null,
301+
},
302+
};
303+
};
304+
305+
const result = await publishProduct(client, store, testContext, productDescriptor, testConfig);
306+
307+
expect(result.status).toBe('success');
308+
expect(client.deleteResource).toHaveBeenCalledWith(
309+
testContext,
310+
expect.objectContaining({
311+
type: ResourceType.Subscription,
312+
nameParts: [autoGeneratedId],
313+
})
314+
);
315+
});
316+
317+
it('deletes product-scoped subscriptions on first product creation regardless of displayName', async () => {
318+
const client = createMockClient();
319+
const store = createMockStore();
320+
client.getResource.mockResolvedValue(undefined);
321+
store.readAssociation.mockResolvedValue([]);
322+
store.readContent.mockResolvedValue(undefined);
323+
324+
client.listResources = async function* () {
325+
yield {
326+
id: `${testContext.baseUrl}/subscriptions/src-sub-product`,
327+
name: 'src-sub-product',
328+
properties: {
329+
scope: `${testContext.baseUrl}/products/my-product`,
330+
displayName: 'Kitchen Sink Product Subscription',
331+
},
332+
};
333+
};
334+
335+
const result = await publishProduct(client, store, testContext, productDescriptor, testConfig);
336+
337+
expect(result.status).toBe('success');
338+
expect(client.deleteResource).toHaveBeenCalledWith(
339+
testContext,
340+
expect.objectContaining({
341+
type: ResourceType.Subscription,
342+
nameParts: ['src-sub-product'],
343+
})
344+
);
345+
});
346+
347+
it('does not run cleanup when product already exists', async () => {
348+
const client = createMockClient();
349+
const store = createMockStore();
350+
client.getResource.mockResolvedValue({ name: 'my-product' });
351+
store.readAssociation.mockResolvedValue([]);
352+
store.readContent.mockResolvedValue(undefined);
353+
354+
client.listResources = async function* () {
355+
yield {
356+
id: `${testContext.baseUrl}/subscriptions/${generatedSubscriptionId('d')}`,
357+
name: generatedSubscriptionId('d'),
358+
properties: {
359+
scope: `${testContext.baseUrl}/products/my-product`,
360+
displayName: null,
361+
},
362+
};
363+
};
364+
365+
const result = await publishProduct(client, store, testContext, productDescriptor, testConfig);
366+
367+
expect(result.status).toBe('success');
368+
expect(client.deleteResource).not.toHaveBeenCalled();
369+
});
280370
});
281371
});

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const testConfig: PublishConfig = {
6464
logLevel: LogLevel.INFO,
6565
};
6666

67+
function generatedSubscriptionId(fill: string): string {
68+
return fill.repeat(24);
69+
}
70+
6771
describe('resource-publisher', () => {
6872
describe('publishResource', () => {
6973
beforeEach(() => {
@@ -641,6 +645,67 @@ describe('resource-publisher', () => {
641645
expect(client.putResource).not.toHaveBeenCalled();
642646
});
643647

648+
it('should publish product subscription with empty displayName', async () => {
649+
const client = createMockClient();
650+
const store = createMockStore();
651+
const autoGeneratedId = generatedSubscriptionId('a');
652+
653+
const armScopePrefix =
654+
'/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1';
655+
656+
const subscriptionJson = {
657+
name: autoGeneratedId,
658+
properties: {
659+
ownerId: `${armScopePrefix}/users/1`,
660+
scope: `${armScopePrefix}/products/starter`,
661+
displayName: null,
662+
state: 'active',
663+
},
664+
};
665+
store.readResource.mockResolvedValue(subscriptionJson);
666+
667+
const descriptor: ResourceDescriptor = {
668+
type: ResourceType.Subscription,
669+
nameParts: [autoGeneratedId],
670+
};
671+
672+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
673+
674+
expect(result.status).toBe('success');
675+
expect(result.action).toBe('put');
676+
expect(client.putResource).toHaveBeenCalledTimes(1);
677+
});
678+
679+
it('should publish product subscription when displayName is set', async () => {
680+
const client = createMockClient();
681+
const store = createMockStore();
682+
const autoGeneratedId = generatedSubscriptionId('b');
683+
684+
const armScopePrefix =
685+
'/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1';
686+
687+
const subscriptionJson = {
688+
name: autoGeneratedId,
689+
properties: {
690+
scope: `${armScopePrefix}/products/starter`,
691+
displayName: 'Starter access',
692+
state: 'active',
693+
},
694+
};
695+
store.readResource.mockResolvedValue(subscriptionJson);
696+
697+
const descriptor: ResourceDescriptor = {
698+
type: ResourceType.Subscription,
699+
nameParts: [autoGeneratedId],
700+
};
701+
702+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
703+
704+
expect(result.status).toBe('success');
705+
expect(result.action).toBe('put');
706+
expect(client.putResource).toHaveBeenCalledTimes(1);
707+
});
708+
644709
describe('API revision handling', () => {
645710
it('injects sourceApiId for revision APIs', async () => {
646711
const client = createMockClient();

0 commit comments

Comments
 (0)