Skip to content

Commit

Permalink
feat(cfn-include): add support for retrieving Rule objects from the t…
Browse files Browse the repository at this point in the history
…emplate (#9783)

Fixes #9712

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
skinny85 committed Aug 19, 2020
1 parent 23f05a3 commit e4720bf
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 5 deletions.
19 changes: 19 additions & 0 deletions packages/@aws-cdk/cloudformation-include/README.md
Expand Up @@ -181,6 +181,25 @@ and any changes you make to it will be reflected in the resulting template:
mapping.setValue('my-region', 'AMI', 'ami-04681a1dbd79675a5');
```

## Rules

If your template uses [Service Catalog template Rules](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/reference-template_constraint_rules.html),
you can retrieve them from your template:

```typescript
import * as core from '@aws-cdk/core';

const rule: core.CfnRule = cfnTemplate.getRule('MyRule');
```

The `CfnRule` object is mutable,
and any changes you make to it will be reflected in the resulting template:

```typescript
rule.addAssertion(core.Fn.conditionContains(['m1.small'], myParameter.value),
'MyParameter has to be m1.small');
```

## Outputs

If your template uses [CloudFormation Outputs](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html),
Expand Down
55 changes: 55 additions & 0 deletions packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts
Expand Up @@ -70,6 +70,8 @@ export class CfnInclude extends core.CfnElement {
private readonly parametersToReplace: { [parameterName: string]: any };
private readonly mappingsScope: core.Construct;
private readonly mappings: { [mappingName: string]: core.CfnMapping } = {};
private readonly rules: { [ruleName: string]: core.CfnRule } = {};
private readonly rulesScope: core.Construct;
private readonly outputs: { [logicalId: string]: core.CfnOutput } = {};
private readonly nestedStacks: { [logicalId: string]: IncludedNestedStack } = {};
private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps };
Expand Down Expand Up @@ -111,6 +113,12 @@ export class CfnInclude extends core.CfnElement {
this.getOrCreateCondition(conditionName);
}

// instantiate the rules
this.rulesScope = new core.Construct(this, '$Rules');
for (const ruleName of Object.keys(this.template.Rules || {})) {
this.createRule(ruleName);
}

this.nestedStacksToInclude = props.nestedStacks || {};
// instantiate all resources as CDK L1 objects
for (const logicalId of Object.keys(this.template.Resources || {})) {
Expand Down Expand Up @@ -224,6 +232,24 @@ export class CfnInclude extends core.CfnElement {
return ret;
}

/**
* Returns the CfnRule object from the 'Rules'
* section of the CloudFormation template with the given name.
* Any modifications performed on that object will be reflected in the resulting CDK template.
*
* If a Rule with the given name is not present in the template,
* an exception will be thrown.
*
* @param ruleName the name of the Rule in the CloudFormation template
*/
public getRule(ruleName: string): core.CfnRule {
const ret = this.rules[ruleName];
if (!ret) {
throw new Error(`Rule with name '${ruleName}' was not found in the template`);
}
return ret;
}

/**
* Returns the NestedStack with name logicalId.
* For a nested stack to be returned by this method, it must be specified in the {@link CfnIncludeProps.nestedStacks}
Expand Down Expand Up @@ -274,6 +300,7 @@ export class CfnInclude extends core.CfnElement {
case 'Mappings':
case 'Resources':
case 'Parameters':
case 'Rules':
case 'Outputs':
// these are rendered as a side effect of instantiating the L1s
break;
Expand All @@ -293,6 +320,7 @@ export class CfnInclude extends core.CfnElement {
findRefTarget() { throw new Error('Using Ref expressions in Mapping definitions is not allowed'); },
findResource() { throw new Error('Using GetAtt expressions in Mapping definitions is not allowed'); },
},
parameters: {},
});
const cfnMapping = new core.CfnMapping(this.mappingsScope, mappingName, {
mapping: cfnParser.parseValue(this.template.Mappings[mappingName]),
Expand All @@ -313,6 +341,7 @@ export class CfnInclude extends core.CfnElement {
findCondition() { throw new Error('Referring to Conditions in Parameter definitions is not allowed'); },
findMapping() { throw new Error('Referring to Mappings in Parameter definitions is not allowed'); },
},
parameters: {},
}).parseValue(this.template.Parameters[logicalId]);
const cfnParameter = new core.CfnParameter(this, logicalId, {
type: expression.Type,
Expand All @@ -332,6 +361,32 @@ export class CfnInclude extends core.CfnElement {
this.parameters[logicalId] = cfnParameter;
}

private createRule(ruleName: string): void {
const self = this;
const cfnParser = new cfn_parse.CfnParser({
finder: {
findRefTarget(refTarget: string): core.CfnElement | undefined {
// only parameters can be referenced in Rules
return self.parameters[refTarget];
},
findResource() { throw new Error('Using GetAtt expressions in Rule definitions is not allowed'); },
findCondition() { throw new Error('Referring to Conditions in Rule definitions is not allowed'); },
findMapping(mappingName: string): core.CfnMapping | undefined {
return self.mappings[mappingName];
},
},
parameters: this.parametersToReplace,
context: cfn_parse.CfnParsingContext.RULES,
});
const ruleProperties = cfnParser.parseValue(this.template.Rules[ruleName]);
const rule = new core.CfnRule(this.rulesScope, ruleName, {
ruleCondition: ruleProperties.RuleCondition,
assertions: ruleProperties.Assertions,
});
this.rules[ruleName] = rule;
rule.overrideLogicalId(ruleName);
}

private createOutput(logicalId: string, scope: core.Construct): void {
const self = this;
const outputAttributes = new cfn_parse.CfnParser({
Expand Down
Expand Up @@ -74,7 +74,7 @@ describe('CDK Include', () => {
test("throws an exception when encountering a CFN function it doesn't support", () => {
expect(() => {
includeTestTemplate(stack, 'only-codecommit-repo-using-cfn-functions.json');
}).toThrow(/Unsupported CloudFormation function 'Fn::DoesNotExist'/);
}).toThrow(/Unsupported CloudFormation function 'Fn::ValueOfAll'/);
});

test('throws a validation exception when encountering an unrecognized resource attribute', () => {
Expand Down Expand Up @@ -107,6 +107,12 @@ describe('CDK Include', () => {
}).toThrow(/Mapping used in FindInMap expression with name 'NonExistentMapping' was not found in the template/);
});

test("throws a validation exception when a Rule references a Parameter that isn't in the template", () => {
expect(() => {
includeTestTemplate(stack, 'rule-referencing-a-non-existent-parameter.json');
}).toThrow(/Rule references parameter 'Subnets' which was not found in the template/);
});

test("throws a validation exception when Fn::Sub in string form uses a key that isn't in the template", () => {
expect(() => {
includeTestTemplate(stack, 'fn-sub-key-not-in-template-string.json');
Expand Down
Expand Up @@ -5,7 +5,7 @@
"Properties": {
"RepositoryName": "my-repository",
"RepositoryDescription": {
"Fn::DoesNotExist": "my description, in base-64!"
"Fn::Select": [0, { "Fn::ValueOfAll": ["AWS::EC2::Subnet::Id", "VpcId"]}]
}
}
}
Expand Down
@@ -0,0 +1,14 @@
{
"Rules": {
"VpcRule": {
"Assertions": [
{
"Fn::EachMemberIn": [
{ "Fn::ValueOfAll": ["AWS::EC2::Subnet::Id", "VpcId"] },
{ "Fn::ValueOf": ["Subnets", "VpcId"] }
]
}
]
}
}
}
@@ -0,0 +1,28 @@
{
"Parameters": {
"Env": {
"Type": "String"
},
"Subnets": {
"Type": "List<AWS::EC2::Subnet::Id>"
}
},
"Rules": {
"TestVpcRule": {
"RuleCondition": {
"Fn::Contains": [
["test", "pre-prod", "preprod"],
{ "Ref": "Env" }
]
},
"Assertions": [
{
"Fn::EachMemberIn": [
{ "Fn::ValueOfAll": ["AWS::EC2::Subnet::Id", "VpcId"] },
{ "Fn::ValueOf": ["Subnets", "VpcId"] }
]
}
]
}
}
}
Expand Up @@ -736,6 +736,35 @@ describe('CDK Include', () => {
});
});

test('can ingest a template that contains Rules, and allows retrieving those Rules', () => {
const cfnTemplate = includeTestTemplate(stack, 'only-parameters-and-rule.json');
const rule = cfnTemplate.getRule('TestVpcRule');

expect(rule).toBeDefined();

expect(stack).toMatchTemplate(
loadTestFileToJsObject('only-parameters-and-rule.json'),
);
});

test('fails when trying to replace Parameters referenced in Fn::ValueOf expressions with user-provided values', () => {
expect(() => {
includeTestTemplate(stack, 'only-parameters-and-rule.json', {
parameters: {
'Subnets': ['subnet-1234abcd'],
},
});
}).toThrow(/Cannot substitute parameter 'Subnets' used in Fn::ValueOf expression with attribute 'VpcId'/);
});

test("throws an exception when attempting to retrieve a Rule that doesn't exist in the template", () => {
const cfnTemplate = includeTestTemplate(stack, 'only-parameters-and-rule.json');

expect(() => {
cfnTemplate.getRule('DoesNotExist');
}).toThrow(/Rule with name 'DoesNotExist' was not found in the template/);
});

test('replaces references to parameters with the user-specified values in Resources, Conditions, Metadata, and Options sections', () => {
includeTestTemplate(stack, 'parameter-references.json', {
parameters: {
Expand Down
44 changes: 41 additions & 3 deletions packages/@aws-cdk/core/lib/cfn-parse.ts
Expand Up @@ -214,6 +214,9 @@ export interface FromCloudFormationOptions {
export enum CfnParsingContext {
/** We're currently parsing the 'Conditions' section. */
CONDITIONS,

/** We're currently parsing the 'Rules' section. */
RULES,
}

/**
Expand All @@ -234,9 +237,8 @@ export interface ParseCfnOptions {

/**
* Values provided here will replace references to parameters in the parsed template.
* @default - no parameters will be replaced
*/
readonly parameters?: { [parameterName: string]: any }
readonly parameters: { [parameterName: string]: any };
}

/**
Expand Down Expand Up @@ -540,7 +542,11 @@ export class CfnParser {
return { Condition: condition.logicalId };
}
default:
throw new Error(`Unsupported CloudFormation function '${key}'`);
if (this.options.context === CfnParsingContext.RULES) {
return this.handleRulesIntrinsic(key, object);
} else {
throw new Error(`Unsupported CloudFormation function '${key}'`);
}
}
}

Expand Down Expand Up @@ -604,6 +610,38 @@ export class CfnParser {
}
}

private handleRulesIntrinsic(key: string, object: any): any {
// Rules have their own set of intrinsics:
// https://docs.aws.amazon.com/servicecatalog/latest/adminguide/intrinsic-function-reference-rules.html
switch (key) {
case 'Fn::ValueOf': {
// ValueOf is special,
// as it takes the name of a Parameter as its first argument
const value = this.parseValue(object[key]);
const parameterName = value[0];
if (parameterName in this.parameters) {
// since ValueOf returns the value of a specific attribute,
// fail here - this substitution is not allowed
throw new Error(`Cannot substitute parameter '${parameterName}' used in Fn::ValueOf expression with attribute '${value[1]}'`);
}
const param = this.options.finder.findRefTarget(parameterName);
if (!param) {
throw new Error(`Rule references parameter '${parameterName}' which was not found in the template`);
}
// create an explicit IResolvable,
// as Fn.valueOf() returns a string,
// which is incorrect
// (Fn::ValueOf can also return an array)
return Lazy.anyValue({ produce: () => ({ 'Fn::ValueOf': [param.logicalId, value[1]] }) });
}
default:
// I don't want to hard-code the list of supported Rules-specific intrinsics in this function;
// so, just return undefined here,
// and they will be treated as a regular JSON object
return undefined;
}
}

private specialCaseRefs(value: any): any {
if (value in this.parameters) {
return this.parameters[value];
Expand Down

0 comments on commit e4720bf

Please sign in to comment.