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 CloudFormation Parameter constraint #15770

Merged
merged 19 commits into from
Jul 30, 2021
Merged
Show file tree
Hide file tree
Changes from 18 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
29 changes: 28 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ enables organizations to create and manage catalogs of products for their end us
- [Constraints](#constraints)
- [Tag update constraint](#tag-update-constraint)
- [Notify on stack events](#notify-on-stack-events)
- [CloudFormation parameters constraint](#cloudformation-parameters-constraint)
- [Set launch role](#set-launch-role)
- [Deploy with StackSets](#deploy-with-stacksets)

Expand Down Expand Up @@ -162,7 +163,7 @@ A product can be added to multiple portfolios depending on your resource and org
portfolio.addProduct(product);
```

### Tag Options
## 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.
Expand Down Expand Up @@ -228,6 +229,32 @@ portfolio.notifyOnStackEvents(product, topic2, {
});
```

### CloudFormation parameters constraint

CloudFormation parameters constraints allow you to configure the that are available to end users when they launch a product via defined rules.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing word?

Suggested change
CloudFormation parameters constraints allow you to configure the that are available to end users when they launch a product via defined rules.
CloudFormation parameters constraints allow you to configure the values that are available to end users when they launch a product via defined rules.

A rule consists of one or more assertions that narrow the allowable values for parameters in a product.
You can configure multiple parameter constraints to govern the different parameters and parameter options in your products.
For example, a rule might define the various instance types that users can choose from when launching a stack that includes EC2 instances.
A parameter rule has an optional `condition` field that allows ability to configure when rules are applied.
If a `condition` is specified, all the assertions will be applied if the condition evalutates to true.
For information on rule-specific intrinsic functions to define rule conditions and assertions,
see [AWS Rule Functions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-rules.html).

```ts fixture=portfolio-product
import * as cdk from '@aws-cdk/core';

portfolio.constrainCloudFormationParameters(product, {
rule: {
ruleName: 'testInstanceType',
condition: cdk.Fn.conditionEquals(cdk.Fn.ref('Environment'), 'test'),
assertions: [{
assert: cdk.Fn.conditionContains(['t2.micro', 't2.small'], cdk.Fn.ref('InstanceType')),
description: 'For test environment, the instance type should be small',
}],
},
});
```

### Set launch role

Allows you to configure a specific AWS `IAM` role that a user must assume when launching a product.
Expand Down
48 changes: 48 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/constraints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';
import { MessageLanguage } from './common';

/**
Expand Down Expand Up @@ -62,4 +63,51 @@ export interface TagUpdateConstraintOptions extends CommonConstraintOptions {
* @default true
*/
readonly allow?: boolean;
}

/**
* An assertion within a template rule, defined by intrinsic functions.
*/
export interface TemplateRuleAssertion {
/**
* The assertion condition.
*/
readonly assert: cdk.ICfnRuleConditionExpression;

/**
* The description for the asssertion.
* @default - no description provided for the assertion.
*/
readonly description?: string;
}

/**
* Defines the provisioning template constraints.
*/
export interface TemplateRule {
/**
* Name of the rule.
*/
readonly ruleName: string;

/**
* Specify when to apply rule with a rule-specific intrinsic function.
* @default - no rule condition provided
*/
readonly condition?: cdk.ICfnRuleConditionExpression;

/**
* A list of assertions that make up the rule.
*/
readonly assertions: TemplateRuleAssertion[];
}

/**
* Properties for provisoning rule constraint.
*/
export interface CloudFormationRuleConstraintOptions extends CommonConstraintOptions {
/**
* The rule with condition and assertions to apply to template.
*/
readonly rule: TemplateRule;
}
18 changes: 16 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as iam from '@aws-cdk/aws-iam';
import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import { MessageLanguage } from './common';
import { CommonConstraintOptions, StackSetsConstraintOptions, TagUpdateConstraintOptions } from './constraints';
import {
CloudFormationRuleConstraintOptions, CommonConstraintOptions,
StackSetsConstraintOptions, TagUpdateConstraintOptions,
} from './constraints';
import { AssociationManager } from './private/association-manager';
import { hashValues } from './private/util';
import { InputValidator } from './private/validation';
Expand Down Expand Up @@ -100,6 +103,13 @@ export interface IPortfolio extends cdk.IResource {
*/
notifyOnStackEvents(product: IProduct, topic: sns.ITopic, options?: CommonConstraintOptions): void;

/**
* Set provisioning rules for the product.
* @param product A service catalog product.
* @param options options for the constraint.
*/
constrainCloudFormationParameters(product:IProduct, options: CloudFormationRuleConstraintOptions): void;

/**
* Force users to assume a certain role when launching a product.
*
Expand Down Expand Up @@ -136,7 +146,7 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
}

public addProduct(product: IProduct): void {
AssociationManager.associateProductWithPortfolio(this, product);
AssociationManager.associateProductWithPortfolio(this, product, undefined);
}

public shareWithAccount(accountId: string, options: PortfolioShareOptions = {}): void {
Expand All @@ -161,6 +171,10 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
AssociationManager.notifyOnStackEvents(this, product, topic, options);
}

public constrainCloudFormationParameters(product: IProduct, options: CloudFormationRuleConstraintOptions): void {
AssociationManager.constrainCloudFormationParameters(this, product, options);
}

public setLaunchRole(product: IProduct, launchRole: iam.IRole, options: CommonConstraintOptions = {}): void {
AssociationManager.setLaunchRole(this, product, launchRole, options);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import * as iam from '@aws-cdk/aws-iam';
import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import { CommonConstraintOptions, StackSetsConstraintOptions, TagUpdateConstraintOptions } from '../constraints';
import {
CloudFormationRuleConstraintOptions, CommonConstraintOptions, StackSetsConstraintOptions,
TagUpdateConstraintOptions, TemplateRule, TemplateRuleAssertion,
} from '../constraints';
import { IPortfolio } from '../portfolio';
import { IProduct } from '../product';
import {
CfnLaunchNotificationConstraint, CfnLaunchRoleConstraint, CfnPortfolioProductAssociation,
CfnLaunchNotificationConstraint, CfnLaunchRoleConstraint, CfnLaunchTemplateConstraint, CfnPortfolioProductAssociation,
CfnResourceUpdateConstraint, CfnStackSetConstraint, CfnTagOption, CfnTagOptionAssociation,
} from '../servicecatalog.generated';
import { TagOptions } from '../tag-options';
Expand All @@ -14,8 +17,9 @@ import { InputValidator } from './validation';

export class AssociationManager {
public static associateProductWithPortfolio(
portfolio: IPortfolio, product: IProduct,
portfolio: IPortfolio, product: IProduct, options: CommonConstraintOptions | undefined,
): { associationKey: string, cfnPortfolioProductAssociation: CfnPortfolioProductAssociation } {
InputValidator.validateLength(this.prettyPrintAssociation(portfolio, product), 'description', 0, 2000, options?.description);
const associationKey = hashValues(portfolio.node.addr, product.node.addr, product.stack.node.addr);
const constructId = `PortfolioProductAssociation${associationKey}`;
const existingAssociation = portfolio.node.tryFindChild(constructId);
Expand All @@ -33,8 +37,7 @@ export class AssociationManager {
}

public static constrainTagUpdates(portfolio: IPortfolio, product: IProduct, options: TagUpdateConstraintOptions): void {
this.validateCommonConstraintOptions(portfolio, product, options);
const association = this.associateProductWithPortfolio(portfolio, product);
const association = this.associateProductWithPortfolio(portfolio, product, options);
const constructId = `ResourceUpdateConstraint${association.associationKey}`;

if (!portfolio.node.tryFindChild(constructId)) {
Expand All @@ -54,8 +57,7 @@ export class AssociationManager {
}

public static notifyOnStackEvents(portfolio: IPortfolio, product: IProduct, topic: sns.ITopic, options: CommonConstraintOptions): void {
this.validateCommonConstraintOptions(portfolio, product, options);
const association = this.associateProductWithPortfolio(portfolio, product);
const association = this.associateProductWithPortfolio(portfolio, product, options);
const constructId = `LaunchNotificationConstraint${hashValues(topic.node.addr, topic.stack.node.addr, association.associationKey)}`;

if (!portfolio.node.tryFindChild(constructId)) {
Expand All @@ -74,9 +76,31 @@ export class AssociationManager {
}
}

public static constrainCloudFormationParameters(
portfolio: IPortfolio, product: IProduct,
options: CloudFormationRuleConstraintOptions,
): void {
const association = this.associateProductWithPortfolio(portfolio, product, options);
const constructId = `LaunchTemplateConstraint${hashValues(association.associationKey, options.rule.ruleName)}`;

if (!portfolio.node.tryFindChild(constructId)) {
const constraint = new CfnLaunchTemplateConstraint(portfolio as unknown as cdk.Resource, constructId, {
acceptLanguage: options.messageLanguage,
description: options.description,
portfolioId: portfolio.portfolioId,
productId: product.productId,
rules: this.formatTemplateRule(portfolio.stack, options.rule),
});

// Add dependsOn to force proper order in deployment.
constraint.addDependsOn(association.cfnPortfolioProductAssociation);
} else {
throw new Error(`Provisioning rule ${options.rule.ruleName} already configured on association ${this.prettyPrintAssociation(portfolio, product)}`);
}
}

public static setLaunchRole(portfolio: IPortfolio, product: IProduct, launchRole: iam.IRole, options: CommonConstraintOptions): void {
this.validateCommonConstraintOptions(portfolio, product, options);
const association = this.associateProductWithPortfolio(portfolio, product);
const association = this.associateProductWithPortfolio(portfolio, product, options);
// Check if a stackset deployment constraint has already been configured.
if (portfolio.node.tryFindChild(this.stackSetConstraintLogicalId(association.associationKey))) {
throw new Error(`Cannot set launch role when a StackSet rule is already defined for association ${this.prettyPrintAssociation(portfolio, product)}`);
Expand All @@ -100,8 +124,7 @@ export class AssociationManager {
}

public static deployWithStackSets(portfolio: IPortfolio, product: IProduct, options: StackSetsConstraintOptions) {
this.validateCommonConstraintOptions(portfolio, product, options);
const association = this.associateProductWithPortfolio(portfolio, product);
const association = this.associateProductWithPortfolio(portfolio, product, options);
// Check if a launch role has already been set.
if (portfolio.node.tryFindChild(this.launchRoleConstraintLogicalId(association.associationKey))) {
throw new Error(`Cannot configure StackSet deployment when a launch role is already defined for association ${this.prettyPrintAssociation(portfolio, product)}`);
Expand Down Expand Up @@ -168,7 +191,25 @@ export class AssociationManager {
return `- Portfolio: ${portfolio.node.path} | Product: ${product.node.path}`;
}

private static validateCommonConstraintOptions(portfolio: IPortfolio, product: IProduct, options: CommonConstraintOptions): void {
InputValidator.validateLength(this.prettyPrintAssociation(portfolio, product), 'description', 0, 2000, options.description);
private static formatTemplateRule(stack: cdk.Stack, rule: TemplateRule): string {
return JSON.stringify({
[rule.ruleName]: {
Assertions: this.formatAssertions(stack, rule.assertions),
RuleCondition: rule.condition ? stack.resolve(rule.condition) : undefined,
},
});
}

private static formatAssertions(
stack: cdk.Stack, assertions : TemplateRuleAssertion[],
): { Assert: string, AssertDescription: string | undefined }[] {
return assertions.reduce((formattedAssertions, assertion) => {
formattedAssertions.push( {
Assert: stack.resolve(assertion.assert),
AssertDescription: assertion.description,
});
return formattedAssertions;
}, new Array<{ Assert: string, AssertDescription: string | undefined }>());
};
}

Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@
"TestPortfolioPortfolioProductAssociationa0185761d231B0D998A7"
]
},
"TestPortfolioLaunchTemplateConstraintfac7b49c426e599F9FFF": {
"Type": "AWS::ServiceCatalog::LaunchTemplateConstraint",
"Properties": {
"PortfolioId": {
"Ref": "TestPortfolio4AC794EB"
},
"ProductId": {
"Ref": "TestProduct7606930B"
},
"Rules": "{\"SubnetsinVPC\":{\"Assertions\":[{\"Assert\":{\"Fn::EachMemberIn\":[{\"Fn::ValueOfAll\":[\"AWs::EC2::Subnet::Id\",\"VpcId\"]},{\"Fn::RefAll\":\"AWS::EC2::VPC::Id\"}]},\"AssertDescription\":\"test description\"}]}}"
},
"DependsOn": [
"TestPortfolioPortfolioProductAssociationa0185761d231B0D998A7"
]
},
"TagOptionc0d88a3c4b8b": {
"Type": "AWS::ServiceCatalog::TagOption",
"Properties": {
Expand Down
18 changes: 15 additions & 3 deletions packages/@aws-cdk/aws-servicecatalog/test/integ.portfolio.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as iam from '@aws-cdk/aws-iam';
import * as sns from '@aws-cdk/aws-sns';
import { App, Stack } from '@aws-cdk/core';
import * as cdk from '@aws-cdk/core';
import * as servicecatalog from '../lib';

const app = new App();
const stack = new Stack(app, 'integ-servicecatalog-portfolio');
const app = new cdk.App();
const stack = new cdk.Stack(app, 'integ-servicecatalog-portfolio');

const role = new iam.Role(stack, 'TestRole', {
assumedBy: new iam.AccountRootPrincipal(),
Expand Down Expand Up @@ -79,4 +79,16 @@ secondPortfolio.deployWithStackSets(product, {
allowStackSetInstanceOperations: true,
});

portfolio.constrainCloudFormationParameters(product, {
rule: {
ruleName: 'SubnetsinVPC',
assertions: [{
assert: cdk.Fn.conditionEachMemberIn(
cdk.Fn.valueOfAll('AWs::EC2::Subnet::Id', 'VpcId'),
cdk.Fn.refAll('AWS::EC2::VPC::Id')),
description: 'test description',
}],
},
});

app.synth();
Loading