Skip to content

Commit 9410361

Browse files
authored
fix(core): throw on intrinsics in CFN update and create policies (#31578)
### Issue # (if applicable) Closes #27578, Closes #30740. ### Reason for this change `cfn-include` only allows Intrinsics in resource update and create policies to wrap primitive values. If Intrinsics are included anywhere else, `cfn-include` silently drops them. CDK's type system [does not allow](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/cfn-resource-policy.ts) intrinsics in resource policies unless they define a primitive value. `cfn-include` adheres to this type system and drops any resource policies that use an intrinsic to define a complex value. This is an example of a forbidden use of intrinsics: ``` "Resources": { "ResourceSignalIntrinsic": { // .... "CreationPolicy": { "ResourceSignal": { "Fn::If": [ "UseCountParameter", { "Count": { "Ref": "CountParameter" } }, 5 ] } } } } } ``` This is forbidden because an intrinsic contains the `Count` property of this policy. CFN allows this, but CDK's type system does not permit it. ### Description of changes `cfn-include` will throw if any intrinsics break the type system, instead of silently dropping them. CDK's type system is a useful constraint around these resource update / create policies because it allows constructs that modify them, like autoscaling, to not be token-aware. Tokens are not resolved at synthesis time, so it makes it impossible to modify these with simple arithmetic if they contain tokens. The CDK will never (or at least should not) generate a token that breaks this type system. Thus, the only use-case for allowing these tokens is `cfn-include`. Supporting these customers would require the CDK type system to allow these, and thus CDK L2s should handle such cases; except, for L2 customers, this use-case does not happen. Explicitly reject templates that don't conform to this. Throwing here is a breaking change, so this is under a feature flag. Additionally add a new property, `dehydratedResources` -- a list of logical IDs that `cfn-include` will not parse. Those resources still exist in the final template. This does not impact L2 users. ### Description of how you validated changes Unit testing. Manually verified that this does not impact any L2s. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 0e03d39 commit 9410361

30 files changed

+1162
-82
lines changed

packages/aws-cdk-lib/cloudformation-include/lib/cfn-include.ts

+39-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export interface CfnIncludeProps {
6666
* @default - will throw an error on detecting any cyclical references
6767
*/
6868
readonly allowCyclicalReferences?: boolean;
69+
70+
/**
71+
* Specifies a list of LogicalIDs for resources that will be included in the CDK Stack,
72+
* but will not be parsed and converted to CDK types. This allows you to use CFN templates
73+
* that rely on Intrinsic placement that `cfn-include`
74+
* would otherwise reject, such as non-primitive values in resource update policies.
75+
*
76+
* @default - All resources are hydrated
77+
*/
78+
readonly dehydratedResources?: string[];
6979
}
7080

7181
/**
@@ -109,6 +119,7 @@ export class CfnInclude extends core.CfnElement {
109119
private readonly template: any;
110120
private readonly preserveLogicalIds: boolean;
111121
private readonly allowCyclicalReferences: boolean;
122+
private readonly dehydratedResources: string[];
112123
private logicalIdToPlaceholderMap: Map<string, string>;
113124

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

126137
this.preserveLogicalIds = props.preserveLogicalIds ?? true;
127138

139+
this.dehydratedResources = props.dehydratedResources ?? [];
140+
141+
for (const logicalId of this.dehydratedResources) {
142+
if (!Object.keys(this.template.Resources).includes(logicalId)) {
143+
throw new Error(`Logical ID '${logicalId}' was specified in 'dehydratedResources', but does not belong to a resource in the template.`);
144+
}
145+
}
146+
128147
// check if all user specified parameter values exist in the template
129148
for (const logicalId of Object.keys(this.parametersToReplace)) {
130149
if (!(logicalId in (this.template.Parameters || {}))) {
@@ -663,8 +682,27 @@ export class CfnInclude extends core.CfnElement {
663682

664683
const resourceAttributes: any = this.template.Resources[logicalId];
665684
let l1Instance: core.CfnResource;
666-
if (this.nestedStacksToInclude[logicalId]) {
685+
if (this.nestedStacksToInclude[logicalId] && this.dehydratedResources.includes(logicalId)) {
686+
throw new Error(`nested stack '${logicalId}' was marked as dehydrated - nested stacks cannot be dehydrated`);
687+
} else if (this.nestedStacksToInclude[logicalId]) {
667688
l1Instance = this.createNestedStack(logicalId, cfnParser);
689+
} else if (this.dehydratedResources.includes(logicalId)) {
690+
691+
l1Instance = new core.CfnResource(this, logicalId, {
692+
type: resourceAttributes.Type,
693+
properties: resourceAttributes.Properties,
694+
});
695+
const cfnOptions = l1Instance.cfnOptions;
696+
cfnOptions.creationPolicy = resourceAttributes.CreationPolicy;
697+
cfnOptions.updatePolicy = resourceAttributes.UpdatePolicy;
698+
cfnOptions.deletionPolicy = resourceAttributes.DeletionPolicy;
699+
cfnOptions.updateReplacePolicy = resourceAttributes.UpdateReplacePolicy;
700+
cfnOptions.version = resourceAttributes.Version;
701+
cfnOptions.description = resourceAttributes.Description;
702+
cfnOptions.metadata = resourceAttributes.Metadata;
703+
this.resources[logicalId] = l1Instance;
704+
705+
return l1Instance;
668706
} else {
669707
const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type);
670708
// The AWS::CloudFormation::CustomResource type corresponds to the CfnCustomResource class.

packages/aws-cdk-lib/cloudformation-include/test/invalid-templates.test.ts

+184
Original file line numberDiff line numberDiff line change
@@ -245,17 +245,201 @@ describe('CDK Include', () => {
245245
},
246246
);
247247
});
248+
249+
test('throws an exception if Tags contains invalid intrinsics', () => {
250+
expect(() => {
251+
includeTestTemplate(stack, 'tags-with-invalid-intrinsics.json');
252+
}).toThrow(/expression does not exist in the template/);
253+
});
254+
255+
test('non-leaf Intrinsics cannot be used in the top-level creation policy', () => {
256+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
257+
expect(() => {
258+
includeTestTemplate(stack, 'intrinsics-create-policy.json');
259+
}).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./);
260+
});
261+
262+
test('Intrinsics cannot be used in the autoscaling creation policy', () => {
263+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
264+
expect(() => {
265+
includeTestTemplate(stack, 'intrinsics-create-policy-autoscaling.json');
266+
}).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./);
267+
});
268+
269+
test('Intrinsics cannot be used in the create policy resource signal', () => {
270+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
271+
expect(() => {
272+
includeTestTemplate(stack, 'intrinsics-create-policy-resource-signal.json');
273+
}).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./);
274+
});
275+
276+
test('Intrinsics cannot be used in the top-level update policy', () => {
277+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
278+
expect(() => {
279+
includeTestTemplate(stack, 'intrinsics-update-policy.json');
280+
}).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./);
281+
});
282+
283+
test('Intrinsics cannot be used in the auto scaling rolling update update policy', () => {
284+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
285+
expect(() => {
286+
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-rolling-update.json');
287+
}).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./);
288+
});
289+
290+
test('Intrinsics cannot be used in the auto scaling replacing update update policy', () => {
291+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
292+
expect(() => {
293+
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-replacing-update.json');
294+
}).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./);
295+
});
296+
297+
test('Intrinsics cannot be used in the auto scaling scheduled action update policy', () => {
298+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
299+
expect(() => {
300+
includeTestTemplate(stack, 'intrinsics-update-policy-autoscaling-scheduled-action.json');
301+
}).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./);
302+
});
303+
304+
test('Intrinsics cannot be used in the code deploy lambda alias update policy', () => {
305+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
306+
expect(() => {
307+
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json');
308+
}).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./);
309+
});
310+
311+
test('FF toggles error checking', () => {
312+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, false);
313+
expect(() => {
314+
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json');
315+
}).not.toThrow();
316+
});
317+
318+
test('FF disabled with dehydratedResources does not throw', () => {
319+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, false);
320+
expect(() => {
321+
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json', {
322+
dehydratedResources: ['Alias'],
323+
});
324+
}).not.toThrow();
325+
});
326+
327+
test('dehydrated resources retain attributes with complex Intrinsics', () => {
328+
stack.node.setContext(cxapi.CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS, true);
329+
includeTestTemplate(stack, 'intrinsics-update-policy-code-deploy-lambda-alias-update.json', {
330+
dehydratedResources: ['Alias'],
331+
});
332+
333+
expect(Template.fromStack(stack).hasResource('AWS::Lambda::Alias', {
334+
UpdatePolicy: {
335+
CodeDeployLambdaAliasUpdate: {
336+
'Fn::If': [
337+
'SomeCondition',
338+
{
339+
AfterAllowTrafficHook: 'SomeOtherHook',
340+
ApplicationName: 'SomeApp',
341+
BeforeAllowTrafficHook: 'SomeHook',
342+
DeploymentGroupName: 'SomeDeploymentGroup',
343+
},
344+
{
345+
AfterAllowTrafficHook: 'SomeOtherOtherHook',
346+
ApplicationName: 'SomeOtherApp',
347+
BeforeAllowTrafficHook: 'SomeOtherHook',
348+
DeploymentGroupName: 'SomeOtherDeploymentGroup',
349+
350+
},
351+
],
352+
},
353+
},
354+
}));
355+
});
356+
357+
test('dehydrated resources retain all attributes', () => {
358+
includeTestTemplate(stack, 'resource-all-attributes.json', {
359+
dehydratedResources: ['Foo'],
360+
});
361+
362+
expect(Template.fromStack(stack).hasResource('AWS::Foo::Bar', {
363+
Properties: { Blinky: 'Pinky' },
364+
Type: 'AWS::Foo::Bar',
365+
CreationPolicy: { Inky: 'Clyde' },
366+
DeletionPolicy: { DeletionPolicyKey: 'DeletionPolicyValue' },
367+
Metadata: { SomeKey: 'SomeValue' },
368+
Version: '1.2.3.4.5.6',
369+
UpdateReplacePolicy: { Oh: 'No' },
370+
Description: 'This resource does not match the spec, but it does have every possible attribute',
371+
UpdatePolicy: {
372+
Foo: 'Bar',
373+
},
374+
}));
375+
});
376+
377+
test('synth-time validation does not run on dehydrated resources', () => {
378+
// synth-time validation fails if resource is hydrated
379+
expect(() => {
380+
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json');
381+
Template.fromStack(stack);
382+
}).toThrow(`Resolution error: Supplied properties not correct for \"CfnLoadBalancerProps\"
383+
tags: element 1: {} should have a 'key' and a 'value' property.`);
384+
385+
app = new core.App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } });
386+
stack = new core.Stack(app);
387+
388+
// synth-time validation not run if resource is dehydrated
389+
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json', {
390+
dehydratedResources: ['MyLoadBalancer'],
391+
});
392+
393+
expect(Template.fromStack(stack).hasResource('AWS::ElasticLoadBalancingV2::LoadBalancer', {
394+
Properties: {
395+
Tags: [
396+
{
397+
Key: 'Name',
398+
Value: 'MyLoadBalancer',
399+
},
400+
{
401+
data: [
402+
'IsExtraTag',
403+
{
404+
Key: 'Name2',
405+
Value: 'MyLoadBalancer2',
406+
},
407+
{
408+
data: 'AWS::NoValue',
409+
type: 'Ref',
410+
isCfnFunction: true,
411+
},
412+
],
413+
type: 'Fn::If',
414+
isCfnFunction: true,
415+
},
416+
],
417+
},
418+
}));
419+
});
420+
421+
test('throws on dehydrated resources not present in the template', () => {
422+
expect(() => {
423+
includeTestTemplate(stack, 'intrinsics-tags-resource-validation.json', {
424+
dehydratedResources: ['ResourceNotExistingHere'],
425+
});
426+
}).toThrow(/Logical ID 'ResourceNotExistingHere' was specified in 'dehydratedResources', but does not belong to a resource in the template./);
427+
});
248428
});
249429

250430
interface IncludeTestTemplateProps {
251431
/** @default false */
252432
readonly allowCyclicalReferences?: boolean;
433+
434+
/** @default none */
435+
readonly dehydratedResources?: string[];
253436
}
254437

