From 2d19e1535586d2b006d43da787ffbb0fad8b4978 Mon Sep 17 00:00:00 2001 From: arcrank Date: Wed, 24 Nov 2021 19:25:35 -0500 Subject: [PATCH] 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 --- .../@aws-cdk/aws-servicecatalog/README.md | 15 +++- .../aws-servicecatalog/lib/portfolio.ts | 4 +- .../lib/private/association-manager.ts | 23 ++--- .../aws-servicecatalog/lib/product.ts | 25 +++++- .../test/integ.portfolio.expected.json | 33 +++++++ .../test/integ.portfolio.ts | 1 + .../test/integ.product.expected.json | 57 ++++++++++++ .../aws-servicecatalog/test/integ.product.ts | 9 +- .../aws-servicecatalog/test/product.test.ts | 89 +++++++++++++++++++ 9 files changed, 237 insertions(+), 19 deletions(-) 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); + }); }); });