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

fix(core): throw on intrinsics in CFN update and create policies #31578

Merged
merged 20 commits into from
Oct 3, 2024
Merged
40 changes: 39 additions & 1 deletion packages/aws-cdk-lib/cloudformation-include/lib/cfn-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export interface CfnIncludeProps {
* @default - will throw an error on detecting any cyclical references
*/
readonly allowCyclicalReferences?: boolean;

/**
* Specifies a list of LogicalIDs for resources that will be included in the CDK Stack,
* but will not be parsed and converted to CDK types. This allows you to use CFN templates
* that rely on Intrinsic placement that `cfn-include`
* would otherwise reject, such as non-primitive values in resource update policies.
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
*
* @default - All resources are hydrated
*/
readonly dehydratedResources?: string[];
}

/**
Expand Down Expand Up @@ -109,6 +119,7 @@ export class CfnInclude extends core.CfnElement {
private readonly template: any;
private readonly preserveLogicalIds: boolean;
private readonly allowCyclicalReferences: boolean;
private readonly dehydratedResources: string[];
private logicalIdToPlaceholderMap: Map<string, string>;

constructor(scope: Construct, id: string, props: CfnIncludeProps) {
Expand All @@ -125,6 +136,14 @@ export class CfnInclude extends core.CfnElement {

this.preserveLogicalIds = props.preserveLogicalIds ?? true;

this.dehydratedResources = props.dehydratedResources ?? [];

for (const logicalId of this.dehydratedResources) {
if (!Object.keys(this.template.Resources).includes(logicalId)) {
throw new Error(`Logical ID '${logicalId}' was specified in 'dehydratedResources', but does not belong to a resource in the template.`);
}
}

// check if all user specified parameter values exist in the template
for (const logicalId of Object.keys(this.parametersToReplace)) {
if (!(logicalId in (this.template.Parameters || {}))) {
Expand Down Expand Up @@ -663,8 +682,27 @@ export class CfnInclude extends core.CfnElement {

const resourceAttributes: any = this.template.Resources[logicalId];
let l1Instance: core.CfnResource;
if (this.nestedStacksToInclude[logicalId]) {
if (this.nestedStacksToInclude[logicalId] && this.dehydratedResources.includes(logicalId)) {
throw new Error(`nested stack '${logicalId}' was marked as dehydrated - nested stacks cannot be dehydrated`);
} else if (this.nestedStacksToInclude[logicalId]) {
l1Instance = this.createNestedStack(logicalId, cfnParser);
} else if (this.dehydratedResources.includes(logicalId)) {

l1Instance = new core.CfnResource(this, logicalId, {
type: resourceAttributes.Type,
properties: resourceAttributes.Properties,
});
const cfnOptions = l1Instance.cfnOptions;
cfnOptions.creationPolicy = resourceAttributes.CreationPolicy;
cfnOptions.updatePolicy = resourceAttributes.UpdatePolicy;
cfnOptions.deletionPolicy = resourceAttributes.DeletionPolicy;
cfnOptions.updateReplacePolicy = resourceAttributes.UpdateReplacePolicy;
cfnOptions.version = resourceAttributes.Version;
cfnOptions.description = resourceAttributes.Description;
cfnOptions.metadata = resourceAttributes.Metadata;
this.resources[logicalId] = l1Instance;

return l1Instance;
} else {
const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type);
// The AWS::CloudFormation::CustomResource type corresponds to the CfnCustomResource class.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,17 +245,201 @@ describe('CDK Include', () => {
},
);
});

test('throws an exception if Tags contains invalid intrinsics', () => {
expect(() => {
includeTestTemplate(stack, 'tags-with-invalid-intrinsics.json');
}).toThrow(/expression does not exist in the template/);
});

test('non-leaf Intrinsics cannot be used in the top-level creation policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-create-policy.json');
}).toThrow(/Cannot convert resource 'CreationPolicyIntrinsic' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'CreationPolicyIntrinsic' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the autoscaling creation policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-create-policy-autoscaling.json');
}).toThrow(/Cannot convert resource 'AutoScalingCreationPolicyIntrinsic' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'AutoScalingCreationPolicyIntrinsic' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the create policy resource signal', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-create-policy-resource-signal.json');
}).toThrow(/Cannot convert resource 'ResourceSignalIntrinsic' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'ResourceSignalIntrinsic' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the top-level update policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy.json');
}).toThrow(/Cannot convert resource 'ASG' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'ASG' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the auto scaling rolling update update policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-rolling-update.json');
}).toThrow(/Cannot convert resource 'ASG' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'ASG' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the auto scaling replacing update update policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-replacing-update.json');
}).toThrow(/Cannot convert resource 'ASG' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'ASG' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the auto scaling scheduled action update policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-scheduled-action.json');
}).toThrow(/Cannot convert resource 'ASG' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'ASG' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('Intrinsics cannot be used in the code deploy lambda alias update policy', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json');
}).toThrow(/Cannot convert resource 'Alias' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify 'Alias' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output./);
});

test('FF toggles error checking', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, false);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json');
}).not.toThrow();
});

test('FF disabled with dehydratedResources does not throw', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, false);
expect(() => {
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json', {
dehydratedResources: ['Alias'],
});
}).not.toThrow();
});

test('dehydrated resources retain attributes with complex Intrinsics', () => {
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json', {
dehydratedResources: ['Alias'],
});

expect(Template.fromStack(stack).hasResource('AWS::Lambda::Alias', {
UpdatePolicy: {
CodeDeployLambdaAliasUpdate: {
'Fn::If': [
'SomeCondition',
{
AfterAllowTrafficHook: 'SomeOtherHook',
ApplicationName: 'SomeApp',
BeforeAllowTrafficHook: 'SomeHook',
DeploymentGroupName: 'SomeDeploymentGroup',
},
{
AfterAllowTrafficHook: 'SomeOtherOtherHook',
ApplicationName: 'SomeOtherApp',
BeforeAllowTrafficHook: 'SomeOtherHook',
DeploymentGroupName: 'SomeOtherDeploymentGroup',

},
],
},
},
}));
});

test('dehydrated resources retain all attributes', () => {
includeTestTemplate(stack, 'resource-all-attributes.json', {
dehydratedResources: ['Foo'],
});

expect(Template.fromStack(stack).hasResource('AWS::Foo::Bar', {
Properties: { Blinky: 'Pinky' },
Type: 'AWS::Foo::Bar',
CreationPolicy: { Inky: 'Clyde' },
DeletionPolicy: { DeletionPolicyKey: 'DeletionPolicyValue' },
Metadata: { SomeKey: 'SomeValue' },
Version: '1.2.3.4.5.6',
UpdateReplacePolicy: { Oh: 'No' },
Description: 'This resource does not match the spec, but it does have every possible attribute',
UpdatePolicy: {
Foo: 'Bar',
},
}));
});

test('synth-time validation does not run on dehydrated resources', () => {
// synth-time validation fails if resource is hydrated
expect(() => {
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json');
Template.fromStack(stack);
}).toThrow(`Resolution error: Supplied properties not correct for \"CfnLoadBalancerProps\"
tags: element 1: {} should have a 'key' and a 'value' property.`);

app = new core.App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } });
stack = new core.Stack(app);

// synth-time validation not run if resource is dehydrated
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json', {
dehydratedResources: ['MyLoadBalancer'],
});

expect(Template.fromStack(stack).hasResource('AWS::ElasticLoadBalancingV2::LoadBalancer', {
Properties: {
Tags: [
{
Key: 'Name',
Value: 'MyLoadBalancer',
},
{
data: [
'IsExtraTag',
{
Key: 'Name2',
Value: 'MyLoadBalancer2',
},
{
data: 'AWS::NoValue',
type: 'Ref',
isCfnFunction: true,
},
],
type: 'Fn::If',
isCfnFunction: true,
},
],
},
}));
});

test('throws on dehydrated resources not present in the template', () => {
expect(() => {
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json', {
dehydratedResources: ['ResourceNotExistingHere'],
});
}).toThrow(/Logical ID 'ResourceNotExistingHere' was specified in 'dehydratedResources', but does not belong to a resource in the template./);
});
});

interface IncludeTestTemplateProps {
/** @default false */
readonly allowCyclicalReferences?: boolean;

