diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index bbc82e13e2d7b..436fe376f9624 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -202,15 +202,22 @@ portfolio.addProduct(product); TagOptions allow administrators to easily manage tags on provisioned products by creating a selection of tags for end users to choose from. For example, an end user can choose an `ec2` for the instance type size. -TagOptions are created by specifying a key with a selection of values. +TagOptions are created by specifying a key with a selection of values and can be associated with both portfolios and products. +When launching a product, both the TagOptions associated with the product and the containing portfolio are made available. + At the moment, TagOptions can only be disabled in the console. -```ts fixture=basic-portfolio -const tagOptions = new servicecatalog.TagOptions({ +```ts fixture=portfolio-product +const tagOptionsForPortfolio = new servicecatalog.TagOptions({ + costCenter: ['Data Insights', 'Marketing'], +}); +portfolio.associateTagOptions(tagOptionsForPortfolio); + +const tagOptionsForProduct = new servicecatalog.TagOptions({ ec2InstanceType: ['A1', 'M4'], ec2InstanceSize: ['medium', 'large'], }); -portfolio.associateTagOptions(tagOptions); +product.associateTagOptions(tagOptionsForProduct); ``` ## Constraints diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts b/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts index 36d267d022519..cfe92db543a8e 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts @@ -186,7 +186,7 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio { } public associateTagOptions(tagOptions: TagOptions) { - AssociationManager.associateTagOptions(this, tagOptions); + AssociationManager.associateTagOptions(this, this.portfolioId, tagOptions); } public constrainTagUpdates(product: IProduct, options: TagUpdateConstraintOptions = {}): void { @@ -275,7 +275,7 @@ export interface PortfolioProps { readonly description?: string; /** - * TagOptions associated directly on portfolio + * TagOptions associated directly to a portfolio. * * @default - No tagOptions provided */ diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts index b92fb2483ad54..e1e4ee8de38da 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts @@ -139,27 +139,28 @@ export class AssociationManager { } } - public static associateTagOptions(portfolio: IPortfolio, tagOptions: TagOptions): void { - const portfolioStack = cdk.Stack.of(portfolio); + + public static associateTagOptions(resource: cdk.IResource, resourceId: string, tagOptions: TagOptions): void { + const resourceStack = cdk.Stack.of(resource); for (const [key, tagOptionsList] of Object.entries(tagOptions.tagOptionsMap)) { - InputValidator.validateLength(portfolio.node.addr, 'TagOption key', 1, 128, key); + InputValidator.validateLength(resource.node.addr, 'TagOption key', 1, 128, key); tagOptionsList.forEach((value: string) => { - InputValidator.validateLength(portfolio.node.addr, 'TagOption value', 1, 256, value); - const tagOptionKey = hashValues(key, value, portfolioStack.node.addr); + InputValidator.validateLength(resource.node.addr, 'TagOption value', 1, 256, value); + const tagOptionKey = hashValues(key, value, resourceStack.node.addr); const tagOptionConstructId = `TagOption${tagOptionKey}`; - let cfnTagOption = portfolioStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption; + let cfnTagOption = resourceStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption; if (!cfnTagOption) { - cfnTagOption = new CfnTagOption(portfolioStack, tagOptionConstructId, { + cfnTagOption = new CfnTagOption(resourceStack, tagOptionConstructId, { key: key, value: value, active: true, }); } - const tagAssocationKey = hashValues(key, value, portfolio.node.addr); + const tagAssocationKey = hashValues(key, value, resource.node.addr); const tagAssocationConstructId = `TagOptionAssociation${tagAssocationKey}`; - if (!portfolio.node.tryFindChild(tagAssocationConstructId)) { - new CfnTagOptionAssociation(portfolio as unknown as cdk.Resource, tagAssocationConstructId, { - resourceId: portfolio.portfolioId, + if (!resource.node.tryFindChild(tagAssocationConstructId)) { + new CfnTagOptionAssociation(resource as cdk.Resource, tagAssocationConstructId, { + resourceId: resourceId, tagOptionId: cfnTagOption.ref, }); } diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts index 466e1fa726e55..29a47fc6932a9 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts @@ -1,7 +1,9 @@ import { ArnFormat, IResource, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; +import { TagOptions } from '.'; import { CloudFormationTemplate } from './cloudformation-template'; import { MessageLanguage } from './common'; +import { AssociationManager } from './private/association-manager'; import { InputValidator } from './private/validation'; import { CfnCloudFormationProduct } from './servicecatalog.generated'; @@ -20,11 +22,22 @@ export interface IProduct extends IResource { * @attribute */ readonly productId: string; + + /** + * Associate Tag Options. + * A TagOption is a key-value pair managed in AWS Service Catalog. + * It is not an AWS tag, but serves as a template for creating an AWS tag based on the TagOption. + */ + associateTagOptions(tagOptions: TagOptions): void; } abstract class ProductBase extends Resource implements IProduct { public abstract readonly productArn: string; public abstract readonly productId: string; + + public associateTagOptions(tagOptions: TagOptions) { + AssociationManager.associateTagOptions(this, this.productId, tagOptions); + } } /** @@ -118,6 +131,13 @@ export interface CloudFormationProductProps { * @default - No support URL provided */ readonly supportUrl?: string; + + /** + * TagOptions associated directly to a product. + * + * @default - No tagOptions provided + */ + readonly tagOptions?: TagOptions } /** @@ -170,13 +190,16 @@ export class CloudFormationProduct extends Product { supportUrl: props.supportUrl, }); + this.productId = product.ref; this.productArn = Stack.of(this).formatArn({ service: 'catalog', resource: 'product', resourceName: product.ref, }); - this.productId = product.ref; + if (props.tagOptions !== undefined) { + this.associateTagOptions(props.tagOptions); + } } private renderProvisioningArtifacts( diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json index 5407c293f09b5..c298f292d039d 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json @@ -256,6 +256,39 @@ ] } }, + "TestProductTagOptionAssociation667d45e6d8a1F30303D6": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOptionc0d88a3c4b8b" + } + } + }, + "TestProductTagOptionAssociationec68fcd0154fF6DAD979": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOption9b16df08f83d" + } + } + }, + "TestProductTagOptionAssociation259ba31b62cc63D068F9": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOptiondf34c1c83580" + } + } + }, "Topic198E71B3E": { "Type": "AWS::SNS::Topic" }, diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts index a96c11a45ba3f..669016f35be2a 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts @@ -40,6 +40,7 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { 'https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), }, ], + tagOptions: tagOptions, }); portfolio.addProduct(product); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json index f54d640e1d0ca..fb51ec2ad0df4 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json @@ -218,6 +218,63 @@ } ] } + }, + "TestProductTagOptionAssociation0d813eebb333DA3E2F21": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOptionab501c9aef99" + } + } + }, + "TestProductTagOptionAssociation5d93a5c977b4B664DD87": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOptiona453ac93ee6f" + } + } + }, + "TestProductTagOptionAssociationcfaf40b186a3E5FDECDC": { + "Type": "AWS::ServiceCatalog::TagOptionAssociation", + "Properties": { + "ResourceId": { + "Ref": "TestProduct7606930B" + }, + "TagOptionId": { + "Ref": "TagOptiona006431604cb" + } + } + }, + "TagOptionab501c9aef99": { + "Type": "AWS::ServiceCatalog::TagOption", + "Properties": { + "Key": "key1", + "Value": "value1", + "Active": true + } + }, + "TagOptiona453ac93ee6f": { + "Type": "AWS::ServiceCatalog::TagOption", + "Properties": { + "Key": "key1", + "Value": "value2", + "Active": true + } + }, + "TagOptiona006431604cb": { + "Type": "AWS::ServiceCatalog::TagOption", + "Properties": { + "Key": "key2", + "Value": "value1", + "Active": true + } } }, "Parameters": { diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts index 7a88c98a466d1..e1e08105ee3ce 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts @@ -14,7 +14,7 @@ class TestProductStack extends servicecatalog.ProductStack { } } -new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { +const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { productName: 'testProduct', owner: 'testOwner', productVersions: [ @@ -38,4 +38,11 @@ new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { ], }); +const tagOptions = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value1'], +}); + +product.associateTagOptions(tagOptions); + app.synth(); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts index f399a79dcdb83..0afc91ce86153 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts @@ -271,5 +271,94 @@ describe('Product', () => { productVersions: [], }); }).toThrowError(/Invalid product versions for resource Default\/MyProduct/); + }), + + describe('adding and associating TagOptions to a product', () => { + let product: servicecatalog.IProduct; + + beforeEach(() => { + product = new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + ], + }); + }), + + test('add tag options to product', () => { + const tagOptions = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value1'], + }); + + product.associateTagOptions(tagOptions); + + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3); + }), + + test('add tag options as input to product in props', () => { + const tagOptions = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value1'], + }); + + new servicecatalog.CloudFormationProduct(stack, 'MyProductWithTagOptions', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + ], + tagOptions: tagOptions, + }); + + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3); + }), + + test('adding identical tag options to product is idempotent', () => { + const tagOptions1 = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value1'], + }); + + const tagOptions2 = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + }); + + product.associateTagOptions(tagOptions1); + product.associateTagOptions(tagOptions2); // If not idempotent this would fail + + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3); + }), + + test('adding duplicate tag options to portfolio and product creates unique tag options and enumerated associations', () => { + const tagOptions1 = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value1'], + }); + + const tagOptions2 = new servicecatalog.TagOptions({ + key1: ['value1', 'value2'], + key2: ['value2'], + }); + + const portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolio', { + displayName: 'testPortfolio', + providerName: 'testProvider', + }); + + portfolio.associateTagOptions(tagOptions1); + product.associateTagOptions(tagOptions2); // If not idempotent this would fail + + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 4); //Generates a resource for each unique key-value pair + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 6); + }); }); });