diff --git a/packages/aws-cdk-lib/core/lib/app.ts b/packages/aws-cdk-lib/core/lib/app.ts index d24ec829f00df..019919c8b85d4 100644 --- a/packages/aws-cdk-lib/core/lib/app.ts +++ b/packages/aws-cdk-lib/core/lib/app.ts @@ -192,7 +192,7 @@ export class App extends Stage { if (autoSynth) { // synth() guarantees it will only execute once, so a default of 'true' // doesn't bite manual calling of the function. - process.once('beforeExit', () => this.synth()); + process.once('beforeExit', () => this.synth({ errorOnDuplicateSynth: false })); } this._treeMetadata = props.treeMetadata ?? true; diff --git a/packages/aws-cdk-lib/core/lib/stage.ts b/packages/aws-cdk-lib/core/lib/stage.ts index f48ff158b9b54..c3f9acf4a0563 100644 --- a/packages/aws-cdk-lib/core/lib/stage.ts +++ b/packages/aws-cdk-lib/core/lib/stage.ts @@ -146,6 +146,11 @@ export class Stage extends Construct { */ private assembly?: cxapi.CloudAssembly; + /** + * The cached set of construct paths. Empty if assembly was not yet built. + */ + private constructPathsCache: Set; + /** * Validation plugins to run during synthesis. If any plugin reports any violation, * synthesis will be interrupted and the report displayed to the user. @@ -163,6 +168,7 @@ export class Stage extends Construct { Object.defineProperty(this, STAGE_SYMBOL, { value: true }); + this.constructPathsCache = new Set(); this.parentStage = Stage.of(this); this.region = props.env?.region ?? this.parentStage?.region; @@ -210,16 +216,62 @@ export class Stage extends Construct { * calls will return the same assembly. */ public synth(options: StageSynthesisOptions = { }): cxapi.CloudAssembly { - if (!this.assembly || options.force) { + + let newConstructPaths = this.listAllConstructPaths(this); + + // If the assembly cache is uninitiazed, run synthesize and reset construct paths cache + if (this.constructPathsCache.size == 0 || !this.assembly || options.force) { this.assembly = synthesize(this, { skipValidation: options.skipValidation, validateOnSynthesis: options.validateOnSynthesis, }); + newConstructPaths = this.listAllConstructPaths(this); + this.constructPathsCache = newConstructPaths; } + // If the construct paths set has changed + if (!this.constructPathSetsAreEqual(this.constructPathsCache, newConstructPaths)) { + const errorMessage = 'Synthesis has been called multiple times and the construct tree was modified after the first synthesis.'; + if (options.errorOnDuplicateSynth ?? true) { + throw new Error(errorMessage + ' This is not allowed. Remove multple synth() calls and do not modify the construct tree after the first synth().'); + } else { + // eslint-disable-next-line no-console + console.error(errorMessage + ' Only the results of the first synth() call are used, and modifications done after it are ignored. Avoid construct tree mutations after synth() has been called unless this is intentional.'); + } + } + + // Reset construct paths cache + this.constructPathsCache = newConstructPaths; + return this.assembly; } + // Function that lists all construct paths and returns them as a set + private listAllConstructPaths(construct: IConstruct): Set { + const paths = new Set(); + function recurse(root: IConstruct) { + paths.add(root.node.path); + for (const child of root.node.children) { + if (!Stage.isStage(child)) { + recurse(child); + } + } + } + recurse(construct); + return paths; + } + + // Checks if sets of construct paths are equal + private constructPathSetsAreEqual(set1: Set, set2: Set): boolean { + if (set1.size !== set2.size) return false; + for (const id of set1) { + if (!set2.has(id)) { + return false; + } + } + return true; + } + private createBuilder(outdir?: string) { // cannot specify "outdir" if we are a nested stage if (this.parentStage && outdir) { @@ -259,4 +311,11 @@ export interface StageSynthesisOptions { * @default false */ readonly force?: boolean; + + /** + * Whether or not to throw a warning instead of an error if the construct tree has + * been mutated since the last synth. + * @default true + */ + readonly errorOnDuplicateSynth?: boolean; } diff --git a/packages/aws-cdk-lib/core/test/synthesis.test.ts b/packages/aws-cdk-lib/core/test/synthesis.test.ts index 8b67b371e0be8..90760a19d05da 100644 --- a/packages/aws-cdk-lib/core/test/synthesis.test.ts +++ b/packages/aws-cdk-lib/core/test/synthesis.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import { Construct } from 'constructs'; +import { Template } from '../../assertions'; import * as cxschema from '../../cloud-assembly-schema'; import * as cxapi from '../../cx-api'; import * as cdk from '../lib'; @@ -362,6 +363,30 @@ describe('synthesis', () => { }); + test('calling synth multiple times errors if construct tree is mutated', () => { + const app = new cdk.App(); + + const stages = [ + { + stage: 'PROD', + }, + { + stage: 'BETA', + }, + ]; + + // THEN - no error the first time synth is called + let stack = new cdk.Stack(app, `${stages[0].stage}-Stack`, {}); + expect(() => { + Template.fromStack(stack); + }).not.toThrow(); + + // THEN - error is thrown since synth was called with mutated stack name + stack = new cdk.Stack(app, `${stages[1].stage}-Stack`, {}); + expect(() => { + Template.fromStack(stack); + }).toThrow('Synthesis has been called multiple times and the construct tree was modified after the first synthesis'); + }); }); function list(outdir: string) { diff --git a/packages/aws-cdk-lib/pipelines/test/compliance/synths.test.ts b/packages/aws-cdk-lib/pipelines/test/compliance/synths.test.ts index ce52f6a2df0a8..a0555f1a28800 100644 --- a/packages/aws-cdk-lib/pipelines/test/compliance/synths.test.ts +++ b/packages/aws-cdk-lib/pipelines/test/compliance/synths.test.ts @@ -186,6 +186,27 @@ test('CodeBuild: environment variables specified in multiple places are correctl }), }); + new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk-2', { + synth: new cdkp.CodeBuildStep('Synth', { + input: cdkp.CodePipelineSource.gitHub('test/test', 'main'), + primaryOutputDirectory: '.', + env: { + SOME_ENV_VAR: 'SomeValue', + }, + installCommands: [ + 'install1', + 'install2', + ], + commands: ['synth'], + buildEnvironment: { + environmentVariables: { + INNER_VAR: { value: 'InnerValue' }, + }, + privileged: true, + }, + }), + }); + // THEN Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodeBuild::Project', { Environment: Match.objectLike({ @@ -217,27 +238,6 @@ test('CodeBuild: environment variables specified in multiple places are correctl }, }); - new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk-2', { - synth: new cdkp.CodeBuildStep('Synth', { - input: cdkp.CodePipelineSource.gitHub('test/test', 'main'), - primaryOutputDirectory: '.', - env: { - SOME_ENV_VAR: 'SomeValue', - }, - installCommands: [ - 'install1', - 'install2', - ], - commands: ['synth'], - buildEnvironment: { - environmentVariables: { - INNER_VAR: { value: 'InnerValue' }, - }, - privileged: true, - }, - }), - }); - // THEN Template.fromStack(pipelineStack).hasResourceProperties('AWS::CodeBuild::Project', { Environment: Match.objectLike({