Skip to content

Commit 0db3dc2

Browse files
kaizenccgithub-actions
and
github-actions
authored
chore(tmp-toolkit-helpers): formatStackDiff and formatSecurityDiff moved to tmp-toolkit-helpers (#278)
moves two functions that are relevant to diff into `tmp-toolkit-helpers`. this will facilitate the adoption of `diff` in the programmatic toolkit as it will now be able to reuse these functions. existing tests pass to ensure no breakage. additional tests have been added to `tmp-toolkit-helpers` to test `formatXxxDiff`, which were previously not directly tested. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent a3b9762 commit 0db3dc2

File tree

20 files changed

+665
-333
lines changed

20 files changed

+665
-333
lines changed

.projenrc.ts

+2
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,9 @@ const tmpToolkitHelpers = configureProject(
680680
],
681681
deps: [
682682
cloudAssemblySchema.name,
683+
cloudFormationDiff,
683684
'archiver',
685+
'chalk@4',
684686
'glob',
685687
'semver',
686688
'uuid',

packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/tmp-toolkit-helpers/package.json

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './nested-stack-templates';
2+
export * from './stack-helpers';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Template } from './stack-helpers';
2+
3+
export interface NestedStackTemplates {
4+
readonly physicalName: string | undefined;
5+
readonly deployedTemplate: Template;
6+
readonly generatedTemplate: Template;
7+
readonly nestedStackTemplates: {
8+
[nestedStackLogicalId: string]: NestedStackTemplates;
9+
};
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface Template {
2+
Parameters?: Record<string, TemplateParameter>;
3+
[section: string]: any;
4+
}
5+
6+
export interface TemplateParameter {
7+
Type: string;
8+
Default?: any;
9+
Description?: string;
10+
[key: string]: any;
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { format } from 'node:util';
2+
import { Writable } from 'stream';
3+
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
4+
import {
5+
type DescribeChangeSetOutput,
6+
type TemplateDiff,
7+
fullDiff,
8+
formatSecurityChanges,
9+
formatDifferences,
10+
mangleLikeCloudFormation,
11+
} from '@aws-cdk/cloudformation-diff';
12+
import type * as cxapi from '@aws-cdk/cx-api';
13+
import * as chalk from 'chalk';
14+
15+
import { RequireApproval } from '../../api/require-approval';
16+
import { ToolkitError } from '../../api/toolkit-error';
17+
import type { NestedStackTemplates } from '../cloudformation/nested-stack-templates';
18+
import type { IoHelper } from '../io/private';
19+
import { IoDefaultMessages } from '../io/private';
20+
21+
/*
22+
* Custom writable stream that collects text into a string buffer.
23+
* Used on classes that take in and directly write to a stream, but
24+
* we intend to capture the output rather than print.
25+
*/
26+
export class StringWriteStream extends Writable {
27+
private buffer: string[] = [];
28+
29+
constructor() {
30+
super();
31+
}
32+
33+
_write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void {
34+
this.buffer.push(chunk.toString());
35+
callback();
36+
}
37+
38+
toString(): string {
39+
return this.buffer.join('');
40+
}
41+
}
42+
43+
/**
44+
* Output of formatSecurityDiff
45+
*/
46+
export interface FormatSecurityDiffOutput {
47+
/**
48+
* Complete formatted security diff, if it is prompt-worthy
49+
*/
50+
readonly formattedDiff?: string;
51+
}
52+
53+
/**
54+
* Formats the security changes of this diff, if the change is impactful enough according to the approval level
55+
*
56+
* Returns the diff if the changes are prompt-worthy, an empty object otherwise.
57+
*/
58+
export function formatSecurityDiff(
59+
ioHelper: IoHelper,
60+
oldTemplate: any,
61+
newTemplate: cxapi.CloudFormationStackArtifact,
62+
requireApproval: RequireApproval,
63+
stackName?: string,
64+
changeSet?: DescribeChangeSetOutput,
65+
): FormatSecurityDiffOutput {
66+
const ioDefaultHelper = new IoDefaultMessages(ioHelper);
67+
68+
const diff = fullDiff(oldTemplate, newTemplate.template, changeSet);
69+
70+
if (diffRequiresApproval(diff, requireApproval)) {
71+
ioDefaultHelper.info(format('Stack %s\n', chalk.bold(stackName)));
72+
73+
// eslint-disable-next-line max-len
74+
ioDefaultHelper.warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${requireApproval}).`);
75+
ioDefaultHelper.warning('Please confirm you intend to make the following modifications:\n');
76+
77+
// The security diff is formatted via `Formatter`, which takes in a stream
78+
// and sends its output directly to that stream. To faciliate use of the
79+
// global CliIoHost, we create our own stream to capture the output of
80+
// `Formatter` and return the output as a string for the consumer of
81+
// `formatSecurityDiff` to decide what to do with it.
82+
const stream = new StringWriteStream();
83+
try {
84+
// formatSecurityChanges updates the stream with the formatted security diff
85+
formatSecurityChanges(stream, diff, buildLogicalToPathMap(newTemplate));
86+
} finally {
87+
stream.end();
88+
}
89+
// store the stream containing a formatted stack diff
90+
const formattedDiff = stream.toString();
91+
return { formattedDiff };
92+
}
93+
return {};
94+
}
95+
96+
/**
97+
* Output of formatStackDiff
98+
*/
99+
export interface FormatStackDiffOutput {
100+
/**
101+
* Number of stacks with diff changes
102+
*/
103+
readonly numStacksWithChanges: number;
104+
105+
/**
106+
* Complete formatted diff
107+
*/
108+
readonly formattedDiff: string;
109+
}
110+
111+
/**
112+
* Formats the differences between two template states and returns it as a string.
113+
*
114+
* @param oldTemplate the old/current state of the stack.
115+
* @param newTemplate the new/target state of the stack.
116+
* @param strict do not filter out AWS::CDK::Metadata or Rules
117+
* @param context lines of context to use in arbitrary JSON diff
118+
* @param quiet silences \'There were no differences\' messages
119+
*
120+
* @returns the formatted diff, and the number of stacks in this stack tree that have differences, including the top-level root stack
121+
*/
122+
export function formatStackDiff(
123+
ioHelper: IoHelper,
124+
oldTemplate: any,
125+
newTemplate: cxapi.CloudFormationStackArtifact,
126+
strict: boolean,
127+
context: number,
128+
quiet: boolean,
129+
stackName?: string,
130+
changeSet?: DescribeChangeSetOutput,
131+
isImport?: boolean,
132+
nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): FormatStackDiffOutput {
133+
const ioDefaultHelper = new IoDefaultMessages(ioHelper);
134+
135+
let diff = fullDiff(oldTemplate, newTemplate.template, changeSet, isImport);
136+
137+
// The stack diff is formatted via `Formatter`, which takes in a stream
138+
// and sends its output directly to that stream. To faciliate use of the
139+
// global CliIoHost, we create our own stream to capture the output of
140+
// `Formatter` and return the output as a string for the consumer of
141+
// `formatStackDiff` to decide what to do with it.
142+
const stream = new StringWriteStream();
143+
144+
let numStacksWithChanges = 0;
145+
let formattedDiff = '';
146+
let filteredChangesCount = 0;
147+
try {
148+
// must output the stack name if there are differences, even if quiet
149+
if (stackName && (!quiet || !diff.isEmpty)) {
150+
stream.write(format('Stack %s\n', chalk.bold(stackName)));
151+
}
152+
153+
if (!quiet && isImport) {
154+
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
155+
}
156+
157+
// detect and filter out mangled characters from the diff
158+
if (diff.differenceCount && !strict) {
159+
const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(newTemplate.template)));
160+
const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, changeSet);
161+
filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount);
162+
if (filteredChangesCount > 0) {
163+
diff = mangledDiff;
164+
}
165+
}
166+
167+
// filter out 'AWS::CDK::Metadata' resources from the template
168+
// filter out 'CheckBootstrapVersion' rules from the template
169+
if (!strict) {
170+
obscureDiff(diff);
171+
}
172+
173+
if (!diff.isEmpty) {
174+
numStacksWithChanges++;
175+
176+
// formatDifferences updates the stream with the formatted stack diff
177+
formatDifferences(stream, diff, {
178+
...logicalIdMapFromTemplate(oldTemplate),
179+
...buildLogicalToPathMap(newTemplate),
180+
}, context);
181+
182+
// store the stream containing a formatted stack diff
183+
formattedDiff = stream.toString();
184+
} else if (!quiet) {
185+
ioDefaultHelper.info(chalk.green('There were no differences'));
186+
}
187+
} finally {
188+
stream.end();
189+
}
190+
191+
if (filteredChangesCount > 0) {
192+
ioDefaultHelper.info(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.`));
193+
}
194+
195+
for (const nestedStackLogicalId of Object.keys(nestedStackTemplates ?? {})) {
196+
if (!nestedStackTemplates) {
197+
break;
198+
}
199+
const nestedStack = nestedStackTemplates[nestedStackLogicalId];
200+
201+
(newTemplate as any)._template = nestedStack.generatedTemplate;
202+
const nextDiff = formatStackDiff(
203+
ioHelper,
204+
nestedStack.deployedTemplate,
205+
newTemplate,
206+
strict,
207+
context,
208+
quiet,
209+
nestedStack.physicalName ?? nestedStackLogicalId,
210+
undefined,
211+
isImport,
212+
nestedStack.nestedStackTemplates,
213+
);
214+
numStacksWithChanges += nextDiff.numStacksWithChanges;
215+
formattedDiff += nextDiff.formattedDiff;
216+
}
217+
218+
return {
219+
numStacksWithChanges,
220+
formattedDiff,
221+
};
222+
}
223+
224+
/**
225+
* Return whether the diff has security-impacting changes that need confirmation
226+
*
227+
* TODO: Filter the security impact determination based off of an enum that allows
228+
* us to pick minimum "severities" to alert on.
229+
*/
230+
function diffRequiresApproval(diff: TemplateDiff, requireApproval: RequireApproval) {
231+
switch (requireApproval) {
232+
case RequireApproval.NEVER: return false;
233+
case RequireApproval.ANY_CHANGE: return diff.permissionsAnyChanges;
234+
case RequireApproval.BROADENING: return diff.permissionsBroadened;
235+
default: throw new ToolkitError(`Unrecognized approval level: ${requireApproval}`);
236+
}
237+
}
238+
239+
export function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) {
240+
const map: { [id: string]: string } = {};
241+
for (const md of stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) {
242+
map[md.data as string] = md.path;
243+
}
244+
return map;
245+
}
246+
247+
export function logicalIdMapFromTemplate(template: any) {
248+
const ret: Record<string, string> = {};
249+
250+
for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
251+
const path = (resource as any)?.Metadata?.['aws:cdk:path'];
252+
if (path) {
253+
ret[logicalId] = path;
254+
}
255+
}
256+
return ret;
257+
}
258+
259+
/**
260+
* Remove any template elements that we don't want to show users.
261+
* This is currently:
262+
* - AWS::CDK::Metadata resource
263+
* - CheckBootstrapVersion Rule
264+
*/
265+
export function obscureDiff(diff: TemplateDiff) {
266+
if (diff.unknown) {
267+
// see https://github.com/aws/aws-cdk/issues/17942
268+
diff.unknown = diff.unknown.filter(change => {
269+
if (!change) {
270+
return true;
271+
}
272+
if (change.newValue?.CheckBootstrapVersion) {
273+
return false;
274+
}
275+
if (change.oldValue?.CheckBootstrapVersion) {
276+
return false;
277+
}
278+
return true;
279+
});
280+
}
281+
282+
if (diff.resources) {
283+
diff.resources = diff.resources.filter(change => {
284+
if (!change) {
285+
return true;
286+
}
287+
if (change.newResourceType === 'AWS::CDK::Metadata') {
288+
return false;
289+
}
290+
if (change.oldResourceType === 'AWS::CDK::Metadata') {
291+
return false;
292+
}
293+
return true;
294+
});
295+
}
296+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './diff';
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from './cloud-assembly';
2+
export * from './cloudformation';
3+
export * from './diff';
24
export * from './io';
35
export * from './toolkit-error';
46
export * from './require-approval';

0 commit comments

Comments
 (0)