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): allow associating TagOptions with a Portfolio #15612

Merged
merged 8 commits into from
Jul 19, 2021
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ enables organizations to create and manage catalogs of products for their end us
- [Sharing a portfolio with another AWS account](#sharing-a-portfolio-with-another-aws-account)
- [Product](#product)
- [Adding a product to a portfolio](#adding-a-product-to-a-portfolio)
- [TagOptions](#tag-options)
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
- [Constraints](#constraints)
- [Tag update constraint](#tag-update-constraint)

Expand Down Expand Up @@ -157,6 +158,21 @@ A product can be added to multiple portfolios depending on your resource and org
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.
At the moment, TagOptions can only be disabled in the console.

```ts fixture=basic-portfolio
const tagOptions = new servicecatalog.TagOptions({
ec2InstanceType: ['A1', 'M4'],
ec2InstanceSize: ['medium', 'large'],
});
portfolio.associateTagOptions(tagOptions);
```

## Constraints

Constraints define governance mechanisms that allow you to manage permissions, notifications, and options related to actions end users can perform on products,
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './constraints';
export * from './cloudformation-template';
export * from './portfolio';
export * from './product';
export * from './tag-options';

// AWS::ServiceCatalog CloudFormation Resources:
export * from './servicecatalog.generated';
22 changes: 22 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { hashValues } from './private/util';
import { InputValidator } from './private/validation';
import { IProduct } from './product';
import { CfnPortfolio, CfnPortfolioPrincipalAssociation, CfnPortfolioShare } from './servicecatalog.generated';
import { TagOptions } from './tag-options';

// 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
Expand Down Expand Up @@ -79,6 +80,13 @@ export interface IPortfolio extends cdk.IResource {
*/
addProduct(product: IProduct): void;

/**
* 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;

/**
* Add a Resource Update Constraint.
*/
Expand Down Expand Up @@ -116,6 +124,10 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
});
}

public associateTagOptions(tagOptions: TagOptions) {
AssociationManager.associateTagOptions(this, tagOptions);
}

public constrainTagUpdates(product: IProduct, options: TagUpdateConstraintOptions = {}): void {
AssociationManager.constrainTagUpdates(this, product, options);
}
Expand Down Expand Up @@ -170,6 +182,13 @@ export interface PortfolioProps {
* @default - No description provided
*/
readonly description?: string;

/**
* TagOptions associated directly on portfolio
*
* @default - No tagOptions provided
*/
readonly tagOptions?: TagOptions
}

/**
Expand Down Expand Up @@ -226,6 +245,9 @@ export class Portfolio extends PortfolioBase {
resource: 'portfolio',
resourceName: this.portfolioId,
});
if (props.tagOptions !== undefined) {
this.associateTagOptions(props.tagOptions);
}
}

protected generateUniqueHash(value: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as cdk from '@aws-cdk/core';
import { TagUpdateConstraintOptions } from '../constraints';
import { IPortfolio } from '../portfolio';
import { IProduct } from '../product';
import { CfnPortfolioProductAssociation, CfnResourceUpdateConstraint } from '../servicecatalog.generated';
import { CfnPortfolioProductAssociation, CfnResourceUpdateConstraint, CfnTagOption, CfnTagOptionAssociation } from '../servicecatalog.generated';
import { TagOptions } from '../tag-options';
import { hashValues } from './util';
import { InputValidator } from './validation';

Expand Down Expand Up @@ -48,6 +49,34 @@ export class AssociationManager {
}
}

public static associateTagOptions(portfolio: IPortfolio, tagOptions: TagOptions): void {
const portfolioStack = cdk.Stack.of(portfolio);
for (const [key, tagOptionsList] of Object.entries(tagOptions.tagOptionsMap)) {
InputValidator.validateLength(portfolio.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);
const tagOptionConstructId = `TagOption${tagOptionKey}`;
let cfnTagOption = portfolioStack.node.tryFindChild(tagOptionConstructId) as CfnTagOption;
if (!cfnTagOption) {
cfnTagOption = new CfnTagOption(portfolioStack, tagOptionConstructId, {
key: key,
value: value,
active: true,
});
}
const tagAssocationKey = hashValues(key, value, portfolio.node.addr);
const tagAssocationConstructId = `TagOptionAssociation${tagAssocationKey}`;
if (!portfolio.node.tryFindChild(tagAssocationConstructId)) {
new CfnTagOptionAssociation(portfolio as unknown as cdk.Resource, tagAssocationConstructId, {
resourceId: portfolio.portfolioId,
tagOptionId: cfnTagOption.ref,
});
}
});
};
}

private static prettyPrintAssociation(portfolio: IPortfolio, product: IProduct): string {
return `- Portfolio: ${portfolio.node.path} | Product: ${product.node.path}`;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/tag-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Defines a Tag Option, which are similar to tags
* but have multiple values per key.
*/
export class TagOptions {
/**
* List of CfnTagOption
*/
public readonly tagOptionsMap: { [key: string]: string[] };

constructor(tagOptionsMap: { [key: string]: string[]} ) {
this.tagOptionsMap = { ...tagOptionsMap };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,39 @@
"PrincipalType": "IAM"
}
},
"TestPortfolioTagOptionAssociation517ba9dbaf19EA8252F0": {
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
"Properties": {
"ResourceId": {
"Ref": "TestPortfolio4AC794EB"
},
"TagOptionId": {
"Ref": "TagOptionc0d88a3c4b8b"
}
}
},
"TestPortfolioTagOptionAssociationb38e9aae7f1bD3708991": {
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
"Properties": {
"ResourceId": {
"Ref": "TestPortfolio4AC794EB"
},
"TagOptionId": {
"Ref": "TagOption9b16df08f83d"
}
}
},
"TestPortfolioTagOptionAssociationeeabbf0db0e3ADBF0A6D": {
"Type": "AWS::ServiceCatalog::TagOptionAssociation",
"Properties": {
"ResourceId": {
"Ref": "TestPortfolio4AC794EB"
},
"TagOptionId": {
"Ref": "TagOptiondf34c1c83580"
}
}
},
"TestPortfolioPortfolioSharebf5b82f042508F035880": {
"Type": "AWS::ServiceCatalog::PortfolioShare",
"Properties": {
Expand Down Expand Up @@ -109,6 +142,30 @@
"TestPortfolioPortfolioProductAssociationa0185761d231B0D998A7"
]
},
"TagOptionc0d88a3c4b8b": {
"Type": "AWS::ServiceCatalog::TagOption",
"Properties": {
"Key": "key1",
"Value": "value1",
"Active": true
}
},
"TagOption9b16df08f83d": {
"Type": "AWS::ServiceCatalog::TagOption",
"Properties": {
"Key": "key1",
"Value": "value2",
"Active": true
}
},
"TagOptiondf34c1c83580": {
"Type": "AWS::ServiceCatalog::TagOption",
"Properties": {
"Key": "key2",
"Value": "value1",
"Active": true
}
},
"TestProduct7606930B": {
"Type": "AWS::ServiceCatalog::CloudFormationProduct",
"Properties": {
Expand Down
6 changes: 6 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ const portfolio = new servicecatalog.Portfolio(stack, 'TestPortfolio', {
portfolio.giveAccessToRole(role);
portfolio.giveAccessToGroup(group);

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

portfolio.shareWithAccount('123456789012');

const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
Expand Down
75 changes: 75 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,81 @@ describe('portfolio associations and product constraints', () => {
expect(stack).toCountResources('AWS::ServiceCatalog::PortfolioProductAssociation', 1); //check anyway
}),

test('add tag options to portfolio', () => {
const tagOptions = new servicecatalog.TagOptions({
key1: ['value1', 'value2'],
key2: ['value1'],
});

portfolio.associateTagOptions(tagOptions);

expect(stack).toCountResources('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
expect(stack).toHaveResource('AWS::ServiceCatalog::TagOptionAssociation');
}),

test('add tag options to portfolio as prop', () => {
const tagOptions = new servicecatalog.TagOptions({
key1: ['value1', 'value2'],
key2: ['value1'],
});

portfolio = new servicecatalog.Portfolio(stack, 'MyPortfolioWithTag', {
displayName: 'testPortfolio',
providerName: 'testProvider',
tagOptions: tagOptions,
});

expect(stack).toCountResources('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
expect(stack).toHaveResource('AWS::ServiceCatalog::TagOptionAssociation');
}),

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'],
});

portfolio.associateTagOptions(tagOptions1);
portfolio.associateTagOptions(tagOptions2); // If not idempotent this would fail

expect(stack).toCountResources('AWS::ServiceCatalog::TagOption', 3); //Generates a resource for each unique key-value pair
expect(stack).toHaveResource('AWS::ServiceCatalog::TagOptionAssociation');
}),

test('fails to add tag options with invalid minimum key length', () => {
const tagOptions = new servicecatalog.TagOptions({
'': ['value1', 'value2'],
'key2': ['value1'],
});
expect(() => {
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'],
});
expect(() => {
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'],
});
expect(() => {
portfolio.associateTagOptions(tagOptions);
}).toThrowError(/Invalid TagOption value for resource/);
}),

test('add tag update constraint', () => {
portfolio.addProduct(product);
portfolio.constrainTagUpdates(product, {
Expand Down