Skip to content

Commit 3d4bdcf

Browse files
authored
Merge pull request #148 from hearde/bugfix/doc-condition
Use conditional dependencies for documents that are conditional
2 parents fcc71e1 + 877334f commit 3d4bdcf

File tree

9 files changed

+1606
-60
lines changed

9 files changed

+1606
-60
lines changed

source/lib/__snapshots__/ssm-doc-rate-limit.test.ts.snap

Lines changed: 755 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { CfnDocument } from 'aws-cdk-lib/aws-ssm';
4+
import { Aspects, CfnCondition, CfnParameter, Fn, Stack } from 'aws-cdk-lib';
5+
import SsmDocRateLimit from './ssm-doc-rate-limit';
6+
import { WaitProvider } from './wait-provider';
7+
import { Template } from 'aws-cdk-lib/assertions';
8+
9+
describe('SSM doc rate limit aspect', function () {
10+
const stack = new Stack();
11+
const serviceToken = 'my-token';
12+
const waitProvider = WaitProvider.fromServiceToken(stack, 'WaitProvider', serviceToken);
13+
Aspects.of(stack).add(new SsmDocRateLimit(waitProvider));
14+
const content = {};
15+
const numDocuments = 12;
16+
for (let i = 0; i < numDocuments; ++i) {
17+
new CfnDocument(stack, `Document${i}`, { content });
18+
}
19+
const template = Template.fromStack(stack);
20+
21+
it('matches snapshot', function () {
22+
expect(template).toMatchSnapshot();
23+
});
24+
25+
const documents = template.findResources('AWS::SSM::Document', { Properties: { Content: content } });
26+
const documentLogicalIds = Object.getOwnPropertyNames(documents);
27+
28+
const expectedBatchSize = 5;
29+
30+
const createWaits = template.findResources('Custom::Wait', {
31+
Properties: {
32+
CreateIntervalSeconds: 1,
33+
UpdateIntervalSeconds: 1,
34+
DeleteIntervalSeconds: 0,
35+
ServiceToken: serviceToken,
36+
},
37+
});
38+
const createWaitLogicalIds = Object.getOwnPropertyNames(createWaits);
39+
40+
it('has the correct number of create resources', function () {
41+
expect(createWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
42+
});
43+
44+
const deleteWaits = template.findResources('Custom::Wait', {
45+
Properties: {
46+
CreateIntervalSeconds: 0,
47+
UpdateIntervalSeconds: 0,
48+
DeleteIntervalSeconds: 0.5,
49+
ServiceToken: serviceToken,
50+
},
51+
});
52+
const deleteWaitLogicalIds = Object.getOwnPropertyNames(deleteWaits);
53+
54+
it('has the correct number of delete resources', function () {
55+
expect(deleteWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
56+
});
57+
58+
it('create resources form dependency chain', function () {
59+
expect(formDependencyChain(createWaits)).toStrictEqual(true);
60+
});
61+
62+
it('delete resources form dependency chain', function () {
63+
expect(formDependencyChain(deleteWaits)).toStrictEqual(true);
64+
});
65+
66+
it('documents depend on create and delete depends on documents', function () {
67+
const documentSets: string[][] = [];
68+
deleteWaitLogicalIds.forEach(function (logicalId: string) {
69+
documentSets.push(
70+
(deleteWaits[logicalId].DependsOn as Array<string>).filter(function (value: string) {
71+
return documentLogicalIds.includes(value);
72+
})
73+
);
74+
});
75+
const remainingDocuments = { ...documents };
76+
documentSets.forEach(function (documentSet: string[]) {
77+
// all documents depend on the same create resource
78+
const expectedCreateResource = documents[documentSet[0]].DependsOn[0];
79+
documentSet.forEach(function (value: string) {
80+
delete remainingDocuments[value];
81+
expect(documents[value].DependsOn).toHaveLength(1);
82+
expect(documents[value].DependsOn[0]).toStrictEqual(expectedCreateResource);
83+
});
84+
});
85+
// all documents in a set
86+
expect(Object.getOwnPropertyNames(remainingDocuments)).toHaveLength(0);
87+
});
88+
});
89+
90+
describe('SSM doc rate limit aspect with conditional documents', function () {
91+
const stack = new Stack();
92+
const serviceToken = 'my-token';
93+
const waitProvider = WaitProvider.fromServiceToken(stack, 'WaitProvider', serviceToken);
94+
Aspects.of(stack).add(new SsmDocRateLimit(waitProvider));
95+
const content = {};
96+
const numDocuments = 12;
97+
for (let i = 0; i < numDocuments; ++i) {
98+
const param = new CfnParameter(stack, `Parameter${i}`);
99+
const condition = new CfnCondition(stack, `Condition${i}`, { expression: Fn.conditionEquals(param, 'asdf') });
100+
const doc = new CfnDocument(stack, `Document${i}`, { content });
101+
doc.cfnOptions.condition = condition;
102+
}
103+
const template = Template.fromStack(stack);
104+
105+
it('matches snapshot', function () {
106+
expect(template).toMatchSnapshot();
107+
});
108+
109+
const documents = template.findResources('AWS::SSM::Document', { Properties: { Content: content } });
110+
const documentLogicalIds = Object.getOwnPropertyNames(documents);
111+
112+
const expectedBatchSize = 5;
113+
114+
const createWaits = template.findResources('Custom::Wait', {
115+
Properties: {
116+
CreateIntervalSeconds: 1,
117+
UpdateIntervalSeconds: 1,
118+
DeleteIntervalSeconds: 0,
119+
ServiceToken: serviceToken,
120+
},
121+
});
122+
const createWaitLogicalIds = Object.getOwnPropertyNames(createWaits);
123+
124+
it('has the correct number of create resources', function () {
125+
expect(createWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
126+
});
127+
128+
const deleteWaits = template.findResources('Custom::Wait', {
129+
Properties: {
130+
CreateIntervalSeconds: 0,
131+
UpdateIntervalSeconds: 0,
132+
DeleteIntervalSeconds: 0.5,
133+
ServiceToken: serviceToken,
134+
},
135+
});
136+
const deleteWaitLogicalIds = Object.getOwnPropertyNames(deleteWaits);
137+
138+
it('has the correct number of delete resources', function () {
139+
expect(deleteWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
140+
});
141+
142+
it('create resources form dependency chain', function () {
143+
expect(formDependencyChain(createWaits)).toStrictEqual(true);
144+
});
145+
146+
it('delete resources form dependency chain', function () {
147+
expect(formDependencyChain(deleteWaits)).toStrictEqual(true);
148+
});
149+
150+
const dummyResources = template.findResources('AWS::CloudFormation::WaitConditionHandle');
151+
const dummyResourceLogicalIds = Object.getOwnPropertyNames(dummyResources);
152+
153+
it('documents depend on create and delete depends on documents', function () {
154+
const documentSets: string[][] = [];
155+
deleteWaitLogicalIds.forEach(function (logicalId: string) {
156+
const documentSet: string[] = [];
157+
const dependencies = deleteWaits[logicalId].DependsOn as Array<string>;
158+
dependencies.forEach(function (value: string) {
159+
if (dummyResourceLogicalIds.includes(value)) {
160+
const dummyResource = dummyResources[value];
161+
Object.entries(dummyResource.Metadata).forEach(function (meta: [string, unknown]) {
162+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
163+
documentSet.push((meta[1] as { [_: string]: any })['Fn::If'][1].Ref);
164+
});
165+
}
166+
});
167+
documentSet.push(
168+
...dependencies.filter(function (value: string) {
169+
return documentLogicalIds.includes(value);
170+
})
171+
);
172+
documentSets.push(documentSet);
173+
});
174+
const remainingDocuments = { ...documents };
175+
documentSets.forEach(function (documentSet: string[]) {
176+
// all documents depend on the same create resource
177+
const expectedCreateResource = documents[documentSet[0]].DependsOn[0];
178+
documentSet.forEach(function (value: string) {
179+
delete remainingDocuments[value];
180+
expect(documents[value].DependsOn).toHaveLength(1);
181+
expect(documents[value].DependsOn[0]).toStrictEqual(expectedCreateResource);
182+
});
183+
});
184+
// all documents in a set
185+
expect(Object.getOwnPropertyNames(remainingDocuments)).toHaveLength(0);
186+
});
187+
});
188+
189+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
190+
type Resources = { [_: string]: { [_: string]: any } };
191+
192+
// do the resources depend on each other in a serial manner
193+
// this isn't foolproof, but it should be enough for simple cases
194+
function formDependencyChain(resources: Resources): boolean {
195+
const logicalIds = Object.getOwnPropertyNames(resources);
196+
let dependencyChainFound = false;
197+
// if so, there will be a resource which starts a chain that contains all the other resources
198+
logicalIds.forEach(function (logicalId: string | undefined) {
199+
const resourcesRemaining = { ...resources };
200+
while (logicalId) {
201+
let dependencies = resourcesRemaining[logicalId].DependsOn;
202+
// only check dependencies of the same resource type
203+
if (dependencies) {
204+
dependencies = (dependencies as Array<string>).filter(function (value: string) {
205+
return logicalIds.includes(value);
206+
});
207+
}
208+
delete resourcesRemaining[logicalId];
209+
if (dependencies && dependencies.length != 0) {
210+
expect(dependencies).toHaveLength(1);
211+
logicalId = dependencies[0];
212+
} else {
213+
logicalId = undefined;
214+
}
215+
}
216+
// if there are no resources left, this resource is the terminal resource
217+
if (Object.getOwnPropertyNames(resourcesRemaining).length === 0) {
218+
dependencyChainFound = true;
219+
}
220+
});
221+
return dependencyChainFound;
222+
}

source/lib/ssm-doc-rate-limit.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { CfnCustomResource, CustomResource, IAspect, Stack } from 'aws-cdk-lib';
3+
import { CfnCustomResource, CfnWaitConditionHandle, CustomResource, Fn, IAspect, Stack } from 'aws-cdk-lib';
44
import { CfnDocument } from 'aws-cdk-lib/aws-ssm';
55
import { Construct, IConstruct } from 'constructs';
66
import { createHash, Hash } from 'crypto';
@@ -13,6 +13,7 @@ export default class SsmDocRateLimit implements IAspect {
1313
private previousCreateWaitResource: CustomResource | undefined;
1414
private currentDeleteWaitResource: CustomResource | undefined;
1515
private previousDeleteWaitResource: CustomResource | undefined;
16+
private currentDummyResource: CfnWaitConditionHandle | undefined;
1617

1718
private hash: Hash;
1819

@@ -51,6 +52,12 @@ export default class SsmDocRateLimit implements IAspect {
5152
}
5253
}
5354

55+
initDummyResource(scope: Construct): void {
56+
if (!this.currentDummyResource) {
57+
this.currentDummyResource = new CfnWaitConditionHandle(scope, `Gate${this.waitResourceIndex - 1}`);
58+
}
59+
}
60+
5461
visit(node: IConstruct): void {
5562
if (node instanceof CfnDocument) {
5663
const scope = Stack.of(node);
@@ -67,7 +74,20 @@ export default class SsmDocRateLimit implements IAspect {
6774
updateWaitResourceHash(this.currentDeleteWaitResource, digest);
6875

6976
node.addDependency(this.currentCreateWaitResource.node.defaultChild as CfnCustomResource);
70-
this.currentDeleteWaitResource.node.addDependency(node);
77+
78+
if (node.cfnOptions.condition) {
79+
this.initDummyResource(scope);
80+
if (!this.currentDummyResource) {
81+
throw new Error('Dummy resource not initialized!');
82+
}
83+
this.currentDummyResource.addMetadata(
84+
`${node.logicalId}Ready`,
85+
Fn.conditionIf(node.cfnOptions.condition.logicalId, Fn.ref(node.logicalId), '')
86+
);
87+
this.currentDeleteWaitResource.node.addDependency(this.currentDummyResource);
88+
} else {
89+
this.currentDeleteWaitResource.node.addDependency(node);
90+
}
7191

7292
++this.documentIndex;
7393

@@ -77,6 +97,7 @@ export default class SsmDocRateLimit implements IAspect {
7797
this.previousDeleteWaitResource = this.currentDeleteWaitResource;
7898
this.currentCreateWaitResource = undefined;
7999
this.currentDeleteWaitResource = undefined;
100+
this.currentDummyResource = undefined;
80101
this.hash = createHash('sha256');
81102
}
82103
}

source/playbooks/AFSBP/test/__snapshots__/afsbp_stack.test.ts.snap

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,9 +1131,7 @@ def parse_event(event, _):
11311131
"DeletWait0": {
11321132
"DeletionPolicy": "Delete",
11331133
"DependsOn": [
1134-
"ControlAFSBPEC21",
1135-
"ControlAFSBPLambda1",
1136-
"ControlAFSBPRDS1",
1134+
"Gate0",
11371135
],
11381136
"Properties": {
11391137
"CreateIntervalSeconds": 0,
@@ -1147,6 +1145,38 @@ def parse_event(event, _):
11471145
"Type": "Custom::Wait",
11481146
"UpdateReplacePolicy": "Delete",
11491147
},
1148+
"Gate0": {
1149+
"Metadata": {
1150+
"ControlAFSBPEC21Ready": {
1151+
"Fn::If": [
1152+
"EnableEC21Condition",
1153+
{
1154+
"Ref": "ControlAFSBPEC21",
1155+
},
1156+
"",
1157+
],
1158+
},
1159+
"ControlAFSBPLambda1Ready": {
1160+
"Fn::If": [
1161+
"EnableLambda1Condition",
1162+
{
1163+
"Ref": "ControlAFSBPLambda1",
1164+
},
1165+
"",
1166+
],
1167+
},
1168+
"ControlAFSBPRDS1Ready": {
1169+
"Fn::If": [
1170+
"EnableRDS1Condition",
1171+
{
1172+
"Ref": "ControlAFSBPRDS1",
1173+
},
1174+
"",
1175+
],
1176+
},
1177+
},
1178+
"Type": "AWS::CloudFormation::WaitConditionHandle",
1179+
},
11501180
},
11511181
}
11521182
`;

source/playbooks/CIS120/test/__snapshots__/cis_stack.test.ts.snap

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,9 +1481,7 @@ def parse_event(event, _):
14811481
"DeletWait0": {
14821482
"DeletionPolicy": "Delete",
14831483
"DependsOn": [
1484-
"ControlCIS13",
1485-
"ControlCIS15",
1486-
"ControlCIS21",
1484+
"Gate0",
14871485
],
14881486
"Properties": {
14891487
"CreateIntervalSeconds": 0,
@@ -1497,6 +1495,38 @@ def parse_event(event, _):
14971495
"Type": "Custom::Wait",
14981496
"UpdateReplacePolicy": "Delete",
14991497
},
1498+
"Gate0": {
1499+
"Metadata": {
1500+
"ControlCIS13Ready": {
1501+
"Fn::If": [
1502+
"Enable13Condition",
1503+
{
1504+
"Ref": "ControlCIS13",
1505+
},
1506+
"",
1507+
],
1508+
},
1509+
"ControlCIS15Ready": {
1510+
"Fn::If": [
1511+
"Enable15Condition",
1512+
{
1513+
"Ref": "ControlCIS15",
1514+
},
1515+
"",
1516+
],
1517+
},
1518+
"ControlCIS21Ready": {
1519+
"Fn::If": [
1520+
"Enable21Condition",
1521+
{
1522+
"Ref": "ControlCIS21",
1523+
},
1524+
"",
1525+
],
1526+
},
1527+
},
1528+
"Type": "AWS::CloudFormation::WaitConditionHandle",
1529+
},
15001530
"RemapCIS4245EB49A0": {
15011531
"Properties": {
15021532
"Description": "Remap the CIS 4.2 finding to CIS 4.1 remediation",

0 commit comments

Comments
 (0)