/** @default none */
readonly dehydratedResources?: string[];
}

function includeTestTemplate(scope: constructs.Construct, testTemplate: string, props: IncludeTestTemplateProps = {}): inc.CfnInclude {
return new inc.CfnInclude(scope, 'MyScope', {
templateFile: _testTemplateFilePath(testTemplate),
allowCyclicalReferences: props.allowCyclicalReferences,
dehydratedResources: props.dehydratedResources,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,77 @@ describe('CDK Include for nested stacks', () => {
});
});
});

describe('dehydrated resources', () => {
let parentStack: core.Stack;
let childStack: core.Stack;

beforeEach(() => {
parentStack = new core.Stack();
});

test('dehydrated resources are included in child templates, even if they are otherwise invalid', () => {
const parentTemplate = new inc.CfnInclude(parentStack, 'ParentStack', {
templateFile: testTemplateFilePath('parent-dehydrated.json'),
dehydratedResources: ['ASG'],
loadNestedStacks: {
'ChildStack': {
templateFile: testTemplateFilePath('child-dehydrated.json'),
dehydratedResources: ['ChildASG'],
},
},
});
childStack = parentTemplate.getNestedStack('ChildStack').stack;

Template.fromStack(childStack).templateMatches({
"Conditions": {
"SomeCondition": {
"Fn::Equals": [
2,
2,
],
},
},
"Resources": {
"ChildStackChildASGF815DFE9": {
"Type": "AWS::AutoScaling::AutoScalingGroup",
"Properties": {
"MaxSize": 10,
"MinSize": 1,
},
"UpdatePolicy": {
"AutoScalingScheduledAction": {
"Fn::If": [
"SomeCondition",
{
"IgnoreUnmodifiedGroupSizeProperties": true,
},
{
"IgnoreUnmodifiedGroupSizeProperties": false,
},
],
},
},
},
},
});
});

test('throws if a nested stack is marked dehydrated', () => {
expect(() => {
new inc.CfnInclude(parentStack, 'ParentStack', {
templateFile: testTemplateFilePath('parent-dehydrated.json'),
dehydratedResources: ['ChildStack'],
loadNestedStacks: {
'ChildStack': {
templateFile: testTemplateFilePath('child-dehydrated.json'),
dehydratedResources: ['ChildASG'],
},
},
});
}).toThrow(/nested stack 'ChildStack' was marked as dehydrated - nested stacks cannot be dehydrated/);
});
});
});

function loadTestFileToJsObject(testTemplate: string): any {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"Parameters": {
"MinSuccessfulInstancesPercent": {
"Type": "Number"
}
},
"Resources": {
"AutoScalingCreationPolicyIntrinsic": {
"Type": "AWS::AutoScaling::AutoScalingGroup",
"Properties": {
"MinSize": "1",
"MaxSize": "5"
},
"CreationPolicy": {
"AutoScalingCreationPolicy": {
"MinSuccessfulInstancesPercent": {
"Ref": "MinSuccessfulInstancesPercent"
}
}
}
}
}
}
Loading