From 7ae32a538a4555b82781172acd530a1a01edbf8b Mon Sep 17 00:00:00 2001 From: Aidan Crank Date: Tue, 23 Nov 2021 20:58:10 -0500 Subject: [PATCH 1/2] feat(servicecatalog): Add tag options to a CloudformationProduct Users can now associate tag options to a cloudformation product through an association call or upon instantiation. Tag options addded 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 tag option 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 Co-authored-by: Dillon Ponzo --- .../@aws-cdk/aws-servicecatalog/README.md | 17 +++- .../aws-servicecatalog/lib/portfolio.ts | 4 +- .../lib/private/association-manager.ts | 24 ++--- .../aws-servicecatalog/lib/product.ts | 24 +++++ .../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, 240 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index 2d7694d3e84b6..f8224984b1a3d 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -202,15 +202,24 @@ 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: ['A', 'B'], +}); + +const tagOptionsForProduct = new servicecatalog.TagOptions({ ec2InstanceType: ['A1', 'M4'], ec2InstanceSize: ['medium', 'large'], }); -portfolio.associateTagOptions(tagOptions); + +portfolio.associateTagOptions(tagOptionsForPortfolio); +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 3056a48e19777..021db176f91ec 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts @@ -160,7 +160,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 { @@ -235,7 +235,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 bf5a68a8e70d3..fc0284e1d0a3b 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts @@ -1,6 +1,7 @@ import * as iam from '@aws-cdk/aws-iam'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; +import { IResource } from '@aws-cdk/core'; import { CloudFormationRuleConstraintOptions, CommonConstraintOptions, StackSetsConstraintOptions, TagUpdateConstraintOptions, TemplateRule, TemplateRuleAssertion, @@ -151,27 +152,28 @@ export class AssociationManager { } } - public static associateTagOptions(portfolio: IPortfolio, tagOptions: TagOptions): void { - const portfolioStack = cdk.Stack.of(portfolio); + + public static associateTagOptions(resource: 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..b3c9ebaae621c 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 } /** @@ -177,6 +197,10 @@ export class CloudFormationProduct extends Product { }); 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); + }); }); }); From a3db0bfa540ffcc62964814a9fd373a12ad7fd67 Mon Sep 17 00:00:00 2001 From: Aidan Crank Date: Wed, 24 Nov 2021 15:02:02 -0500 Subject: [PATCH 2/2] addressing comments --- packages/@aws-cdk/aws-servicecatalog/README.md | 10 ++++------ .../lib/private/association-manager.ts | 3 +-- packages/@aws-cdk/aws-servicecatalog/lib/product.ts | 3 +-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index f8224984b1a3d..2e88657874e29 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -202,23 +202,21 @@ 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, -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. +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=portfolio-product const tagOptionsForPortfolio = new servicecatalog.TagOptions({ - costCenter: ['A', 'B'], + costCenter: ['Data Insights', 'Marketing'], }); +portfolio.associateTagOptions(tagOptionsForPortfolio); const tagOptionsForProduct = new servicecatalog.TagOptions({ ec2InstanceType: ['A1', 'M4'], ec2InstanceSize: ['medium', 'large'], }); - -portfolio.associateTagOptions(tagOptionsForPortfolio); product.associateTagOptions(tagOptionsForProduct); ``` 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 fc0284e1d0a3b..bf26c3e8cf4ad 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts @@ -1,7 +1,6 @@ import * as iam from '@aws-cdk/aws-iam'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; -import { IResource } from '@aws-cdk/core'; import { CloudFormationRuleConstraintOptions, CommonConstraintOptions, StackSetsConstraintOptions, TagUpdateConstraintOptions, TemplateRule, TemplateRuleAssertion, @@ -153,7 +152,7 @@ export class AssociationManager { } - public static associateTagOptions(resource: IResource, resourceId: string, tagOptions: TagOptions): void { + 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(resource.node.addr, 'TagOption key', 1, 128, key); diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts index b3c9ebaae621c..29a47fc6932a9 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts @@ -190,14 +190,13 @@ 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); }