From bbf057124b34fa976d57f368f43417d7758c4450 Mon Sep 17 00:00:00 2001 From: arcrank Date: Mon, 24 Jan 2022 21:43:13 -0500 Subject: [PATCH] feat(servicecatalog): Create TagOptions Construct (#18314) Fixes: [#17753](https://github.com/aws/aws-cdk/issues/17753) Previously TagOptions were defined via an interface and we only created the underlying resources upon an association. This broke CX if tagoptions were mangaged centrally. We move to make the TagOptions class a wrapper around aggregate individual TagOptions. BREAKING CHANGE: `TagOptions` now have `scope` and `props` argument in constructor, and data is now passed via a `allowedValueForTags` field in props ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-servicecatalog/README.md | 17 +- .../lib/private/association-manager.ts | 37 ++-- .../aws-servicecatalog/lib/product.ts | 4 +- .../aws-servicecatalog/lib/tag-options.ts | 72 +++++++- .../@aws-cdk/aws-servicecatalog/package.json | 8 +- .../test/integ.portfolio.expected.json | 18 +- .../test/integ.portfolio.ts | 8 +- .../test/integ.product.expected.json | 12 +- .../aws-servicecatalog/test/integ.product.ts | 8 +- .../aws-servicecatalog/test/portfolio.test.ts | 72 ++++---- .../aws-servicecatalog/test/product.test.ts | 59 ++++--- .../test/tag-option.test.ts | 163 ++++++++++++++++++ 12 files changed, 351 insertions(+), 127 deletions(-) create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/tag-option.test.ts diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index 436fe376f9624..12e57cbc200ee 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -201,21 +201,24 @@ portfolio.addProduct(product); ## Tag Options 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. +TagOptions are created by specifying a tag key with a selection of allowed 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: ['Data Insights', 'Marketing'], +const tagOptionsForPortfolio = new servicecatalog.TagOptions(this, 'OrgTagOptions', { + allowedValuesForTags: { + Group: ['finance', 'engineering', 'marketing', 'research'], + CostCenter: ['01', '02','03'], + }, }); portfolio.associateTagOptions(tagOptionsForPortfolio); -const tagOptionsForProduct = new servicecatalog.TagOptions({ - ec2InstanceType: ['A1', 'M4'], - ec2InstanceSize: ['medium', 'large'], +const tagOptionsForProduct = new servicecatalog.TagOptions(this, 'ProductTagOptions', { + allowedValuesForTags: { + Environment: ['dev', 'alpha', 'prod'], + }, }); 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 e1e4ee8de38da..dd44ef0f022fc 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts @@ -9,7 +9,7 @@ import { IPortfolio } from '../portfolio'; import { IProduct } from '../product'; import { CfnLaunchNotificationConstraint, CfnLaunchRoleConstraint, CfnLaunchTemplateConstraint, CfnPortfolioProductAssociation, - CfnResourceUpdateConstraint, CfnStackSetConstraint, CfnTagOption, CfnTagOptionAssociation, + CfnResourceUpdateConstraint, CfnStackSetConstraint, CfnTagOptionAssociation, } from '../servicecatalog.generated'; import { TagOptions } from '../tag-options'; import { hashValues } from './util'; @@ -139,33 +139,16 @@ export class AssociationManager { } } - 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); - tagOptionsList.forEach((value: string) => { - InputValidator.validateLength(resource.node.addr, 'TagOption value', 1, 256, value); - const tagOptionKey = hashValues(key, value, resourceStack.node.addr); - const tagOptionConstructId = `TagOption${tagOptionKey}`; - let cfnTagOption = resourceStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption; - if (!cfnTagOption) { - cfnTagOption = new CfnTagOption(resourceStack, tagOptionConstructId, { - key: key, - value: value, - active: true, - }); - } - const tagAssocationKey = hashValues(key, value, resource.node.addr); - const tagAssocationConstructId = `TagOptionAssociation${tagAssocationKey}`; - if (!resource.node.tryFindChild(tagAssocationConstructId)) { - new CfnTagOptionAssociation(resource as cdk.Resource, tagAssocationConstructId, { - resourceId: resourceId, - tagOptionId: cfnTagOption.ref, - }); - } - }); - }; + for (const cfnTagOption of tagOptions._cfnTagOptions) { + const tagAssocationConstructId = `TagOptionAssociation${hashValues(cfnTagOption.key, cfnTagOption.value, resource.node.addr)}`; + if (!resource.node.tryFindChild(tagAssocationConstructId)) { + new CfnTagOptionAssociation(resource as cdk.Resource, tagAssocationConstructId, { + resourceId: resourceId, + tagOptionId: cfnTagOption.ref, + }); + } + } } private static setLaunchRoleConstraint( diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts index 29a47fc6932a9..3c4e8bd9fb59f 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product.ts @@ -1,11 +1,11 @@ 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'; +import { TagOptions } from './tag-options'; /** * A Service Catalog product, currently only supports type CloudFormationProduct @@ -137,7 +137,7 @@ export interface CloudFormationProductProps { * * @default - No tagOptions provided */ - readonly tagOptions?: TagOptions + readonly tagOptions?: TagOptions; } /** diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/tag-options.ts b/packages/@aws-cdk/aws-servicecatalog/lib/tag-options.ts index 808ea78add4a3..b0342c6336bd3 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/tag-options.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/tag-options.ts @@ -1,14 +1,70 @@ +import * as cdk from '@aws-cdk/core'; +import { hashValues } from './private/util'; +import { InputValidator } from './private/validation'; +import { CfnTagOption } from './servicecatalog.generated'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from 'constructs'; + +/** + * Properties for TagOptions. + */ +export interface TagOptionsProps { + /** + * The values that are allowed to be set for specific tags. + * The keys of the map represent the tag keys, + * and the values of the map are a list of allowed values for that particular tag key. + */ + readonly allowedValuesForTags: { [tagKey: string]: string[] }; +} + /** - * Defines a Tag Option, which are similar to tags - * but have multiple values per key. + * Defines a set of TagOptions, which are a list of key-value pairs 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. + * See https://docs.aws.amazon.com/servicecatalog/latest/adminguide/tagoptions.html + * + * @resource AWS::ServiceCatalog::TagOption */ -export class TagOptions { +export class TagOptions extends cdk.Resource { /** - * List of CfnTagOption - */ - public readonly tagOptionsMap: { [key: string]: string[] }; + * List of underlying CfnTagOption resources. + * + * @internal + */ + public _cfnTagOptions: CfnTagOption[]; - constructor(tagOptionsMap: { [key: string]: string[]} ) { - this.tagOptionsMap = { ...tagOptionsMap }; + constructor(scope: Construct, id: string, props: TagOptionsProps) { + super(scope, id); + + this._cfnTagOptions = this.createUnderlyingTagOptions(props.allowedValuesForTags); + } + + private createUnderlyingTagOptions(allowedValuesForTags: { [tagKey: string]: string[] }): CfnTagOption[] { + if (Object.keys(allowedValuesForTags).length === 0) { + throw new Error(`No tag option keys or values were provided for resource ${this.node.path}`); + } + var tagOptions: CfnTagOption[] = []; + + for (const [tagKey, tagValues] of Object.entries(allowedValuesForTags)) { + InputValidator.validateLength(this.node.addr, 'TagOption key', 1, 128, tagKey); + + const uniqueTagValues = new Set(tagValues); + if (uniqueTagValues.size === 0) { + throw new Error(`No tag option values were provided for tag option key ${tagKey} for resource ${this.node.path}`); + } + uniqueTagValues.forEach((tagValue: string) => { + InputValidator.validateLength(this.node.addr, 'TagOption value', 1, 256, tagValue); + const tagOptionIdentifier = hashValues(tagKey, tagValue); + const tagOption = new CfnTagOption(this, tagOptionIdentifier, { + key: tagKey, + value: tagValue, + active: true, + }); + tagOptions.push(tagOption); + }); + } + return tagOptions; } } + diff --git a/packages/@aws-cdk/aws-servicecatalog/package.json b/packages/@aws-cdk/aws-servicecatalog/package.json index 13567c9c5c88d..e11b48a0c3392 100644 --- a/packages/@aws-cdk/aws-servicecatalog/package.json +++ b/packages/@aws-cdk/aws-servicecatalog/package.json @@ -105,7 +105,13 @@ "props-physical-name:@aws-cdk/aws-servicecatalog.CloudFormationProductProps", "resource-attribute:@aws-cdk/aws-servicecatalog.Portfolio.portfolioName", "props-physical-name:@aws-cdk/aws-servicecatalog.PortfolioProps", - "props-physical-name:@aws-cdk/aws-servicecatalog.ProductStack" + "props-physical-name:@aws-cdk/aws-servicecatalog.ProductStack", + "props-struct-name:@aws-cdk/aws-servicecatalog.ITagOptions", + "props-physical-name:@aws-cdk/aws-servicecatalog.TagOptionsProps", + "ref-via-interface:@aws-cdk/aws-servicecatalog.CloudFormationProductProps.tagOptions", + "ref-via-interface:@aws-cdk/aws-servicecatalog.IProduct.associateTagOptions.tagOptions", + "ref-via-interface:@aws-cdk/aws-servicecatalog.IPortfolio.associateTagOptions.tagOptions", + "ref-via-interface:@aws-cdk/aws-servicecatalog.PortfolioProps.tagOptions" ] }, "maturity": "experimental", 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 c298f292d039d..c25d867209591 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.expected.json @@ -81,7 +81,7 @@ "Ref": "TestPortfolio4AC794EB" }, "TagOptionId": { - "Ref": "TagOptionc0d88a3c4b8b" + "Ref": "TagOptions5f31c54ba705F110F743" } } }, @@ -92,7 +92,7 @@ "Ref": "TestPortfolio4AC794EB" }, "TagOptionId": { - "Ref": "TagOption9b16df08f83d" + "Ref": "TagOptions8d263919cebb6764AC10" } } }, @@ -103,7 +103,7 @@ "Ref": "TestPortfolio4AC794EB" }, "TagOptionId": { - "Ref": "TagOptiondf34c1c83580" + "Ref": "TagOptionsa260cbbd99c416C40F73" } } }, @@ -217,7 +217,7 @@ "TestPortfolioPortfolioProductAssociationa0185761d231B0D998A7" ] }, - "TagOptionc0d88a3c4b8b": { + "TagOptions5f31c54ba705F110F743": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key1", @@ -225,7 +225,7 @@ "Active": true } }, - "TagOption9b16df08f83d": { + "TagOptions8d263919cebb6764AC10": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key1", @@ -233,7 +233,7 @@ "Active": true } }, - "TagOptiondf34c1c83580": { + "TagOptionsa260cbbd99c416C40F73": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key2", @@ -263,7 +263,7 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOptionc0d88a3c4b8b" + "Ref": "TagOptions5f31c54ba705F110F743" } } }, @@ -274,7 +274,7 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOption9b16df08f83d" + "Ref": "TagOptions8d263919cebb6764AC10" } } }, @@ -285,7 +285,7 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOptiondf34c1c83580" + "Ref": "TagOptionsa260cbbd99c416C40F73" } } }, diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts index 669016f35be2a..c5941dec42062 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts @@ -22,9 +22,11 @@ const portfolio = new servicecatalog.Portfolio(stack, 'TestPortfolio', { portfolio.giveAccessToRole(role); portfolio.giveAccessToGroup(group); -const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], +const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); portfolio.associateTagOptions(tagOptions); 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 fb51ec2ad0df4..05ea621ec10fd 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json @@ -226,7 +226,7 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOptionab501c9aef99" + "Ref": "TagOptions5f31c54ba705F110F743" } } }, @@ -237,7 +237,7 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOptiona453ac93ee6f" + "Ref": "TagOptions8d263919cebb6764AC10" } } }, @@ -248,11 +248,11 @@ "Ref": "TestProduct7606930B" }, "TagOptionId": { - "Ref": "TagOptiona006431604cb" + "Ref": "TagOptionsa260cbbd99c416C40F73" } } }, - "TagOptionab501c9aef99": { + "TagOptions5f31c54ba705F110F743": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key1", @@ -260,7 +260,7 @@ "Active": true } }, - "TagOptiona453ac93ee6f": { + "TagOptions8d263919cebb6764AC10": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key1", @@ -268,7 +268,7 @@ "Active": true } }, - "TagOptiona006431604cb": { + "TagOptionsa260cbbd99c416C40F73": { "Type": "AWS::ServiceCatalog::TagOption", "Properties": { "Key": "key2", diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts index e1e08105ee3ce..22429b3ddbf83 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts @@ -38,9 +38,11 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { ], }); -const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], +const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); product.associateTagOptions(tagOptions); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts index 43a283c157f80..74406931a069f 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts @@ -304,9 +304,11 @@ describe('portfolio associations and product constraints', () => { }), test('add tag options to portfolio', () => { - const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); portfolio.associateTagOptions(tagOptions); @@ -316,9 +318,11 @@ describe('portfolio associations and product constraints', () => { }), test('add tag options to portfolio as prop', () => { - const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolioWithTag', { @@ -331,49 +335,55 @@ describe('portfolio associations and product constraints', () => { Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 3); }), - test('adding identical tag options to portfolio is idempotent', () => { - const tagOptions1 = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], - }); - - const tagOptions2 = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], + test('adding tag options to portfolio multiple times is idempotent', () => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); - portfolio.associateTagOptions(tagOptions1); - portfolio.associateTagOptions(tagOptions2); // If not idempotent this would fail + portfolio.associateTagOptions(tagOptions); + portfolio.associateTagOptions(tagOptions); // 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('fails to add tag options with invalid minimum key length', () => { - const tagOptions = new servicecatalog.TagOptions({ - '': ['value1', 'value2'], - 'key2': ['value1'], - }); + test('fails to create and then add tag options with invalid minimum key length', () => { expect(() => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + '': ['value1', 'value2'], + 'key2': ['value1'], + }, + }); + portfolio.associateTagOptions(tagOptions); }).toThrowError(/Invalid TagOption key for resource/); }); - test('fails to add tag options with invalid maxium key length', () => { - const tagOptions = new servicecatalog.TagOptions({ - ['key1'.repeat(1000)]: ['value1', 'value2'], - key2: ['value1'], - }); + test('fails to create and then add tag options with invalid maxium key length', () => { expect(() => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + ['key1'.repeat(1000)]: ['value1', 'value2'], + key2: ['value1'], + }, + }); + portfolio.associateTagOptions(tagOptions); }).toThrowError(/Invalid TagOption key for resource/); }), - test('fails to add tag options with invalid value length', () => { - const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1'.repeat(1000), 'value2'], - key2: ['value1'], - }); + test('fails to create and then add tag options with invalid value length', () => { expect(() => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1'.repeat(1000), 'value2'], + key2: ['value1'], + }, + }); portfolio.associateTagOptions(tagOptions); }).toThrowError(/Invalid TagOption value for resource/); }), diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts index 0afc91ce86153..691ae9d14d9f1 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts @@ -271,7 +271,7 @@ describe('Product', () => { productVersions: [], }); }).toThrowError(/Invalid product versions for resource Default\/MyProduct/); - }), + }); describe('adding and associating TagOptions to a product', () => { let product: servicecatalog.IProduct; @@ -286,12 +286,14 @@ describe('Product', () => { }, ], }); - }), + }); test('add tag options to product', () => { - const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); product.associateTagOptions(tagOptions); @@ -301,9 +303,11 @@ describe('Product', () => { }), test('add tag options as input to product in props', () => { - const tagOptions = new servicecatalog.TagOptions({ - key1: ['value1', 'value2'], - key2: ['value1'], + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); new servicecatalog.CloudFormationProduct(stack, 'MyProductWithTagOptions', { @@ -321,32 +325,27 @@ describe('Product', () => { 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'], + test('adding tag options to product multiple times is idempotent', () => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); - product.associateTagOptions(tagOptions1); - product.associateTagOptions(tagOptions2); // If not idempotent this would fail + product.associateTagOptions(tagOptions); + product.associateTagOptions(tagOptions); // 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'], + test('adding tag options to portfolio and product creates unique tag options and enumerated associations', () => { + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1'], + }, }); const portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolio', { @@ -354,10 +353,10 @@ describe('Product', () => { providerName: 'testProvider', }); - portfolio.associateTagOptions(tagOptions1); - product.associateTagOptions(tagOptions2); // If not idempotent this would fail + portfolio.associateTagOptions(tagOptions); + product.associateTagOptions(tagOptions); - Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 4); //Generates a resource for each unique key-value pair + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOptionAssociation', 6); }); }); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/tag-option.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/tag-option.test.ts new file mode 100644 index 0000000000000..67f601ed6e521 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/tag-option.test.ts @@ -0,0 +1,163 @@ +import { Template } from '@aws-cdk/assertions'; +import * as cdk from '@aws-cdk/core'; +import * as servicecatalog from '../lib'; + +describe('TagOptions', () => { + let app: cdk.App; + let stack: cdk.Stack; + + beforeEach(() => { + app = new cdk.App(); + stack = new cdk.Stack(app); + }); + + describe('creating tagOption(s)', () => { + test('default tagOptions creation', () => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1', 'value2', 'value3'], + }, + }); + + Template.fromStack(stack).resourceCountIs('AWS::ServiceCatalog::TagOption', 5); + }), + + test('fails to create tag option with invalid minimum key length', () => { + expect(() => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + '': ['value1', 'value2'], + }, + }); + }).toThrowError(/Invalid TagOption key for resource/); + }), + + test('fails to create tag option with invalid maxium key length', () => { + expect(() => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + ['longKey'.repeat(1000)]: ['value1', 'value2'], + }, + }); + }).toThrowError(/Invalid TagOption key for resource/); + }), + + test('fails to create tag option with invalid value length', () => { + expect(() => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key: ['tagOptionValue'.repeat(1000)], + }, + }); + }).toThrowError(/Invalid TagOption value for resource/); + }), + + test('fails to create tag options with no tag keys or values', () => { + expect(() => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: {}, + }); + }).toThrowError(/No tag option keys or values were provided/); + }), + + test('fails to create tag options for tag key with no values', () => { + expect(() => { + new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: [], + }, + }); + }).toThrowError(/No tag option values were provided for tag option key/); + }), + + test('associate tag options', () => { + const portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolio', { + displayName: 'testPortfolio', + providerName: 'testProvider', + }); + + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1', 'value2', 'value3'], + }, + }); + portfolio.associateTagOptions(tagOptions); + + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOption', 5); + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOptionAssociation', 5); + }), + + test('creating tag options with duplicate values is idempotent', () => { + const portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolio', { + displayName: 'testPortfolio', + providerName: 'testProvider', + }); + + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2', 'value2'], + key2: ['value1', 'value2', 'value3', 'value3'], + }, + }); + portfolio.associateTagOptions(tagOptions); + + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOption', 5); + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOptionAssociation', 5); + }), + + test('create and associate tag options to different resources', () => { + const portfolio1 = new servicecatalog.Portfolio(stack, 'MyPortfolio1', { + displayName: 'testPortfolio1', + providerName: 'testProvider1', + }); + + const portfolio2 = new servicecatalog.Portfolio(stack, 'MyPortfolio2', { + displayName: 'testPortfolio2', + providerName: 'testProvider2', + }); + + const tagOptions = new servicecatalog.TagOptions(stack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1', 'value2', 'value3'], + }, + }); + + portfolio1.associateTagOptions(tagOptions); + portfolio2.associateTagOptions(tagOptions); + + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOption', 5); + Template.fromStack(stack).hasResource('AWS::ServiceCatalog::TagOptionAssociation', 10); + }), + + test('create and associate tag options in another stack', () => { + const tagOptionsStack = new cdk.Stack(app, 'TagOptionsStack'); + const productStack = new cdk.Stack(app, 'ProductStack'); + + const tagOptions = new servicecatalog.TagOptions(tagOptionsStack, 'TagOptions', { + allowedValuesForTags: { + key1: ['value1', 'value2'], + key2: ['value1', 'value2', 'value3'], + }, + }); + + new servicecatalog.CloudFormationProduct(productStack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl('https://awsdocs.s3.amazonaws.com/servicecatalog/development-environment.template'), + }, + ], + tagOptions: tagOptions, + }); + + Template.fromStack(tagOptionsStack).hasResource('AWS::ServiceCatalog::TagOption', 5); + Template.fromStack(productStack).resourceCountIs('AWS::ServiceCatalog::TagOption', 0); + Template.fromStack(productStack).hasResource('AWS::ServiceCatalog::TagOptionAssociation', 5); + }); + }); +});