Skip to content

Commit 5b3d06d

Browse files
authored
feat(integ-runner): support snapshot diff on nested stacks (#22881)
Previously we did not run diff Nested Stack templates separately. Any change would have been detected by the changed asset hash in the `AWS::CloudFormation::Stack` resource. With this change, nested templates are diff'ed as part of the integ test and any errors will be printed. The change allows us to then ignore the asset hash in the s3 url for nested stacks if `diffAsset: false` (the default) is set. Previously this was the only way to detect changes in a nested stack. However the hash would also change if an asset inside the nested stack has changed (which should be ignored). With the change to diff nested stack templates, asset hashes in nested stack template are now transparently ignored. ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [x] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [x] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent fc4668d commit 5b3d06d

File tree

53 files changed

+2972
-381
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2972
-381
lines changed

packages/@aws-cdk/integ-runner/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Test Results:
169169
Tests: 1 passed, 1 total
170170
```
171171

172+
Nested stack templates are also compared as part of the snapshot. However asset hashes are ignored by default. To enable diff for asset hashes, set `diffAssets: true` of `IntegTestProps`.
173+
172174
#### Update Workflow
173175

174176
By default, integration tests are run with the "update workflow" enabled. This can be disabled by using the `--disable-update-workflow` command line option.

packages/@aws-cdk/integ-runner/lib/runner/private/cloud-assembly.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ export class AssemblyManifestReader {
7878
return stacks;
7979
}
8080

81+
/**
82+
* Get the nested stacks for a given stack
83+
* returns a map of artifactId to CloudFormation template
84+
*/
85+
public getNestedStacksForStack(stackId: string): Record<string, any> {
86+
const nestedTemplates: string[] = this.getAssetManifestsForStack(stackId).flatMap(
87+
manifest => manifest.files
88+
.filter(asset => asset.source.path?.endsWith('.nested.template.json'))
89+
.map(asset => asset.source.path!),
90+
);
91+
92+
const nestedStacks: Record<string, any> = Object.fromEntries(nestedTemplates.map(templateFile => ([
93+
templateFile.split('.', 1)[0],
94+
fs.readJSONSync(path.resolve(this.directory, templateFile)),
95+
])));
96+
97+
return nestedStacks;
98+
}
99+
81100
/**
82101
* Write trace data to the assembly manifest metadata
83102
*/
@@ -125,6 +144,19 @@ export class AssemblyManifestReader {
125144
return assets;
126145
}
127146

147+
/**
148+
* Return a list of asset artifacts for a given stack
149+
*/
150+
public getAssetManifestsForStack(stackId: string): AssetManifest[] {
151+
return Object.values(this.manifest.artifacts ?? {})
152+
.filter(artifact =>
153+
artifact.type === ArtifactType.ASSET_MANIFEST && (artifact.properties as AssetManifestProperties)?.file === `${stackId}.assets.json`)
154+
.map(artifact => {
155+
const fileName = (artifact.properties as AssetManifestProperties).file;
156+
return AssetManifest.fromFile(path.join(this.directory, fileName));
157+
});
158+
}
159+
128160
/**
129161
* Get a list of assets from the assembly manifest
130162
*/
@@ -153,7 +185,7 @@ export class AssemblyManifestReader {
153185
assetManifest.entries.forEach(entry => {
154186
if (entry.type === 'file') {
155187
const source = (entry as FileManifestEntry).source;
156-
if (source.path && source.path.startsWith('asset.')) {
188+
if (source.path && (source.path.startsWith('asset.') || source.path.endsWith('nested.template.json'))) {
157189
assets.push(entry as FileManifestEntry);
158190
}
159191
} else if (entry.type === 'docker-image') {

packages/@aws-cdk/integ-runner/lib/runner/snapshot-test-runner.ts

Lines changed: 133 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ import { Diagnostic, DiagnosticReason, DestructiveChange, SnapshotVerificationOp
66
import { AssemblyManifestReader } from './private/cloud-assembly';
77
import { IntegRunnerOptions, IntegRunner, DEFAULT_SYNTH_OPTIONS } from './runner-base';
88

9+
interface SnapshotAssembly {
10+
/**
11+
* Map of stacks that are part of this assembly
12+
*/
13+
[stackName: string]: {
14+
/**
15+
* All templates for this stack, including nested stacks
16+
*/
17+
templates: {
18+
[templateId: string]: any
19+
},
20+
21+
/**
22+
* List of asset Ids that are used by this assembly
23+
*/
24+
assets: string[]
25+
}
26+
}
27+
928
/**
1029
* Runner for snapshot tests. This handles orchestrating
1130
* the validation of the integration test snapshots
@@ -24,15 +43,7 @@ export class IntegSnapshotRunner extends IntegRunner {
2443
public testSnapshot(options: SnapshotVerificationOptions = {}): { diagnostics: Diagnostic[], destructiveChanges: DestructiveChange[] } {
2544
let doClean = true;
2645
try {
27-
// read the existing snapshot
28-
const expectedStacks = this.readAssembly(this.snapshotDir);
29-
// only diff stacks that are part of the test case
30-
const expectedStacksToDiff: Record<string, any> = {};
31-
for (const [stackName, template] of Object.entries(expectedStacks)) {
32-
if (this.expectedTestSuite?.stacks.includes(stackName)) {
33-
expectedStacksToDiff[stackName] = template;
34-
}
35-
}
46+
const expectedSnapshotAssembly = this.getSnapshotAssembly(this.snapshotDir, this.expectedTestSuite?.stacks);
3647

3748
// synth the integration test
3849
// FIXME: ideally we should not need to run this again if
@@ -52,18 +63,10 @@ export class IntegSnapshotRunner extends IntegRunner {
5263
});
5364

5465
// read the "actual" snapshot
55-
const actualDir = this.cdkOutDir;
56-
const actualStacks = this.readAssembly(actualDir);
57-
// only diff stacks that are part of the test case
58-
const actualStacksToDiff: Record<string, any> = {};
59-
for (const [stackName, template] of Object.entries(actualStacks)) {
60-
if (this.actualTestSuite.stacks.includes(stackName)) {
61-
actualStacksToDiff[stackName] = template;
62-
}
63-
}
66+
const actualSnapshotAssembly = this.getSnapshotAssembly(this.cdkOutDir, this.actualTestSuite.stacks);
6467

6568
// diff the existing snapshot (expected) with the integration test (actual)
66-
const diagnostics = this.diffAssembly(expectedStacksToDiff, actualStacksToDiff);
69+
const diagnostics = this.diffAssembly(expectedSnapshotAssembly, actualSnapshotAssembly);
6770

6871
if (diagnostics.diagnostics.length) {
6972
// Attach additional messages to the first diagnostic
@@ -72,7 +75,7 @@ export class IntegSnapshotRunner extends IntegRunner {
7275
if (options.retain) {
7376
additionalMessages.push(
7477
`(Failure retained) Expected: ${path.relative(process.cwd(), this.snapshotDir)}`,
75-
` Actual: ${path.relative(process.cwd(), actualDir)}`,
78+
` Actual: ${path.relative(process.cwd(), this.cdkOutDir)}`,
7679
),
7780
doClean = false;
7881
}
@@ -107,6 +110,36 @@ export class IntegSnapshotRunner extends IntegRunner {
107110
}
108111
}
109112

113+
/**
114+
* For a given cloud assembly return a collection of all templates
115+
* that should be part of the snapshot and any required meta data.
116+
*
117+
* @param cloudAssemblyDir The directory of the cloud assembly to look for snapshots
118+
* @param pickStacks Pick only these stacks from the cloud assembly
119+
* @returns A SnapshotAssembly, the collection of all templates in this snapshot and required meta data
120+
*/
121+
private getSnapshotAssembly(cloudAssemblyDir: string, pickStacks: string[] = []): SnapshotAssembly {
122+
const assembly = this.readAssembly(cloudAssemblyDir);
123+
const stacks = assembly.stacks;
124+
const snapshots: SnapshotAssembly = {};
125+
for (const [stackName, stackTemplate] of Object.entries(stacks)) {
126+
if (pickStacks.includes(stackName)) {
127+
const manifest = AssemblyManifestReader.fromPath(cloudAssemblyDir);
128+
const assets = manifest.getAssetIdsForStack(stackName);
129+
130+
snapshots[stackName] = {
131+
templates: {
132+
[stackName]: stackTemplate,
133+
...assembly.getNestedStacksForStack(stackName),
134+
},
135+
assets,
136+
};
137+
}
138+
}
139+
140+
return snapshots;
141+
}
142+
110143
/**
111144
* For a given stack return all resource types that are allowed to be destroyed
112145
* as part of a stack update
@@ -131,86 +164,98 @@ export class IntegSnapshotRunner extends IntegRunner {
131164
* @returns any diagnostics and any destructive changes
132165
*/
133166
private diffAssembly(
134-
expected: Record<string, any>,
135-
actual: Record<string, any>,
167+
expected: SnapshotAssembly,
168+
actual: SnapshotAssembly,
136169
): { diagnostics: Diagnostic[], destructiveChanges: DestructiveChange[] } {
137170
const failures: Diagnostic[] = [];
138171
const destructiveChanges: DestructiveChange[] = [];
139172

140173
// check if there is a CFN template in the current snapshot
141174
// that does not exist in the "actual" snapshot
142-
for (const templateId of Object.keys(expected)) {
143-
if (!actual.hasOwnProperty(templateId)) {
144-
failures.push({
145-
testName: this.testName,
146-
reason: DiagnosticReason.SNAPSHOT_FAILED,
147-
message: `${templateId} exists in snapshot, but not in actual`,
148-
});
175+
for (const [stackId, stack] of Object.entries(expected)) {
176+
for (const templateId of Object.keys(stack.templates)) {
177+
if (!actual[stackId]?.templates[templateId]) {
178+
failures.push({
179+
testName: this.testName,
180+
stackName: templateId,
181+
reason: DiagnosticReason.SNAPSHOT_FAILED,
182+
message: `${templateId} exists in snapshot, but not in actual`,
183+
});
184+
}
149185
}
150186
}
151187

152-
for (const templateId of Object.keys(actual)) {
188+
for (const [stackId, stack] of Object.entries(actual)) {
189+
for (const templateId of Object.keys(stack.templates)) {
153190
// check if there is a CFN template in the "actual" snapshot
154191
// that does not exist in the current snapshot
155-
if (!expected.hasOwnProperty(templateId)) {
156-
failures.push({
157-
testName: this.testName,
158-
reason: DiagnosticReason.SNAPSHOT_FAILED,
159-
message: `${templateId} does not exist in snapshot, but does in actual`,
160-
});
161-
continue;
162-
} else {
163-
let actualTemplate = actual[templateId];
164-
let expectedTemplate = expected[templateId];
165-
const allowedDestroyTypes = this.getAllowedDestroyTypesForStack(templateId) ?? [];
166-
167-
// if we are not verifying asset hashes then remove the specific
168-
// asset hashes from the templates so they are not part of the diff
169-
// comparison
170-
if (!this.actualTestSuite.getOptionsForStack(templateId)?.diffAssets) {
171-
actualTemplate = this.canonicalizeTemplate(actualTemplate, templateId, this.cdkOutDir);
172-
expectedTemplate = this.canonicalizeTemplate(expectedTemplate, templateId, this.snapshotDir);
173-
}
174-
const templateDiff = diffTemplate(expectedTemplate, actualTemplate);
175-
if (!templateDiff.isEmpty) {
176-
// go through all the resource differences and check for any
177-
// "destructive" changes
178-
templateDiff.resources.forEachDifference((logicalId: string, change: ResourceDifference) => {
192+
if (!expected[stackId]?.templates[templateId]) {
193+
failures.push({
194+
testName: this.testName,
195+
stackName: templateId,
196+
reason: DiagnosticReason.SNAPSHOT_FAILED,
197+
message: `${templateId} does not exist in snapshot, but does in actual`,
198+
});
199+
continue;
200+
} else {
201+
const config = {
202+
diffAssets: this.actualTestSuite.getOptionsForStack(stackId)?.diffAssets,
203+
};
204+
let actualTemplate = actual[stackId].templates[templateId];
205+
let expectedTemplate = expected[stackId].templates[templateId];
206+
207+
// if we are not verifying asset hashes then remove the specific
208+
// asset hashes from the templates so they are not part of the diff
209+
// comparison
210+
if (!config.diffAssets) {
211+
actualTemplate = this.canonicalizeTemplate(actualTemplate, actual[stackId].assets);
212+
expectedTemplate = this.canonicalizeTemplate(expectedTemplate, expected[stackId].assets);
213+
}
214+
const templateDiff = diffTemplate(expectedTemplate, actualTemplate);
215+
if (!templateDiff.isEmpty) {
216+
const allowedDestroyTypes = this.getAllowedDestroyTypesForStack(stackId) ?? [];
217+
218+
// go through all the resource differences and check for any
219+
// "destructive" changes
220+
templateDiff.resources.forEachDifference((logicalId: string, change: ResourceDifference) => {
179221
// if the change is a removal it will not show up as a 'changeImpact'
180222
// so need to check for it separately, unless it is a resourceType that
181223
// has been "allowed" to be destroyed
182-
const resourceType = change.oldValue?.Type ?? change.newValue?.Type;
183-
if (resourceType && allowedDestroyTypes.includes(resourceType)) {
184-
return;
185-
}
186-
if (change.isRemoval) {
187-
destructiveChanges.push({
188-
impact: ResourceImpact.WILL_DESTROY,
189-
logicalId,
190-
stackName: templateId,
191-
});
192-
} else {
193-
switch (change.changeImpact) {
194-
case ResourceImpact.MAY_REPLACE:
195-
case ResourceImpact.WILL_ORPHAN:
196-
case ResourceImpact.WILL_DESTROY:
197-
case ResourceImpact.WILL_REPLACE:
198-
destructiveChanges.push({
199-
impact: change.changeImpact,
200-
logicalId,
201-
stackName: templateId,
202-
});
203-
break;
224+
const resourceType = change.oldValue?.Type ?? change.newValue?.Type;
225+
if (resourceType && allowedDestroyTypes.includes(resourceType)) {
226+
return;
204227
}
205-
}
206-
});
207-
const writable = new StringWritable({});
208-
formatDifferences(writable, templateDiff);
209-
failures.push({
210-
reason: DiagnosticReason.SNAPSHOT_FAILED,
211-
message: writable.data,
212-
testName: this.testName,
213-
});
228+
if (change.isRemoval) {
229+
destructiveChanges.push({
230+
impact: ResourceImpact.WILL_DESTROY,
231+
logicalId,
232+
stackName: templateId,
233+
});
234+
} else {
235+
switch (change.changeImpact) {
236+
case ResourceImpact.MAY_REPLACE:
237+
case ResourceImpact.WILL_ORPHAN:
238+
case ResourceImpact.WILL_DESTROY:
239+
case ResourceImpact.WILL_REPLACE:
240+
destructiveChanges.push({
241+
impact: change.changeImpact,
242+
logicalId,
243+
stackName: templateId,
244+
});
245+
break;
246+
}
247+
}
248+
});
249+
const writable = new StringWritable({});
250+
formatDifferences(writable, templateDiff);
251+
failures.push({
252+
reason: DiagnosticReason.SNAPSHOT_FAILED,
253+
message: writable.data,
254+
stackName: templateId,
255+
testName: this.testName,
256+
config,
257+
});
258+
}
214259
}
215260
}
216261
}
@@ -221,11 +266,8 @@ export class IntegSnapshotRunner extends IntegRunner {
221266
};
222267
}
223268

224-
private readAssembly(dir: string): Record<string, any> {
225-
const assembly = AssemblyManifestReader.fromPath(dir);
226-
const stacks = assembly.stacks;
227-
228-
return stacks;
269+
private readAssembly(dir: string): AssemblyManifestReader {
270+
return AssemblyManifestReader.fromPath(dir);
229271
}
230272

231273
/**
@@ -234,7 +276,7 @@ export class IntegSnapshotRunner extends IntegRunner {
234276
* This makes it possible to compare templates if all that's different between
235277
* them is the hashes of the asset values.
236278
*/
237-
private canonicalizeTemplate(template: any, stackName: string, manifestDir: string): any {
279+
private canonicalizeTemplate(template: any, assets: string[]): any {
238280
const assetsSeen = new Set<string>();
239281
const stringSubstitutions = new Array<[RegExp, string]>();
240282

@@ -262,8 +304,6 @@ export class IntegSnapshotRunner extends IntegRunner {
262304

263305
// find assets defined in the asset manifest
264306
try {
265-
const manifest = AssemblyManifestReader.fromPath(manifestDir);
266-
const assets = manifest.getAssetIdsForStack(stackName);
267307
assets.forEach(asset => {
268308
if (!assetsSeen.has(asset)) {
269309
assetsSeen.add(asset);

packages/@aws-cdk/integ-runner/lib/workers/common.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ export interface Diagnostic {
221221
*/
222222
readonly testName: string;
223223

224+
/**
225+
* The name of the stack
226+
*/
227+
readonly stackName: string;
228+
224229
/**
225230
* The diagnostic message
226231
*/
@@ -240,6 +245,11 @@ export interface Diagnostic {
240245
* Additional messages to print
241246
*/
242247
readonly additionalMessages?: string[];
248+
249+
/**
250+
* Relevant config options that were used for the integ test
251+
*/
252+
readonly config?: Record<string, any>;
243253
}
244254

245255
export function printSummary(total: number, failed: number): void {

0 commit comments

Comments
 (0)