Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(servicecatalog): Add TagOptions to a CloudformationProduct #17672

Merged
merged 3 commits into from
Nov 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
});