Skip to content

Commit

Permalink
feat(servicecatalog): Add TagOptions to a CloudformationProduct (#17672)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
arcrank and dponzo authored Nov 25, 2021
1 parent 4982aca commit 2d19e15
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 19 deletions.
15 changes: 11 additions & 4 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
25 changes: 24 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/lib/product.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 8 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -38,4 +38,11 @@ new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
],
});

const tagOptions = new servicecatalog.TagOptions({
key1: ['value1', 'value2'],
key2: ['value1'],
});

product.associateTagOptions(tagOptions);

app.synth();
89 changes: 89 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/test/product.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

0 comments on commit 2d19e15

Please sign in to comment.