255438
function includeTestTemplate(scope: constructs.Construct, testTemplate: string, props: IncludeTestTemplateProps = {}): inc.CfnInclude {
256439
return new inc.CfnInclude(scope, 'MyScope', {
257440
templateFile: _testTemplateFilePath(testTemplate),
258441
allowCyclicalReferences: props.allowCyclicalReferences,
442+
dehydratedResources: props.dehydratedResources,
259443
});
260444
}
261445

packages/aws-cdk-lib/cloudformation-include/test/nested-stacks.test.ts

+71
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,77 @@ describe('CDK Include for nested stacks', () => {
743743
});
744744
});
745745
});
746+
747+
describe('dehydrated resources', () => {
748+
let parentStack: core.Stack;
749+
let childStack: core.Stack;
750+
751+
beforeEach(() => {
752+
parentStack = new core.Stack();
753+
});
754+
755+
test('dehydrated resources are included in child templates, even if they are otherwise invalid', () => {
756+
const parentTemplate = new inc.CfnInclude(parentStack, 'ParentStack', {
757+
templateFile: testTemplateFilePath('parent-dehydrated.json'),
758+
dehydratedResources: ['ASG'],
759+
loadNestedStacks: {
760+
'ChildStack': {
761+
templateFile: testTemplateFilePath('child-dehydrated.json'),
762+
dehydratedResources: ['ChildASG'],
763+
},
764+
},
765+
});
766+
childStack = parentTemplate.getNestedStack('ChildStack').stack;
767+
768+
Template.fromStack(childStack).templateMatches({
769+
"Conditions": {
770+
"SomeCondition": {
771+
"Fn::Equals": [
772+
2,
773+
2,
774+
],
775+
},
776+
},
777+
"Resources": {
778+
"ChildStackChildASGF815DFE9": {
779+
"Type": "AWS::AutoScaling::AutoScalingGroup",
780+
"Properties": {
781+
"MaxSize": 10,
782+
"MinSize": 1,
783+
},
784+
"UpdatePolicy": {
785+
"AutoScalingScheduledAction": {
786+
"Fn::If": [
787+
"SomeCondition",
788+
{
789+
"IgnoreUnmodifiedGroupSizeProperties": true,
790+
},
791+
{
792+
"IgnoreUnmodifiedGroupSizeProperties": false,
793+
},
794+
],
795+
},
796+
},
797+
},
798+
},
799+
});
800+
});
801+
802+
test('throws if a nested stack is marked dehydrated', () => {
803+
expect(() => {
804+
new inc.CfnInclude(parentStack, 'ParentStack', {
805+
templateFile: testTemplateFilePath('parent-dehydrated.json'),
806+
dehydratedResources: ['ChildStack'],
807+
loadNestedStacks: {
808+
'ChildStack': {
809+
templateFile: testTemplateFilePath('child-dehydrated.json'),
810+
dehydratedResources: ['ChildASG'],
811+
},
812+
},
813+
});
814+
}).toThrow(/nested stack 'ChildStack' was marked as dehydrated - nested stacks cannot be dehydrated/);
815+
});
816+
});
746817
});
747818

748819
function loadTestFileToJsObject(testTemplate: string): any {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"Parameters": {
3+
"MinSuccessfulInstancesPercent": {
4+
"Type": "Number"
5+
}
6+
},
7+
"Resources": {
8+
"AutoScalingCreationPolicyIntrinsic": {
9+
"Type": "AWS::AutoScaling::AutoScalingGroup",
10+
"Properties": {
11+
"MinSize": "1",
12+
"MaxSize": "5"
13+
},
14+
"CreationPolicy": {
15+
"AutoScalingCreationPolicy": {
16+
"MinSuccessfulInstancesPercent": {
17+
"Ref": "MinSuccessfulInstancesPercent"
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}

0 commit comments

Comments
 (0)