Skip to content

Commit 0ce19f0

Browse files
authored
feat(core): stack synthesizers can be shared between stacks (#23571)
Currently, `StackSynthesizer` instances must be created fresh for each `Stack`. This makes it impossible to do things like specify the stack synthesizer once at the root of the construct tree (`App`), for example. Lift that restriction: instances of default stack synthesizers can now be shared between multiple Stacks. User-written stack synthesizers can not magically be shared, but they can be made shareable by implementing `IReusableStackSynthesizer`. Immediately add the feature of specyfing a single Stack Synthesizer at the top level. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent c3a345b commit 0ce19f0

11 files changed

+195
-36
lines changed

packages/@aws-cdk/core/lib/app.ts

+17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as cxapi from '@aws-cdk/cx-api';
22
import { Construct } from 'constructs';
33
import * as fs from 'fs-extra';
4+
import { PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER } from './private/private-context';
45
import { addCustomSynthesis, ICustomSynthesis } from './private/synthesis';
6+
import { IReusableStackSynthesizer } from './stack-synthesizers';
57
import { Stage } from './stage';
68

79
const APP_SYMBOL = Symbol.for('@aws-cdk/core.App');
@@ -105,6 +107,17 @@ export interface AppProps {
105107
* @default true
106108
*/
107109
readonly treeMetadata?: boolean;
110+
111+
/**
112+
* The stack synthesizer to use by default for all Stacks in the App
113+
*
114+
* The Stack Synthesizer controls aspects of synthesis and deployment,
115+
* like how assets are referenced and what IAM roles to use. For more
116+
* information, see the README of the main CDK package.
117+
*
118+
* @default - A `DefaultStackSynthesizer` with default settings
119+
*/
120+
readonly defaultStackSynthesizer?: IReusableStackSynthesizer;
108121
}
109122

110123
/**
@@ -156,6 +169,10 @@ export class App extends Stage {
156169
this.node.setContext(cxapi.DISABLE_METADATA_STACK_TRACE, true);
157170
}
158171

172+
if (props.defaultStackSynthesizer) {
173+
this.node.setContext(PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER, props.defaultStackSynthesizer);
174+
}
175+
159176
const analyticsReporting = props.analyticsReporting ?? props.runtimeInfo;
160177

161178
if (analyticsReporting !== undefined) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Some construct-tree wide config we pass via context, because it's convenient.
3+
*
4+
* Users shouldn't touch these, and to make sure they don't we make sure the
5+
* context keys have an unguessable prefix that is different on each execution.
6+
*/
7+
8+
const PREFIX = `aws-cdk-private:${Math.random()}:`;
9+
10+
export const PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER = `${PREFIX}core/defaultStackSynthesizer`;

packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export interface BootstraplessSynthesizerProps {
3333
* However, it will not assume asset buckets or repositories have been created,
3434
* and therefore does not support assets.
3535
*
36+
* The name is poorly chosen -- it does still require bootstrapping, it just
37+
* does not support assets.
38+
*
3639
* Used by the CodePipeline construct for the support stacks needed for
3740
* cross-region replication S3 buckets. App builders do not need to use this
3841
* synthesizer directly.

packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { assertBound, StringSpecializer } from './_shared';
66
import { AssetManifestBuilder } from './asset-manifest-builder';
77
import { BOOTSTRAP_QUALIFIER_CONTEXT, DefaultStackSynthesizer } from './default-synthesizer';
88
import { StackSynthesizer } from './stack-synthesizer';
9-
import { ISynthesisSession } from './types';
9+
import { ISynthesisSession, IReusableStackSynthesizer, IBoundStackSynthesizer } from './types';
1010

1111
/**
1212
* Properties for the CliCredentialsStackSynthesizer
@@ -86,7 +86,7 @@ export interface CliCredentialsStackSynthesizerProps {
8686
* of the Bootstrap Stack V2 (also known as "modern bootstrap stack"). You can override
8787
* the default names using the synthesizer's construction properties.
8888
*/
89-
export class CliCredentialsStackSynthesizer extends StackSynthesizer {
89+
export class CliCredentialsStackSynthesizer extends StackSynthesizer implements IReusableStackSynthesizer, IBoundStackSynthesizer {
9090
private qualifier?: string;
9191
private bucketName?: string;
9292
private repositoryName?: string;
@@ -140,6 +140,18 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer {
140140
/* eslint-enable max-len */
141141
}
142142

143+
/**
144+
* Produce a bound Stack Synthesizer for the given stack.
145+
*
146+
* This method may be called more than once on the same object.
147+
*/
148+
public reusableBind(stack: Stack): IBoundStackSynthesizer {
149+
// Create a copy of the current object and bind that
150+
const copy = Object.create(this);
151+
copy.bind(stack);
152+
return copy;
153+
}
154+
143155
public addFileAsset(asset: FileAssetSource): FileAssetLocation {
144156
assertBound(this.bucketName);
145157

packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Token } from '../token';
55
import { assertBound, StringSpecializer } from './_shared';
66
import { AssetManifestBuilder } from './asset-manifest-builder';
77
import { StackSynthesizer } from './stack-synthesizer';
8-
import { ISynthesisSession } from './types';
8+
import { ISynthesisSession, IReusableStackSynthesizer, IBoundStackSynthesizer } from './types';
99

1010
export const BOOTSTRAP_QUALIFIER_CONTEXT = '@aws-cdk/core:bootstrapQualifier';
1111

@@ -227,7 +227,7 @@ export interface DefaultStackSynthesizerProps {
227227
* check to the template, to make sure the bootstrap stack is recent enough
228228
* to support all features expected by this synthesizer.
229229
*/
230-
export class DefaultStackSynthesizer extends StackSynthesizer {
230+
export class DefaultStackSynthesizer extends StackSynthesizer implements IReusableStackSynthesizer, IBoundStackSynthesizer {
231231
/**
232232
* Default ARN qualifier
233233
*/
@@ -324,6 +324,18 @@ export class DefaultStackSynthesizer extends StackSynthesizer {
324324
}
325325
}
326326

327+
/**
328+
* Produce a bound Stack Synthesizer for the given stack.
329+
*
330+
* This method may be called more than once on the same object.
331+
*/
332+
public reusableBind(stack: Stack): IBoundStackSynthesizer {
333+
// Create a copy of the current object and bind that
334+
const copy = Object.create(this);
335+
copy.bind(stack);
336+
return copy;
337+
}
338+
327339
/**
328340
* The qualifier used to bootstrap this stack
329341
*/

packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { Construct } from 'constructs';
44
import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets';
55
import { Fn } from '../cfn-fn';
66
import { FileAssetParameters } from '../private/asset-parameters';
7+
import { Stack } from '../stack';
78
import { assertBound } from './_shared';
89
import { StackSynthesizer } from './stack-synthesizer';
9-
import { ISynthesisSession } from './types';
10+
import { ISynthesisSession, IReusableStackSynthesizer, IBoundStackSynthesizer } from './types';
1011

1112
/**
1213
* The well-known name for the docker image asset ECR repository. All docker
@@ -44,7 +45,7 @@ const ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY = 'assets-ecr-repository-n
4445
* This is the only StackSynthesizer that supports customizing asset behavior
4546
* by overriding `Stack.addFileAsset()` and `Stack.addDockerImageAsset()`.
4647
*/
47-
export class LegacyStackSynthesizer extends StackSynthesizer {
48+
export class LegacyStackSynthesizer extends StackSynthesizer implements IReusableStackSynthesizer, IBoundStackSynthesizer {
4849
private cycle = false;
4950

5051
/**
@@ -109,6 +110,18 @@ export class LegacyStackSynthesizer extends StackSynthesizer {
109110
this.emitArtifact(session);
110111
}
111112

113+
/**
114+
* Produce a bound Stack Synthesizer for the given stack.
115+
*
116+
* This method may be called more than once on the same object.
117+
*/
118+
public reusableBind(stack: Stack): IBoundStackSynthesizer {
119+
// Create a copy of the current object and bind that
120+
const copy = Object.create(this);
121+
copy.bind(stack);
122+
return copy;
123+
}
124+
112125
private doAddDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation {
113126
// check if we have an override from context
114127
const repositoryNameOverride = this.boundStack.node.tryGetContext(ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY);

packages/@aws-cdk/core/lib/stack-synthesizers/types.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface IStackSynthesizer {
1616
/**
1717
* Bind to the stack this environment is going to be used on
1818
*
19-
* Must be called before any of the other methods are called.
19+
* Must be called before any of the other methods are called, and can only be called once.
2020
*/
2121
bind(stack: Stack): void;
2222

@@ -40,6 +40,33 @@ export interface IStackSynthesizer {
4040
synthesize(session: ISynthesisSession): void;
4141
}
4242

43+
/**
44+
* Interface for Stack Synthesizers that can be used for more than one stack.
45+
*
46+
* Regular `IStackSynthesizer` instances can only be bound to a Stack once.
47+
* `IReusableStackSynthesizer` instances.
48+
*
49+
* For backwards compatibility reasons, this class inherits from
50+
* `IStackSynthesizer`, but if an object implements `IReusableStackSynthesizer`,
51+
* no other methods than `reusableBind()` will be called.
52+
*/
53+
export interface IReusableStackSynthesizer extends IStackSynthesizer {
54+
/**
55+
* Produce a bound Stack Synthesizer for the given stack.
56+
*
57+
* This method may be called more than once on the same object.
58+
*/
59+
reusableBind(stack: Stack): IBoundStackSynthesizer;
60+
}
61+
62+
/**
63+
* A Stack Synthesizer, obtained from `IReusableStackSynthesizer.`
64+
*
65+
* Just a type alias with a very concrete contract.
66+
*/
67+
export interface IBoundStackSynthesizer extends IStackSynthesizer {
68+
}
69+
4370
/**
4471
* Represents a single session of synthesis. Passed into `Construct.synthesize()` methods.
4572
*/
@@ -61,3 +88,10 @@ export interface ISynthesisSession {
6188
*/
6289
validateOnSynth?: boolean;
6390
}
91+
92+
/**
93+
* Whether the given Stack Synthesizer is reusable or not
94+
*/
95+
export function isReusableStackSynthesizer(x: IStackSynthesizer): x is IReusableStackSynthesizer {
96+
return !!(x as any).reusableBind;
97+
}

packages/@aws-cdk/core/lib/stack.ts

+26-8
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,17 @@ export interface StackProps {
123123
/**
124124
* Synthesis method to use while deploying this stack
125125
*
126-
* @default - `DefaultStackSynthesizer` if the `@aws-cdk/core:newStyleStackSynthesis` feature flag
127-
* is set, `LegacyStackSynthesizer` otherwise.
126+
* The Stack Synthesizer controls aspects of synthesis and deployment,
127+
* like how assets are referenced and what IAM roles to use. For more
128+
* information, see the README of the main CDK package.
129+
*
130+
* If not specified, the `defaultStackSynthesizer` from `App` will be used.
131+
* If that is not specified, `DefaultStackSynthesizer` is used if
132+
* `@aws-cdk/core:newStyleStackSynthesis` is set to `true` or the CDK major
133+
* version is v2. In CDK v1 `LegacyStackSynthesizer` is the default if no
134+
* other synthesizer is specified.
135+
*
136+
* @default - The synthesizer specified on `App`, or `DefaultStackSynthesizer` otherwise.
128137
*/
129138
readonly synthesizer?: IStackSynthesizer;
130139

@@ -427,10 +436,18 @@ export class Stack extends Construct implements ITaggable {
427436
this._versionReportingEnabled = (props.analyticsReporting ?? this.node.tryGetContext(cxapi.ANALYTICS_REPORTING_ENABLED_CONTEXT))
428437
&& !this.nestedStackParent;
429438

430-
this.synthesizer = props.synthesizer ?? (newStyleSynthesisContext
431-
? new DefaultStackSynthesizer()
432-
: new LegacyStackSynthesizer());
433-
this.synthesizer.bind(this);
439+
const synthesizer = (props.synthesizer
440+
?? this.node.tryGetContext(PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER)
441+
?? (newStyleSynthesisContext ? new DefaultStackSynthesizer() : new LegacyStackSynthesizer()));
442+
443+
if (isReusableStackSynthesizer(synthesizer)) {
444+
// Produce a fresh instance for each stack (should have been the default behavior)
445+
this.synthesizer = synthesizer.reusableBind(this);
446+
} else {
447+
// Bind the single instance in-place to the current stack (backwards compat)
448+
this.synthesizer = synthesizer;
449+
this.synthesizer.bind(this);
450+
}
434451

435452
props.permissionsBoundary?._bind(this);
436453

@@ -1691,12 +1708,13 @@ import { FileSystem } from './fs';
16911708
import { Names } from './names';
16921709
import { Reference } from './reference';
16931710
import { IResolvable } from './resolvable';
1694-
import { DefaultStackSynthesizer, IStackSynthesizer, ISynthesisSession, LegacyStackSynthesizer, BOOTSTRAP_QUALIFIER_CONTEXT } from './stack-synthesizers';
1711+
import { DefaultStackSynthesizer, IStackSynthesizer, ISynthesisSession, LegacyStackSynthesizer, BOOTSTRAP_QUALIFIER_CONTEXT, isReusableStackSynthesizer } from './stack-synthesizers';
16951712
import { StringSpecializer } from './stack-synthesizers/_shared';
16961713
import { Stage } from './stage';
16971714
import { ITaggable, TagManager } from './tag-manager';
16981715
import { Token, Tokenization } from './token';
16991716
import { getExportable } from './private/refs';
17001717
import { Fact, RegionInfo } from '@aws-cdk/region-info';
17011718
import { deployTimeLookup } from './private/region-lookup';
1702-
import { makeUniqueResourceName } from './private/unique-resource-name';
1719+
import { makeUniqueResourceName } from './private/unique-resource-name';import { PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER } from './private/private-context';
1720+

packages/@aws-cdk/core/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126
"construct-interface-extends-iconstruct:@aws-cdk/core.ICustomResourceProvider",
127127
"props-physical-name:@aws-cdk/core.CustomResourceProps",
128128
"integ-return-type:@aws-cdk/core.IStackSynthesizer.bind",
129+
"integ-return-type:@aws-cdk/core.IBoundStackSynthesizer.bind",
130+
"integ-return-type:@aws-cdk/core.IReusableStackSynthesizer.bind",
129131
"props-no-any:@aws-cdk/core.CfnJsonProps.value"
130132
]
131133
},

packages/@aws-cdk/core/test/stack-synthesis/clicreds-synthesis.test.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -207,16 +207,15 @@ describe('CLI creds synthesis', () => {
207207
expect(imageTag).toEqual('test-prefix-docker-asset-hash');
208208
});
209209

210-
test('cannot use same synthesizer for multiple stacks', () => {
210+
test('can use same synthesizer for multiple stacks', () => {
211211
// GIVEN
212212
const synthesizer = new CliCredentialsStackSynthesizer();
213213

214214
// WHEN
215215
new Stack(app, 'Stack2', { synthesizer });
216-
expect(() => {
217-
new Stack(app, 'Stack3', { synthesizer });
218-
}).toThrow(/A StackSynthesizer can only be used for one Stack/);
216+
new Stack(app, 'Stack3', { synthesizer });
219217

218+
app.synth();
220219
});
221220
});
222221

0 commit comments

Comments
 (0)