Skip to content

Commit 2d19e15

Browse files
arcrankdponzo
andauthoredNov 25, 2021
feat(servicecatalog): Add TagOptions to a CloudformationProduct (#17672)
Users can now associate TagOptions to a cloudformation product through an association call or upon instantiation. TagOptions added to a portfolio are made available for any products within it, but you can also have separate, product level tag options. We only create unique TagOption constructs in the template but we can have the same Tag Option associated with both a portfolio and a product in that portfolio, the logic that resolves this is handled by service catalog. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* Co-authored-by: Dillon Ponzo <dponzo18@gmail.com>
1 parent 4982aca commit 2d19e15

9 files changed

+237
-19
lines changed
 

‎packages/@aws-cdk/aws-servicecatalog/README.md

+11-4
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,22 @@ portfolio.addProduct(product);
202202

203203
TagOptions allow administrators to easily manage tags on provisioned products by creating a selection of tags for end users to choose from.
204204
For example, an end user can choose an `ec2` for the instance type size.
205-
TagOptions are created by specifying a key with a selection of values.
205+
TagOptions are created by specifying a key with a selection of values and can be associated with both portfolios and products.
206+
When launching a product, both the TagOptions associated with the product and the containing portfolio are made available.
207+
206208
At the moment, TagOptions can only be disabled in the console.
207209

208-
```ts fixture=basic-portfolio
209-
const tagOptions = new servicecatalog.TagOptions({
210+
```ts fixture=portfolio-product
211+
const tagOptionsForPortfolio = new servicecatalog.TagOptions({
212+
costCenter: ['Data Insights', 'Marketing'],
213+
});
214+
portfolio.associateTagOptions(tagOptionsForPortfolio);
215+
216+
const tagOptionsForProduct = new servicecatalog.TagOptions({
210217
ec2InstanceType: ['A1', 'M4'],
211218
ec2InstanceSize: ['medium', 'large'],
212219
});
213-
portfolio.associateTagOptions(tagOptions);
220+
product.associateTagOptions(tagOptionsForProduct);
214221
```
215222

216223
## Constraints

‎packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
186186
}
187187

188188
public associateTagOptions(tagOptions: TagOptions) {
189-
AssociationManager.associateTagOptions(this, tagOptions);
189+
AssociationManager.associateTagOptions(this, this.portfolioId, tagOptions);
190190
}
191191

192192
public constrainTagUpdates(product: IProduct, options: TagUpdateConstraintOptions = {}): void {
@@ -275,7 +275,7 @@ export interface PortfolioProps {
275275
readonly description?: string;
276276

277277
/**
278-
* TagOptions associated directly on portfolio
278+
* TagOptions associated directly to a portfolio.
279279
*
280280
* @default - No tagOptions provided
281281
*/

‎packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -139,27 +139,28 @@ export class AssociationManager {
139139
}
140140
}
141141

142-
public static associateTagOptions(portfolio: IPortfolio, tagOptions: TagOptions): void {
143-
const portfolioStack = cdk.Stack.of(portfolio);
142+
143+
public static associateTagOptions(resource: cdk.IResource, resourceId: string, tagOptions: TagOptions): void {
144+
const resourceStack = cdk.Stack.of(resource);
144145
for (const [key, tagOptionsList] of Object.entries(tagOptions.tagOptionsMap)) {
145-
InputValidator.validateLength(portfolio.node.addr, 'TagOption key', 1, 128, key);
146+
InputValidator.validateLength(resource.node.addr, 'TagOption key', 1, 128, key);
146147
tagOptionsList.forEach((value: string) => {
147-
InputValidator.validateLength(portfolio.node.addr, 'TagOption value', 1, 256, value);
148-
const tagOptionKey = hashValues(key, value, portfolioStack.node.addr);
148+
InputValidator.validateLength(resource.node.addr, 'TagOption value', 1, 256, value);
149+
const tagOptionKey = hashValues(key, value, resourceStack.node.addr);
149150
const tagOptionConstructId = `TagOption${tagOptionKey}`;
150-
let cfnTagOption = portfolioStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption;
151+
let cfnTagOption = resourceStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption;
151152
if (!cfnTagOption) {
152-
cfnTagOption = new CfnTagOption(portfolioStack, tagOptionConstructId, {
153+
cfnTagOption = new CfnTagOption(resourceStack, tagOptionConstructId, {
153154
key: key,
154155
value: value,
155156
active: true,
156157
});
157158
}
158-
const tagAssocationKey = hashValues(key, value, portfolio.node.addr);
159+
const tagAssocationKey = hashValues(key, value, resource.node.addr);
159160
const tagAssocationConstructId = `TagOptionAssociation${tagAssocationKey}`;
160-
if (!portfolio.node.tryFindChild(tagAssocationConstructId)) {
161-
new CfnTagOptionAssociation(portfolio as unknown as cdk.Resource, tagAssocationConstructId, {
162-
resourceId: portfolio.portfolioId,
161+
if (!resource.node.tryFindChild(tagAssocationConstructId)) {
162+
new CfnTagOptionAssociation(resource as cdk.Resource, tagAssocationConstructId, {
163+
resourceId: resourceId,
163164
tagOptionId: cfnTagOption.ref,
164165
});
165166
}

‎packages/@aws-cdk/aws-servicecatalog/lib/product.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { ArnFormat, IResource, Resource, Stack } from '@aws-cdk/core';
22
import { Construct } from 'constructs';
3+
import { TagOptions } from '.';
34
import { CloudFormationTemplate } from './cloudformation-template';
45
import { MessageLanguage } from './common';
6+
import { AssociationManager } from './private/association-manager';
57
import { InputValidator } from './private/validation';
68
import { CfnCloudFormationProduct } from './servicecatalog.generated';
79

@@ -20,11 +22,22 @@ export interface IProduct extends IResource {
2022
* @attribute
2123
*/
2224
readonly productId: string;
25+
26+
/**
27+
* Associate Tag Options.
28+
* A TagOption is a key-value pair managed in AWS Service Catalog.
29+
* It is not an AWS tag, but serves as a template for creating an AWS tag based on the TagOption.
30+
*/
31+
associateTagOptions(tagOptions: TagOptions): void;
2332
}
2433

2534
abstract class ProductBase extends Resource implements IProduct {
2635
public abstract readonly productArn: string;
2736
public abstract readonly productId: string;
37+
38+
public associateTagOptions(tagOptions: TagOptions) {
39+
AssociationManager.associateTagOptions(this, this.productId, tagOptions);
40+
}
2841
}
2942

3043
/**
@@ -118,6 +131,13 @@ export interface CloudFormationProductProps {
118131
* @default - No support URL provided
119132
*/
120133
readonly supportUrl?: string;
134+
135+
/**
136+
* TagOptions associated directly to a product.
137+
*
138+
* @default - No tagOptions provided
139+
*/
140+
readonly tagOptions?: TagOptions
121141
}
122142

123143
/**
@@ -170,13 +190,16 @@ export class CloudFormationProduct extends Product {
170190
supportUrl: props.supportUrl,
171191
});
172192

193+
this.productId = product.ref;
173194
this.productArn = Stack.of(this).formatArn({
174195
service: 'catalog',
175196
resource: 'product',
176197
resourceName: product.ref,
177198
});
178199

179-
this.productId = product.ref;
200+
if (props.tagOptions !== undefined) {
201+
this.associateTagOptions(props.tagOptions);
202+
}
180203
}
181204

182205
private renderProvisioningArtifacts(

‎packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json

+33
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,39 @@
256256
]
257257
}
258258
},
259+
"TestProductTagOptionAssociation667d45e6d8a1F30303D6": {
260+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
261+
"Properties": {
262+
"ResourceId": {
263+
"Ref": "TestProduct7606930B"
264+
},
265+
"TagOptionId": {
266+
"Ref": "TagOptionc0d88a3c4b8b"
267+
}
268+
}
269+
},
270+
"TestProductTagOptionAssociationec68fcd0154fF6DAD979": {
271+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
272+
"Properties": {
273+
"ResourceId": {
274+
"Ref": "TestProduct7606930B"
275+
},
276+
"TagOptionId": {
277+
"Ref": "TagOption9b16df08f83d"
278+
}
279+
}
280+
},
281+
"TestProductTagOptionAssociation259ba31b62cc63D068F9": {
282+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
283+
"Properties": {
284+
"ResourceId": {
285+
"Ref": "TestProduct7606930B"
286+
},
287+
"TagOptionId": {
288+
"Ref": "TagOptiondf34c1c83580"
289+
}
290+
}
291+
},
259292
"Topic198E71B3E": {
260293
"Type": "AWS::SNS::Topic"
261294
},

‎packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
4040
'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'),
4141
},
4242
],
43+
tagOptions: tagOptions,
4344
});
4445

4546
portfolio.addProduct(product);

‎packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json

+57
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,63 @@
218218
}
219219
]
220220
}
221+
},
222+
"TestProductTagOptionAssociation0d813eebb333DA3E2F21": {
223+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
224+
"Properties": {
225+
"ResourceId": {
226+
"Ref": "TestProduct7606930B"
227+
},
228+
"TagOptionId": {
229+
"Ref": "TagOptionab501c9aef99"
230+
}
231+
}
232+
},
233+
"TestProductTagOptionAssociation5d93a5c977b4B664DD87": {
234+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
235+
"Properties": {
236+
"ResourceId": {
237+
"Ref": "TestProduct7606930B"
238+
},
239+
"TagOptionId": {
240+
"Ref": "TagOptiona453ac93ee6f"
241+
}
242+
}
243+
},
244+
"TestProductTagOptionAssociationcfaf40b186a3E5FDECDC": {
245+
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
246+
"Properties": {
247+
"ResourceId": {
248+
"Ref": "TestProduct7606930B"
249+
},
250+
"TagOptionId": {
251+
"Ref": "TagOptiona006431604cb"
252+
}
253+
}
254+
},
255+
"TagOptionab501c9aef99": {
256+
"Type": "AWS::ServiceCatalog::TagOption",
257+
"Properties": {
258+
"Key": "key1",
259+
"Value": "value1",
260+
"Active": true
261+
}
262+
},
263+
"TagOptiona453ac93ee6f": {
264+
"Type": "AWS::ServiceCatalog::TagOption",
265+
"Properties": {
266+
"Key": "key1",
267+
"Value": "value2",
268+
"Active": true
269+
}
270+
},
271+
"TagOptiona006431604cb": {
272+
"Type": "AWS::ServiceCatalog::TagOption",
273+
"Properties": {
274+
"Key": "key2",
275+
"Value": "value1",
276+
"Active": true
277+
}
221278
}
222279
},
223280
"Parameters": {

‎packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class TestProductStack extends servicecatalog.ProductStack {
1414
}
1515
}
1616

17-
new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
17+
const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
1818
productName: 'testProduct',
1919
owner: 'testOwner',
2020
productVersions: [
@@ -38,4 +38,11 @@ new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
3838
],
3939
});
4040

41+
const tagOptions = new servicecatalog.TagOptions({
42+
key1: ['value1', 'value2'],
43+
key2: ['value1'],
44+
});
45+
46+
product.associateTagOptions(tagOptions);
47+
4148
app.synth();

‎packages/@aws-cdk/aws-servicecatalog/test/product.test.ts

+89
Original file line numberDiff line numberDiff line change
@@ -271,5 +271,94 @@ describe('Product', () => {
271271
productVersions: [],
272272
});
273273
}).toThrowError(/Invalid product versions for resource Default\/MyProduct/);
274+
}),
275+
276+
describe('adding and associating TagOptions to a product', () => {
277+
let product: servicecatalog.IProduct;
278+
279+
beforeEach(() => {
280+
product = new servicecatalog.CloudFormationProduct(stack, 'MyProduct', {
281+
productName: 'testProduct',
282+
owner: 'testOwner',
283+
productVersions: [
284+
{
285+
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'),
286+
},
287+
],
288+
});
289+
}),
290+
291+
test('add tag options to product', () => {
292+
const tagOptions = new servicecatalog.TagOptions({
293+
key1: ['value1', 'value2'],
294+
key2: ['value1'],
295+
});
296+
297+
product.associateTagOptions(tagOptions);
298+
299+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
300+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3);
301+
}),
302+
303+
test('add tag options as input to product in props', () => {
304+
const tagOptions = new servicecatalog.TagOptions({
305+
key1: ['value1', 'value2'],
306+
key2: ['value1'],
307+
});
308+
309+
new servicecatalog.CloudFormationProduct(stack, 'MyProductWithTagOptions', {
310+
productName: 'testProduct',
311+
owner: 'testOwner',
312+
productVersions: [
313+
{
314+
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'),
315+
},
316+
],
317+
tagOptions: tagOptions,
318+
});
319+
320+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
321+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3);
322+
}),
323+
324+
test('adding identical tag options to product is idempotent', () => {
325+
const tagOptions1 = new servicecatalog.TagOptions({
326+
key1: ['value1', 'value2'],
327+
key2: ['value1'],
328+
});
329+
330+
const tagOptions2 = new servicecatalog.TagOptions({
331+
key1: ['value1', 'value2'],
332+
});
333+
334+
product.associateTagOptions(tagOptions1);
335+
product.associateTagOptions(tagOptions2); // If not idempotent this would fail
336+
337+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
338+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3);
339+
}),
340+
341+
test('adding duplicate tag options to portfolio and product creates unique tag options and enumerated associations', () => {
342+
const tagOptions1 = new servicecatalog.TagOptions({
343+
key1: ['value1', 'value2'],
344+
key2: ['value1'],
345+
});
346+
347+
const tagOptions2 = new servicecatalog.TagOptions({
348+
key1: ['value1', 'value2'],
349+
key2: ['value2'],
350+
});
351+
352+
const portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolio', {
353+
displayName: 'testPortfolio',
354+
providerName: 'testProvider',
355+
});
356+
357+
portfolio.associateTagOptions(tagOptions1);
358+
product.associateTagOptions(tagOptions2); // If not idempotent this would fail
359+
360+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 4); //Generates a resource for each unique key-value pair
361+
Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 6);
362+
});
274363
});
275364
});

0 commit comments

Comments
 (0)
Please sign in to comment.