Skip to content

Commit 877334f

Browse files
committed
Add conditional behavior, add tests for conditional behavior
1 parent 4a31549 commit 877334f

File tree

9 files changed

+1340
-135
lines changed

9 files changed

+1340
-135
lines changed

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

Lines changed: 508 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 203 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,222 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
import { CfnDocument } from 'aws-cdk-lib/aws-ssm';
4-
import { Aspects, Stack } from 'aws-cdk-lib';
4+
import { Aspects, CfnCondition, CfnParameter, Fn, Stack } from 'aws-cdk-lib';
55
import SsmDocRateLimit from './ssm-doc-rate-limit';
66
import { WaitProvider } from './wait-provider';
77
import { Template } from 'aws-cdk-lib/assertions';
88

99
describe('SSM doc rate limit aspect', function () {
10-
it('configures dependencies for single document', function () {
11-
const stack = new Stack();
12-
const serviceToken = 'my-token';
13-
const waitProvider = WaitProvider.fromServiceToken(stack, 'WaitProvider', serviceToken);
14-
Aspects.of(stack).add(new SsmDocRateLimit(waitProvider));
15-
const content = {};
16-
new CfnDocument(stack, 'Document', { content });
17-
const template = Template.fromStack(stack);
18-
19-
const documents = template.findResources('AWS::SSM::Document', { Properties: { Content: content } });
20-
const documentLogicalIds = Object.getOwnPropertyNames(documents);
21-
expect(documentLogicalIds).toHaveLength(1);
22-
const documentLogicalId = documentLogicalIds[0];
23-
const document = documents[documentLogicalId];
24-
25-
const createWaits = template.findResources('Custom::Wait', {
26-
Properties: {
27-
CreateIntervalSeconds: 1,
28-
UpdateIntervalSeconds: 1,
29-
DeleteIntervalSeconds: 0,
30-
ServiceToken: serviceToken,
31-
},
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+
);
3274
});
33-
const createWaitLogicalIds = Object.getOwnPropertyNames(createWaits);
34-
expect(createWaitLogicalIds).toHaveLength(1);
35-
const createWaitLogicalId = createWaitLogicalIds[0];
36-
expect(document.DependsOn).toEqual(expect.arrayContaining([createWaitLogicalId]));
37-
38-
const deleteWaits = template.findResources('Custom::Wait', {
39-
Properties: {
40-
CreateIntervalSeconds: 0,
41-
UpdateIntervalSeconds: 0,
42-
DeleteIntervalSeconds: 0.5,
43-
ServiceToken: serviceToken,
44-
},
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+
});
4584
});
46-
const deleteWaitLogicalIds = Object.getOwnPropertyNames(deleteWaits);
47-
expect(deleteWaitLogicalIds).toHaveLength(1);
48-
const deleteWait = deleteWaits[deleteWaitLogicalIds[0]];
49-
expect(deleteWait.DependsOn).toEqual(expect.arrayContaining([documentLogicalId]));
50-
});
51-
52-
it('configures dependencies for many documents', function () {
53-
const stack = new Stack();
54-
const serviceToken = 'my-token';
55-
const waitProvider = WaitProvider.fromServiceToken(stack, 'WaitProvider', serviceToken);
56-
Aspects.of(stack).add(new SsmDocRateLimit(waitProvider));
57-
const content = {};
58-
const numDocuments = 12;
59-
for (let i = 0; i < numDocuments; ++i) {
60-
new CfnDocument(stack, `Document${i}`, { content });
61-
}
62-
const template = Template.fromStack(stack);
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 () {
63106
expect(template).toMatchSnapshot();
107+
});
64108

65-
const documents = template.findResources('AWS::SSM::Document', { Properties: { Content: content } });
66-
const documentLogicalIds = Object.getOwnPropertyNames(documents);
67-
expect(documentLogicalIds).toHaveLength(numDocuments);
109+
const documents = template.findResources('AWS::SSM::Document', { Properties: { Content: content } });
110+
const documentLogicalIds = Object.getOwnPropertyNames(documents);
68111

69-
const expectedBatchSize = 5;
112+
const expectedBatchSize = 5;
70113

71-
const createWaits = template.findResources('Custom::Wait', {
72-
Properties: {
73-
CreateIntervalSeconds: 1,
74-
UpdateIntervalSeconds: 1,
75-
DeleteIntervalSeconds: 0,
76-
ServiceToken: serviceToken,
77-
},
78-
});
79-
const createWaitLogicalIds = Object.getOwnPropertyNames(createWaits);
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 () {
80125
expect(createWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
126+
});
81127

82-
const deleteWaits = template.findResources('Custom::Wait', {
83-
Properties: {
84-
CreateIntervalSeconds: 0,
85-
UpdateIntervalSeconds: 0,
86-
DeleteIntervalSeconds: 0.5,
87-
ServiceToken: serviceToken,
88-
},
89-
});
90-
const deleteWaitLogicalIds = Object.getOwnPropertyNames(deleteWaits);
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 () {
91139
expect(deleteWaitLogicalIds).toHaveLength(Math.ceil(numDocuments / expectedBatchSize));
92140
});
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+
});
93187
});
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
}

0 commit comments

Comments
 (0)