Skip to content

Commit a36b72b

Browse files
authored
feat(core): stack synthesizer that uses CLI credentials (#18963)
Clarify documentation of stack synthesizers a bit more, it was very short. Also add `CliCredentialStackSynthesizer`. Many corporate users have requested to be able to NOT use the default bootstrap roles, because they want to rely on user credentials to do authorization. We now have the following 3 synthesizers: - `LegacyStackSynthesizer`: asset parameters, no roles. - `CliCredentialsStackSynthesizer`: conventional assets, no roles. - `DefaultStackSynthesizer`: conventional assets, conventional roles. (note: asset parameters, conventional roles does not seem like a sensible option). This will give people all the flexibility they need. Closes #16888. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent edac101 commit a36b72b

14 files changed

+883
-267
lines changed

packages/@aws-cdk/core/README.md

+39
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,45 @@ organize their deployments with. If you want to vend a reusable construct,
5757
define it as a subclasses of `Construct`: the consumers of your construct
5858
will decide where to place it in their own stacks.
5959

60+
## Stack Synthesizers
61+
62+
Each Stack has a *synthesizer*, an object that determines how and where
63+
the Stack should be synthesized and deployed. The synthesizer controls
64+
aspects like:
65+
66+
- How does the stack reference assets? (Either through CloudFormation
67+
parameters the CLI supplies, or because the Stack knows a predefined
68+
location where assets will be uploaded).
69+
- What roles are used to deploy the stack? These can be bootstrapped
70+
roles, roles created in some other way, or just the CLI's current
71+
credentials.
72+
73+
The following synthesizers are available:
74+
75+
- `DefaultStackSynthesizer`: recommended. Uses predefined asset locations and
76+
roles created by the modern bootstrap template. Access control is done by
77+
controlling who can assume the deploy role. This is the default stack
78+
synthesizer in CDKv2.
79+
- `LegacyStackSynthesizer`: Uses CloudFormation parameters to communicate
80+
asset locations, and the CLI's current permissions to deploy stacks. The
81+
is the default stack synthesizer in CDKv1.
82+
- `CliCredentialsStackSynthesizer`: Uses predefined asset locations, and the
83+
CLI's current permissions.
84+
85+
Each of these synthesizers takes configuration arguments. To configure
86+
a stack with a synthesizer, pass it as one of its properties:
87+
88+
```ts
89+
new MyStack(app, 'MyStack', {
90+
synthesizer: new DefaultStackSynthesizer({
91+
fileAssetsBucketName: 'my-orgs-asset-bucket',
92+
}),
93+
});
94+
```
95+
96+
For more information on bootstrapping accounts and customizing synthesis,
97+
see [Bootstrapping in the CDK Developer Guide](https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html).
98+
6099
## Nested Stacks
61100

62101
[Nested stacks](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html) are stacks created as part of other stacks. You create a nested stack within another stack by using the `NestedStack` construct.

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

+16
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,19 @@ export interface FileAssetLocation {
256256
/**
257257
* The HTTP URL of this asset on Amazon S3.
258258
*
259+
* This value suitable for inclusion in a CloudFormation template, and
260+
* may be an encoded token.
261+
*
259262
* Example value: `https://s3-us-east-1.amazonaws.com/mybucket/myobject`
260263
*/
261264
readonly httpUrl: string;
262265

263266
/**
264267
* The S3 URL of this asset on Amazon S3.
265268
*
269+
* This value suitable for inclusion in a CloudFormation template, and
270+
* may be an encoded token.
271+
*
266272
* Example value: `s3://mybucket/myobject`
267273
*/
268274
readonly s3ObjectUrl: string;
@@ -285,6 +291,16 @@ export interface FileAssetLocation {
285291
* key via the bucket and no additional parameters have to be granted anymore.
286292
*/
287293
readonly kmsKeyArn?: string;
294+
295+
/**
296+
* Like `s3ObjectUrl`, but not suitable for CloudFormation consumption
297+
*
298+
* If there are placeholders in the S3 URL, they will be returned unreplaced
299+
* and un-evaluated.
300+
*
301+
* @default - This feature cannot be used
302+
*/
303+
readonly s3ObjectUrlWithPlaceholders?: string;
288304
}
289305

290306
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
5+
import { FileAssetSource, FileAssetLocation, FileAssetPackaging, DockerImageAssetSource, DockerImageAssetLocation } from '../assets';
6+
import { Fn } from '../cfn-fn';
7+
import { ISynthesisSession } from '../construct-compat';
8+
import { Stack } from '../stack';
9+
import { resolvedOr } from './_shared';
10+
11+
/**
12+
* Build an manifest from assets added to a stack synthesizer
13+
*/
14+
export class AssetManifestBuilder {
15+
private readonly files: NonNullable<cxschema.AssetManifest['files']> = {};
16+
private readonly dockerImages: NonNullable<cxschema.AssetManifest['dockerImages']> = {};
17+
18+
public addFileAssetDefault(
19+
asset: FileAssetSource,
20+
stack: Stack,
21+
bucketName: string,
22+
bucketPrefix: string,
23+
role?: RoleOptions,
24+
): FileAssetLocation {
25+
validateFileAssetSource(asset);
26+
27+
const extension =
28+
asset.fileName != undefined ? path.extname(asset.fileName) : '';
29+
const objectKey =
30+
bucketPrefix +
31+
asset.sourceHash +
32+
(asset.packaging === FileAssetPackaging.ZIP_DIRECTORY
33+
? '.zip'
34+
: extension);
35+
36+
// Add to manifest
37+
this.files[asset.sourceHash] = {
38+
source: {
39+
path: asset.fileName,
40+
executable: asset.executable,
41+
packaging: asset.packaging,
42+
},
43+
destinations: {
44+
[this.manifestEnvName(stack)]: {
45+
bucketName: bucketName,
46+
objectKey,
47+
region: resolvedOr(stack.region, undefined),
48+
assumeRoleArn: role?.assumeRoleArn,
49+
assumeRoleExternalId: role?.assumeRoleExternalId,
50+
},
51+
},
52+
};
53+
54+
const { region, urlSuffix } = stackLocationOrInstrinsics(stack);
55+
const httpUrl = cfnify(
56+
`https://s3.${region}.${urlSuffix}/${bucketName}/${objectKey}`,
57+
);
58+
const s3ObjectUrlWithPlaceholders = `s3://${bucketName}/${objectKey}`;
59+
60+
// Return CFN expression
61+
//
62+
// 's3ObjectUrlWithPlaceholders' is intended for the CLI. The CLI ultimately needs a
63+
// 'https://s3.REGION.amazonaws.com[.cn]/name/hash' URL to give to CloudFormation.
64+
// However, there's no way for us to actually know the URL_SUFFIX in the framework, so
65+
// we can't construct that URL. Instead, we record the 's3://.../...' form, and the CLI
66+
// transforms it to the correct 'https://.../' URL before calling CloudFormation.
67+
return {
68+
bucketName: cfnify(bucketName),
69+
objectKey,
70+
httpUrl,
71+
s3ObjectUrl: cfnify(s3ObjectUrlWithPlaceholders),
72+
s3ObjectUrlWithPlaceholders,
73+
s3Url: httpUrl,
74+
};
75+
}
76+
77+
public addDockerImageAssetDefault(
78+
asset: DockerImageAssetSource,
79+
stack: Stack,
80+
repositoryName: string,
81+
dockerTagPrefix: string,
82+
role?: RoleOptions,
83+
): DockerImageAssetLocation {
84+
validateDockerImageAssetSource(asset);
85+
const imageTag = dockerTagPrefix + asset.sourceHash;
86+
87+
// Add to manifest
88+
this.dockerImages[asset.sourceHash] = {
89+
source: {
90+
executable: asset.executable,
91+
directory: asset.directoryName,
92+
dockerBuildArgs: asset.dockerBuildArgs,
93+
dockerBuildTarget: asset.dockerBuildTarget,
94+
dockerFile: asset.dockerFile,
95+
networkMode: asset.networkMode,
96+
},
97+
destinations: {
98+
[this.manifestEnvName(stack)]: {
99+
repositoryName: repositoryName,
100+
imageTag,
101+
region: resolvedOr(stack.region, undefined),
102+
assumeRoleArn: role?.assumeRoleArn,
103+
assumeRoleExternalId: role?.assumeRoleExternalId,
104+
},
105+
},
106+
};
107+
108+
const { account, region, urlSuffix } = stackLocationOrInstrinsics(stack);
109+
110+
// Return CFN expression
111+
return {
112+
repositoryName: cfnify(repositoryName),
113+
imageUri: cfnify(
114+
`${account}.dkr.ecr.${region}.${urlSuffix}/${repositoryName}:${imageTag}`,
115+
),
116+
};
117+
}
118+
119+
/**
120+
* Write the manifest to disk, and add it to the synthesis session
121+
*
122+
* Reutrn the artifact Id
123+
*/
124+
public writeManifest(
125+
stack: Stack,
126+
session: ISynthesisSession,
127+
additionalProps: Partial<cxschema.AssetManifestProperties> = {},
128+
): string {
129+
const artifactId = `${stack.artifactId}.assets`;
130+
const manifestFile = `${artifactId}.json`;
131+
const outPath = path.join(session.assembly.outdir, manifestFile);
132+
133+
const manifest: cxschema.AssetManifest = {
134+
version: cxschema.Manifest.version(),
135+
files: this.files,
136+
dockerImages: this.dockerImages,
137+
};
138+
139+
fs.writeFileSync(outPath, JSON.stringify(manifest, undefined, 2));
140+
141+
session.assembly.addArtifact(artifactId, {
142+
type: cxschema.ArtifactType.ASSET_MANIFEST,
143+
properties: {
144+
file: manifestFile,
145+
...additionalProps,
146+
},
147+
});
148+
149+
return artifactId;
150+
}
151+
152+
private manifestEnvName(stack: Stack): string {
153+
return [
154+
resolvedOr(stack.account, 'current_account'),
155+
resolvedOr(stack.region, 'current_region'),
156+
].join('-');
157+
}
158+
}
159+
160+
export interface RoleOptions {
161+
readonly assumeRoleArn?: string;
162+
readonly assumeRoleExternalId?: string;
163+
}
164+
165+
function validateFileAssetSource(asset: FileAssetSource) {
166+
if (!!asset.executable === !!asset.fileName) {
167+
throw new Error(`Exactly one of 'fileName' or 'executable' is required, got: ${JSON.stringify(asset)}`);
168+
}
169+
170+
if (!!asset.packaging !== !!asset.fileName) {
171+
throw new Error(`'packaging' is expected in combination with 'fileName', got: ${JSON.stringify(asset)}`);
172+
}
173+
}
174+
175+
function validateDockerImageAssetSource(asset: DockerImageAssetSource) {
176+
if (!!asset.executable === !!asset.directoryName) {
177+
throw new Error(`Exactly one of 'directoryName' or 'executable' is required, got: ${JSON.stringify(asset)}`);
178+
}
179+
180+
check('dockerBuildArgs');
181+
check('dockerBuildTarget');
182+
check('dockerFile');
183+
184+
function check<K extends keyof DockerImageAssetSource>(key: K) {
185+
if (asset[key] && !asset.directoryName) {
186+
throw new Error(`'${key}' is only allowed in combination with 'directoryName', got: ${JSON.stringify(asset)}`);
187+
}
188+
}
189+
}
190+
191+
/**
192+
* Return the stack locations if they're concrete, or the original CFN intrisics otherwise
193+
*
194+
* We need to return these instead of the tokenized versions of the strings,
195+
* since we must accept those same ${AWS::AccountId}/${AWS::Region} placeholders
196+
* in bucket names and role names (in order to allow environment-agnostic stacks).
197+
*
198+
* We'll wrap a single {Fn::Sub} around the final string in order to replace everything,
199+
* but we can't have the token system render part of the string to {Fn::Join} because
200+
* the CFN specification doesn't allow the {Fn::Sub} template string to be an arbitrary
201+
* expression--it must be a string literal.
202+
*/
203+
function stackLocationOrInstrinsics(stack: Stack) {
204+
return {
205+
account: resolvedOr(stack.account, '${AWS::AccountId}'),
206+
region: resolvedOr(stack.region, '${AWS::Region}'),
207+
urlSuffix: resolvedOr(stack.urlSuffix, '${AWS::URLSuffix}'),
208+
};
209+
}
210+
211+
/**
212+
* If the string still contains placeholders, wrap it in a Fn::Sub so they will be substituted at CFN deployment time
213+
*
214+
* (This happens to work because the placeholders we picked map directly onto CFN
215+
* placeholders. If they didn't we'd have to do a transformation here).
216+
*/
217+
function cfnify(s: string): string {
218+
return s.indexOf('${') > -1 ? Fn.sub(s) : s;
219+
}

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

+61
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import * as crypto from 'crypto';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
24
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
5+
import * as cxapi from '@aws-cdk/cx-api';
6+
import { FileAssetSource, FileAssetPackaging } from '../assets';
37
import { ConstructNode, IConstruct, ISynthesisSession } from '../construct-compat';
48
import { Stack } from '../stack';
9+
import { Token } from '../token';
510

611
/**
712
* Shared logic of writing stack artifact to the Cloud Assembly
@@ -122,4 +127,60 @@ export function assertBound<A>(x: A | undefined): asserts x is NonNullable<A> {
122127

123128
function nonEmptyDict<A>(xs: Record<string, A>) {
124129
return Object.keys(xs).length > 0 ? xs : undefined;
130+
}
131+
132+
/**
133+
* A "replace-all" function that doesn't require us escaping a literal string to a regex
134+
*/
135+
function replaceAll(s: string, search: string, replace: string) {
136+
return s.split(search).join(replace);
137+
}
138+
139+
export class StringSpecializer {
140+
constructor(private readonly stack: Stack, private readonly qualifier: string) {
141+
}
142+
143+
/**
144+
* Function to replace placeholders in the input string as much as possible
145+
*
146+
* We replace:
147+
* - ${Qualifier}: always
148+
* - ${AWS::AccountId}, ${AWS::Region}: only if we have the actual values available
149+
* - ${AWS::Partition}: never, since we never have the actual partition value.
150+
*/
151+
public specialize(s: string): string {
152+
s = replaceAll(s, '${Qualifier}', this.qualifier);
153+
return cxapi.EnvironmentPlaceholders.replace(s, {
154+
region: resolvedOr(this.stack.region, cxapi.EnvironmentPlaceholders.CURRENT_REGION),
155+
accountId: resolvedOr(this.stack.account, cxapi.EnvironmentPlaceholders.CURRENT_ACCOUNT),
156+
partition: cxapi.EnvironmentPlaceholders.CURRENT_PARTITION,
157+
});
158+
}
159+
160+
/**
161+
* Specialize only the qualifier
162+
*/
163+
public qualifierOnly(s: string): string {
164+
return replaceAll(s, '${Qualifier}', this.qualifier);
165+
}
166+
}
167+
168+
/**
169+
* Return the given value if resolved or fall back to a default
170+
*/
171+
export function resolvedOr<A>(x: string, def: A): string | A {
172+
return Token.isUnresolved(x) ? def : x;
173+
}
174+
175+
export function stackTemplateFileAsset(stack: Stack, session: ISynthesisSession): FileAssetSource {
176+
const templatePath = path.join(session.assembly.outdir, stack.templateFile);
177+
const template = fs.readFileSync(templatePath, { encoding: 'utf-8' });
178+
179+
const sourceHash = contentHash(template);
180+
181+
return {
182+
fileName: stack.templateFile,
183+
packaging: FileAssetPackaging.FILE,
184+
sourceHash,
185+
};
125186
}

0 commit comments

Comments
 (0)