From 8b2b38187b709a4e9a37a4de043a84267a9ec937 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 3 Oct 2022 11:27:14 +0200 Subject: [PATCH] feat(core): make `StackSynthesizer` easier to subclass (#22308) `StackSynthesizer`s used to make heavy use of private APIs, which make it impossible for people outside the CDK core framework to implement their own Stack Synthesizers and modify the way assets are referenced. This question comes up more and more, so this PR opens up the facilities for doing that. The changes are as follows: - Implement `bind()` in `StackSynthesizer`, and add a getter called `boundStack` which will return the bound stack. - Change `synthesizeStackTemplate -> synthesizeTemplate`, `emitStackArtifact -> emitArtifact`, both of which will implicitly operate on the bound stack. - Add `addBootstrapVersionRule` so that any subclass can choose to use this or not. - Expose `AssetManifestBuilder`, which stack synthesizers can use to build an asset manifest. Refactor the class to do less at the same time. - Expose `cloudFormationLocationFrom...Asset` functions to translate a published asset location into a CloudFormation location. - Document the contract of each method better. The end result is that it will be easier to mix and match the different behaviors of the existing synthesizers together into new custom behavior. Two new unit test files have been added to show how it is possible to write ONE intruction to the asset manifest (to upload assets to a given location) while returning a completely different instruction to the template (referencing the asset bucket using a CloudFormation parameter). ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../test/custom-synthesis.test.ts | 94 +++++++ .../test/custom-synthesis.test.ts | 99 +++++++ .../lib/private/product-stack-synthesizer.ts | 14 +- .../lib/cloud-assembly/artifact-schema.ts | 19 +- .../_asset-manifest-builder.ts | 221 --------------- .../core/lib/stack-synthesizers/_shared.ts | 16 -- .../asset-manifest-builder.ts | 251 ++++++++++++++++++ .../bootstrapless-synthesizer.ts | 7 +- .../cli-credentials-synthesizer.ts | 38 ++- .../stack-synthesizers/default-synthesizer.ts | 111 +++----- .../core/lib/stack-synthesizers/index.ts | 1 + .../core/lib/stack-synthesizers/legacy.ts | 41 +-- .../core/lib/stack-synthesizers/nested.ts | 14 +- .../stack-synthesizers/stack-synthesizer.ts | 226 +++++++++++++++- .../new-style-synthesis.test.ts | 7 +- packages/@aws-cdk/core/test/synthesis.test.ts | 4 +- .../lib/artifacts/asset-manifest-artifact.ts | 16 ++ 17 files changed, 771 insertions(+), 408 deletions(-) create mode 100644 packages/@aws-cdk/aws-ecr-assets/test/custom-synthesis.test.ts create mode 100644 packages/@aws-cdk/aws-s3-assets/test/custom-synthesis.test.ts delete mode 100644 packages/@aws-cdk/core/lib/stack-synthesizers/_asset-manifest-builder.ts create mode 100644 packages/@aws-cdk/core/lib/stack-synthesizers/asset-manifest-builder.ts diff --git a/packages/@aws-cdk/aws-ecr-assets/test/custom-synthesis.test.ts b/packages/@aws-cdk/aws-ecr-assets/test/custom-synthesis.test.ts new file mode 100644 index 0000000000000..40c886682dc0d --- /dev/null +++ b/packages/@aws-cdk/aws-ecr-assets/test/custom-synthesis.test.ts @@ -0,0 +1,94 @@ +/** + * This file asserts that it is possible to write a custom stacksynthesizer that will synthesize + * ONE thing to the asset manifest, while returning another thing (including tokens) to the + * CloudFormation template -- without reaching into the library internals + */ + +import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; +import { StackSynthesizer, FileAssetSource, FileAssetLocation, DockerImageAssetSource, DockerImageAssetLocation, ISynthesisSession, App, Stack, AssetManifestBuilder, CfnParameter, CfnResource } from '@aws-cdk/core'; +import { AssetManifestArtifact } from '@aws-cdk/cx-api'; +import { DockerImageAsset } from '../lib'; + +test('use custom synthesizer', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'Stack', { + synthesizer: new CustomSynthesizer(), + }); + + // WHEN + const asset = new DockerImageAsset(stack, 'MyAsset', { + directory: path.join(__dirname, 'demo-image'), + }); + new CfnResource(stack, 'TestResource', { + type: 'CDK::TestResource', + properties: { + ImageUri: asset.imageUri, + ImageTag: asset.imageTag, + }, + }); + + // THEN + const assembly = app.synth(); + const stackArtifact = assembly.getStackArtifact(stack.artifactId); + const assetArtifact = stackArtifact.dependencies[0] as AssetManifestArtifact; + + const stackTemplate = Template.fromJSON(stackArtifact.template); + stackTemplate.hasResourceProperties('CDK::TestResource', { + ImageUri: { 'Fn::Sub': '${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/${RepositoryName}:0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14' }, + ImageTag: '0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14', + }); + + expect(assetArtifact.contents).toEqual(expect.objectContaining({ + dockerImages: expect.objectContaining({ + '0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14': { + destinations: { + 'current_account-current_region': { + repositoryName: 'write-repo', + imageTag: '0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14', + }, + }, + source: { + directory: 'asset.0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14', + }, + }, + }), + })); +}); + +class CustomSynthesizer extends StackSynthesizer { + private readonly manifest = new AssetManifestBuilder(); + private parameter?: CfnParameter; + + bind(stack: Stack) { + super.bind(stack); + + this.parameter = new CfnParameter(stack, 'RepositoryName'); + } + + addFileAsset(asset: FileAssetSource): FileAssetLocation { + void(asset); + throw new Error('file assets not supported here'); + } + + addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { + const dest = this.manifest.defaultAddDockerImageAsset(this.boundStack, asset, { + repositoryName: 'write-repo', + }); + return this.cloudFormationLocationFromDockerImageAsset({ + ...dest, + repositoryName: ['${', this.parameter!.logicalId, '}'].join(''), + }); + } + + synthesize(session: ISynthesisSession): void { + // NOTE: Explicitly not adding template to asset manifest + this.synthesizeTemplate(session); + const assetManifestId = this.manifest.emitManifest(this.boundStack, session); + + this.emitArtifact(session, { + additionalDependencies: [assetManifestId], + }); + } +} diff --git a/packages/@aws-cdk/aws-s3-assets/test/custom-synthesis.test.ts b/packages/@aws-cdk/aws-s3-assets/test/custom-synthesis.test.ts new file mode 100644 index 0000000000000..525ccdb01f46c --- /dev/null +++ b/packages/@aws-cdk/aws-s3-assets/test/custom-synthesis.test.ts @@ -0,0 +1,99 @@ +/** + * This file asserts that it is possible to write a custom stacksynthesizer that will synthesize + * ONE thing to the asset manifest, while returning another thing (including tokens) to the + * CloudFormation template -- without reaching into the library internals + */ + +import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; +import { StackSynthesizer, FileAssetSource, FileAssetLocation, DockerImageAssetSource, DockerImageAssetLocation, ISynthesisSession, App, Stack, AssetManifestBuilder, CfnParameter, CfnResource } from '@aws-cdk/core'; +import { AssetManifestArtifact } from '@aws-cdk/cx-api'; +import { Asset } from '../lib'; + +test('use custom synthesizer', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'Stack', { + synthesizer: new CustomSynthesizer(), + }); + + // WHEN + const asset = new Asset(stack, 'MyAsset', { + path: path.join(__dirname, 'file-asset.txt'), + }); + new CfnResource(stack, 'TestResource', { + type: 'CDK::TestResource', + properties: { + Bucket: asset.s3BucketName, + ObjectKey: asset.s3ObjectKey, + S3Url: asset.s3ObjectUrl, + HttpUrl: asset.httpUrl, + }, + }); + + // THEN + const assembly = app.synth(); + const stackArtifact = assembly.getStackArtifact(stack.artifactId); + const assetArtifact = stackArtifact.dependencies[0] as AssetManifestArtifact; + + const stackTemplate = Template.fromJSON(stackArtifact.template); + stackTemplate.hasResourceProperties('CDK::TestResource', { + Bucket: { 'Fn::Sub': '${BucketName}' }, + ObjectKey: '78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt', + S3Url: { 'Fn::Sub': 's3://${BucketName}/78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt' }, + HttpUrl: { 'Fn::Sub': 'https://s3.${AWS::Region}.${AWS::URLSuffix}/${BucketName}/78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt' }, + }); + + expect(assetArtifact.contents).toEqual(expect.objectContaining({ + files: expect.objectContaining({ + '78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197': { + destinations: { + 'current_account-current_region': { + bucketName: 'write-bucket', + objectKey: '78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt', + }, + }, + source: { + packaging: 'file', + path: 'asset.78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt', + }, + }, + }), + })); +}); + +class CustomSynthesizer extends StackSynthesizer { + private readonly manifest = new AssetManifestBuilder(); + private parameter?: CfnParameter; + + bind(stack: Stack) { + super.bind(stack); + + this.parameter = new CfnParameter(stack, 'BucketName'); + } + + addFileAsset(asset: FileAssetSource): FileAssetLocation { + const dest = this.manifest.defaultAddFileAsset(this.boundStack, asset, { + bucketName: 'write-bucket', + }); + return this.cloudFormationLocationFromFileAsset({ + ...dest, + bucketName: ['${', this.parameter!.logicalId, '}'].join(''), + }); + } + + addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { + void(asset); + throw new Error('Docker images are not supported here'); + } + + synthesize(session: ISynthesisSession): void { + const templateAsset = this.addFileAsset(this.synthesizeTemplate(session)); + const assetManifestId = this.manifest.emitManifest(this.boundStack, session); + + this.emitArtifact(session, { + stackTemplateAssetObjectUrl: templateAsset.s3ObjectUrlWithPlaceholders, + additionalDependencies: [assetManifestId], + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts index 4840a7e756fe5..73f6e740d1790 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts @@ -6,15 +6,6 @@ import * as cdk from '@aws-cdk/core'; * Interoperates with the StackSynthesizer of the parent stack. */ export class ProductStackSynthesizer extends cdk.StackSynthesizer { - private stack?: cdk.Stack; - - public bind(stack: cdk.Stack): void { - if (this.stack !== undefined) { - throw new Error('A Stack Synthesizer can only be bound once, create a new instance to use with a different Stack'); - } - this.stack = stack; - } - public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation { throw new Error('Service Catalog Product Stacks cannot use Assets'); } @@ -24,11 +15,8 @@ export class ProductStackSynthesizer extends cdk.StackSynthesizer { } public synthesize(session: cdk.ISynthesisSession): void { - if (!this.stack) { - throw new Error('You must call bindStack() first'); - } // Synthesize the template, but don't emit as a cloud assembly artifact. // It will be registered as an S3 asset of its parent instead. - this.synthesizeStackTemplate(this.stack, session); + this.synthesizeTemplate(session); } } diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts index 4d98b3a29bb32..66872401251aa 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts @@ -134,14 +134,9 @@ export interface AwsCloudFormationStackProperties { } /** - * Artifact properties for the Asset Manifest + * Configuration options for the Asset Manifest */ -export interface AssetManifestProperties { - /** - * Filename of the asset manifest - */ - readonly file: string; - +export interface AssetManifestOptions { /** * Version of bootstrap stack required to deploy this stack * @@ -163,6 +158,16 @@ export interface AssetManifestProperties { readonly bootstrapStackVersionSsmParameter?: string; } +/** + * Artifact properties for the Asset Manifest + */ +export interface AssetManifestProperties extends AssetManifestOptions { + /** + * Filename of the asset manifest + */ + readonly file: string; +} + /** * Artifact properties for the Construct Tree Artifact */ diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/_asset-manifest-builder.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/_asset-manifest-builder.ts deleted file mode 100644 index 74f96800e755a..0000000000000 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/_asset-manifest-builder.ts +++ /dev/null @@ -1,221 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { FileAssetSource, FileAssetLocation, FileAssetPackaging, DockerImageAssetSource, DockerImageAssetLocation } from '../assets'; -import { Fn } from '../cfn-fn'; -import { Stack } from '../stack'; -import { resolvedOr } from './_shared'; -import { ISynthesisSession } from './types'; - -/** - * Build an manifest from assets added to a stack synthesizer - */ -export class AssetManifestBuilder { - private readonly files: NonNullable = {}; - private readonly dockerImages: NonNullable = {}; - - public addFileAssetDefault( - asset: FileAssetSource, - stack: Stack, - bucketName: string, - bucketPrefix: string, - role?: RoleOptions, - ): FileAssetLocation { - validateFileAssetSource(asset); - - const extension = - asset.fileName != undefined ? path.extname(asset.fileName) : ''; - const objectKey = - bucketPrefix + - asset.sourceHash + - (asset.packaging === FileAssetPackaging.ZIP_DIRECTORY - ? '.zip' - : extension); - - // Add to manifest - this.files[asset.sourceHash] = { - source: { - path: asset.fileName, - executable: asset.executable, - packaging: asset.packaging, - }, - destinations: { - [this.manifestEnvName(stack)]: { - bucketName: bucketName, - objectKey, - region: resolvedOr(stack.region, undefined), - assumeRoleArn: role?.assumeRoleArn, - assumeRoleExternalId: role?.assumeRoleExternalId, - }, - }, - }; - - const { region, urlSuffix } = stackLocationOrInstrinsics(stack); - const httpUrl = cfnify( - `https://s3.${region}.${urlSuffix}/${bucketName}/${objectKey}`, - ); - const s3ObjectUrlWithPlaceholders = `s3://${bucketName}/${objectKey}`; - - // Return CFN expression - // - // 's3ObjectUrlWithPlaceholders' is intended for the CLI. The CLI ultimately needs a - // 'https://s3.REGION.amazonaws.com[.cn]/name/hash' URL to give to CloudFormation. - // However, there's no way for us to actually know the URL_SUFFIX in the framework, so - // we can't construct that URL. Instead, we record the 's3://.../...' form, and the CLI - // transforms it to the correct 'https://.../' URL before calling CloudFormation. - return { - bucketName: cfnify(bucketName), - objectKey, - httpUrl, - s3ObjectUrl: cfnify(s3ObjectUrlWithPlaceholders), - s3ObjectUrlWithPlaceholders, - s3Url: httpUrl, - }; - } - - public addDockerImageAssetDefault( - asset: DockerImageAssetSource, - stack: Stack, - repositoryName: string, - dockerTagPrefix: string, - role?: RoleOptions, - ): DockerImageAssetLocation { - validateDockerImageAssetSource(asset); - const imageTag = `${dockerTagPrefix}${asset.sourceHash}`; - - // Add to manifest - this.dockerImages[asset.sourceHash] = { - source: { - executable: asset.executable, - directory: asset.directoryName, - dockerBuildArgs: asset.dockerBuildArgs, - dockerBuildTarget: asset.dockerBuildTarget, - dockerFile: asset.dockerFile, - networkMode: asset.networkMode, - platform: asset.platform, - }, - destinations: { - [this.manifestEnvName(stack)]: { - repositoryName: repositoryName, - imageTag, - region: resolvedOr(stack.region, undefined), - assumeRoleArn: role?.assumeRoleArn, - assumeRoleExternalId: role?.assumeRoleExternalId, - }, - }, - }; - - const { account, region, urlSuffix } = stackLocationOrInstrinsics(stack); - - // Return CFN expression - return { - repositoryName: cfnify(repositoryName), - imageUri: cfnify( - `${account}.dkr.ecr.${region}.${urlSuffix}/${repositoryName}:${imageTag}`, - ), - imageTag: cfnify(imageTag), - }; - } - - /** - * Write the manifest to disk, and add it to the synthesis session - * - * Reutrn the artifact Id - */ - public writeManifest( - stack: Stack, - session: ISynthesisSession, - additionalProps: Partial = {}, - ): string { - const artifactId = `${stack.artifactId}.assets`; - const manifestFile = `${artifactId}.json`; - const outPath = path.join(session.assembly.outdir, manifestFile); - - const manifest: cxschema.AssetManifest = { - version: cxschema.Manifest.version(), - files: this.files, - dockerImages: this.dockerImages, - }; - - fs.writeFileSync(outPath, JSON.stringify(manifest, undefined, 2)); - - session.assembly.addArtifact(artifactId, { - type: cxschema.ArtifactType.ASSET_MANIFEST, - properties: { - file: manifestFile, - ...additionalProps, - }, - }); - - return artifactId; - } - - private manifestEnvName(stack: Stack): string { - return [ - resolvedOr(stack.account, 'current_account'), - resolvedOr(stack.region, 'current_region'), - ].join('-'); - } -} - -export interface RoleOptions { - readonly assumeRoleArn?: string; - readonly assumeRoleExternalId?: string; -} - -function validateFileAssetSource(asset: FileAssetSource) { - if (!!asset.executable === !!asset.fileName) { - throw new Error(`Exactly one of 'fileName' or 'executable' is required, got: ${JSON.stringify(asset)}`); - } - - if (!!asset.packaging !== !!asset.fileName) { - throw new Error(`'packaging' is expected in combination with 'fileName', got: ${JSON.stringify(asset)}`); - } -} - -function validateDockerImageAssetSource(asset: DockerImageAssetSource) { - if (!!asset.executable === !!asset.directoryName) { - throw new Error(`Exactly one of 'directoryName' or 'executable' is required, got: ${JSON.stringify(asset)}`); - } - - check('dockerBuildArgs'); - check('dockerBuildTarget'); - check('dockerFile'); - - function check(key: K) { - if (asset[key] && !asset.directoryName) { - throw new Error(`'${key}' is only allowed in combination with 'directoryName', got: ${JSON.stringify(asset)}`); - } - } -} - -/** - * Return the stack locations if they're concrete, or the original CFN intrisics otherwise - * - * We need to return these instead of the tokenized versions of the strings, - * since we must accept those same ${AWS::AccountId}/${AWS::Region} placeholders - * in bucket names and role names (in order to allow environment-agnostic stacks). - * - * We'll wrap a single {Fn::Sub} around the final string in order to replace everything, - * but we can't have the token system render part of the string to {Fn::Join} because - * the CFN specification doesn't allow the {Fn::Sub} template string to be an arbitrary - * expression--it must be a string literal. - */ -function stackLocationOrInstrinsics(stack: Stack) { - return { - account: resolvedOr(stack.account, '${AWS::AccountId}'), - region: resolvedOr(stack.region, '${AWS::Region}'), - urlSuffix: resolvedOr(stack.urlSuffix, '${AWS::URLSuffix}'), - }; -} - -/** - * If the string still contains placeholders, wrap it in a Fn::Sub so they will be substituted at CFN deployment time - * - * (This happens to work because the placeholders we picked map directly onto CFN - * placeholders. If they didn't we'd have to do a transformation here). - */ -function cfnify(s: string): string { - return s.indexOf('${') > -1 ? Fn.sub(s) : s; -} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts index cae4195035c25..c4579949debe5 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts @@ -1,10 +1,7 @@ import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import { Node, IConstruct } from 'constructs'; -import { FileAssetSource, FileAssetPackaging } from '../assets'; import { Stack } from '../stack'; import { Token } from '../token'; import { ISynthesisSession } from './types'; @@ -172,16 +169,3 @@ export class StringSpecializer { export function resolvedOr(x: string, def: A): string | A { return Token.isUnresolved(x) ? def : x; } - -export function stackTemplateFileAsset(stack: Stack, session: ISynthesisSession): FileAssetSource { - const templatePath = path.join(session.assembly.outdir, stack.templateFile); - const template = fs.readFileSync(templatePath, { encoding: 'utf-8' }); - - const sourceHash = contentHash(template); - - return { - fileName: stack.templateFile, - packaging: FileAssetPackaging.FILE, - sourceHash, - }; -} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/asset-manifest-builder.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/asset-manifest-builder.ts new file mode 100644 index 0000000000000..73e23c330c9b1 --- /dev/null +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/asset-manifest-builder.ts @@ -0,0 +1,251 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import { FileAssetSource, FileAssetPackaging, DockerImageAssetSource } from '../assets'; +import { Stack } from '../stack'; +import { resolvedOr } from './_shared'; +import { ISynthesisSession } from './types'; + +/** + * Build an asset manifest from assets added to a stack + * + * This class does not need to be used by app builders; it is only nessary for building Stack Synthesizers. + */ +export class AssetManifestBuilder { + private readonly files: NonNullable = {}; + private readonly dockerImages: NonNullable = {}; + + /** + * Add a file asset to the manifest with default settings + * + * Derive the region from the stack, use the asset hash as the key, copy the + * file extension over, and set the prefix. + */ + public defaultAddFileAsset(stack: Stack, asset: FileAssetSource, target: AssetManifestFileDestination) { + validateFileAssetSource(asset); + + const extension = + asset.fileName != undefined ? path.extname(asset.fileName) : ''; + const objectKey = + (target.bucketPrefix ?? '') + + asset.sourceHash + + (asset.packaging === FileAssetPackaging.ZIP_DIRECTORY + ? '.zip' + : extension); + + // Add to manifest + return this.addFileAsset(stack, asset.sourceHash, { + path: asset.fileName, + executable: asset.executable, + packaging: asset.packaging, + }, { + bucketName: target.bucketName, + objectKey, + region: resolvedOr(stack.region, undefined), + assumeRoleArn: target.role?.assumeRoleArn, + assumeRoleExternalId: target.role?.assumeRoleExternalId, + }); + } + + /** + * Add a docker image asset to the manifest with default settings + * + * Derive the region from the stack, use the asset hash as the key, and set the prefix. + */ + public defaultAddDockerImageAsset( + stack: Stack, + asset: DockerImageAssetSource, + target: AssetManifestDockerImageDestination, + ) { + validateDockerImageAssetSource(asset); + const imageTag = `${target.dockerTagPrefix ?? ''}${asset.sourceHash}`; + + // Add to manifest + return this.addDockerImageAsset(stack, asset.sourceHash, { + executable: asset.executable, + directory: asset.directoryName, + dockerBuildArgs: asset.dockerBuildArgs, + dockerBuildTarget: asset.dockerBuildTarget, + dockerFile: asset.dockerFile, + networkMode: asset.networkMode, + platform: asset.platform, + }, { + repositoryName: target.repositoryName, + imageTag, + region: resolvedOr(stack.region, undefined), + assumeRoleArn: target.role?.assumeRoleArn, + assumeRoleExternalId: target.role?.assumeRoleExternalId, + }); + } + + /** + * Add a file asset source and destination to the manifest + * + * sourceHash should be unique for every source. + */ + public addFileAsset(stack: Stack, sourceHash: string, source: cxschema.FileSource, dest: cxschema.FileDestination) { + if (!this.files[sourceHash]) { + this.files[sourceHash] = { + source, + destinations: {}, + }; + } + this.files[sourceHash].destinations[this.manifestEnvName(stack)] = dest; + return dest; + } + + /** + * Add a docker asset source and destination to the manifest + * + * sourceHash should be unique for every source. + */ + public addDockerImageAsset(stack: Stack, sourceHash: string, source: cxschema.DockerImageSource, dest: cxschema.DockerImageDestination) { + if (!this.dockerImages[sourceHash]) { + this.dockerImages[sourceHash] = { + source, + destinations: {}, + }; + } + this.dockerImages[sourceHash].destinations[this.manifestEnvName(stack)] = dest; + return dest; + } + + /** + * Whether there are any assets registered in the manifest + */ + public get hasAssets() { + return Object.keys(this.files).length + Object.keys(this.dockerImages).length > 0; + } + + /** + * Write the manifest to disk, and add it to the synthesis session + * + * Return the artifact id, which should be added to the `additionalDependencies` + * field of the stack artifact. + */ + public emitManifest( + stack: Stack, + session: ISynthesisSession, + options: cxschema.AssetManifestOptions = {}, + ): string { + const artifactId = `${stack.artifactId}.assets`; + const manifestFile = `${artifactId}.json`; + const outPath = path.join(session.assembly.outdir, manifestFile); + + const manifest: cxschema.AssetManifest = { + version: cxschema.Manifest.version(), + files: this.files, + dockerImages: this.dockerImages, + }; + + fs.writeFileSync(outPath, JSON.stringify(manifest, undefined, 2)); + + session.assembly.addArtifact(artifactId, { + type: cxschema.ArtifactType.ASSET_MANIFEST, + properties: { + file: manifestFile, + ...options, + }, + }); + + return artifactId; + } + + private manifestEnvName(stack: Stack): string { + return [ + resolvedOr(stack.account, 'current_account'), + resolvedOr(stack.region, 'current_region'), + ].join('-'); + } +} + +/** + * The destination for a file asset, when it is given to the AssetManifestBuilder + */ +export interface AssetManifestFileDestination { + /** + * Bucket name where the file asset should be written + */ + readonly bucketName: string; + + /** + * Prefix to prepend to the asset hash + * + * @default '' + */ + readonly bucketPrefix?: string; + + /** + * Role to use for uploading + * + * @default - current role + */ + readonly role?: RoleOptions; +} + +/** + * The destination for a docker image asset, when it is given to the AssetManifestBuilder + */ +export interface AssetManifestDockerImageDestination { + /** + * Repository name where the docker image asset should be written + */ + readonly repositoryName: string; + + /** + * Prefix to add to the asset hash to make the Docker image tag + * + * @default '' + */ + readonly dockerTagPrefix?: string; + + /** + * Role to use to perform the upload + * + * @default - No role + */ + readonly role?: RoleOptions; +} + +/** + * Options for specifying a role + */ +export interface RoleOptions { + /** + * ARN of the role to assume + */ + readonly assumeRoleArn: string; + + /** + * External ID to use when assuming the role + * + * @default - No external ID + */ + readonly assumeRoleExternalId?: string; +} + +function validateFileAssetSource(asset: FileAssetSource) { + if (!!asset.executable === !!asset.fileName) { + throw new Error(`Exactly one of 'fileName' or 'executable' is required, got: ${JSON.stringify(asset)}`); + } + + if (!!asset.packaging !== !!asset.fileName) { + throw new Error(`'packaging' is expected in combination with 'fileName', got: ${JSON.stringify(asset)}`); + } +} + +function validateDockerImageAssetSource(asset: DockerImageAssetSource) { + if (!!asset.executable === !!asset.directoryName) { + throw new Error(`Exactly one of 'directoryName' or 'executable' is required, got: ${JSON.stringify(asset)}`); + } + + check('dockerBuildArgs'); + check('dockerBuildTarget'); + check('dockerFile'); + + function check(key: K) { + if (asset[key] && !asset.directoryName) { + throw new Error(`'${key}' is only allowed in combination with 'directoryName', got: ${JSON.stringify(asset)}`); + } + } +} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts index f98c9925cdff0..1ecc74efd6126 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts @@ -1,5 +1,4 @@ import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; -import { assertBound } from './_shared'; import { DefaultStackSynthesizer } from './default-synthesizer'; import { ISynthesisSession } from './types'; @@ -56,13 +55,11 @@ export class BootstraplessSynthesizer extends DefaultStackSynthesizer { } public synthesize(session: ISynthesisSession): void { - assertBound(this.stack); - - this.synthesizeStackTemplate(this.stack, session); + this.synthesizeStackTemplate(this.boundStack, session); // do _not_ treat the template as an asset, // because this synthesizer doesn't have a bootstrap bucket to put it in - this.emitStackArtifact(this.stack, session, { + this.emitArtifact(session, { assumeRoleArn: this.deployRoleArn, cloudFormationExecutionRoleArn: this.cloudFormationExecutionRoleArn, }); diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts index 20ba571e45f77..095098ec0022d 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts @@ -2,8 +2,8 @@ import * as cxapi from '@aws-cdk/cx-api'; import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; import { Stack } from '../stack'; import { Token } from '../token'; -import { AssetManifestBuilder } from './_asset-manifest-builder'; -import { assertBound, StringSpecializer, stackTemplateFileAsset } from './_shared'; +import { assertBound, StringSpecializer } from './_shared'; +import { AssetManifestBuilder } from './asset-manifest-builder'; import { BOOTSTRAP_QUALIFIER_CONTEXT, DefaultStackSynthesizer } from './default-synthesizer'; import { StackSynthesizer } from './stack-synthesizer'; import { ISynthesisSession } from './types'; @@ -87,7 +87,6 @@ export interface CliCredentialsStackSynthesizerProps { * the default names using the synthesizer's construction properties. */ export class CliCredentialsStackSynthesizer extends StackSynthesizer { - private stack?: Stack; private qualifier?: string; private bucketName?: string; private repositoryName?: string; @@ -108,7 +107,7 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer { function validateNoToken(key: A) { const prop = props[key]; if (typeof prop === 'string' && Token.isUnresolved(prop)) { - throw new Error(`DefaultSynthesizer property '${key}' cannot contain tokens; only the following placeholder strings are allowed: ` + [ + throw new Error(`CliCredentialsStackSynthesizer property '${key}' cannot contain tokens; only the following placeholder strings are allowed: ` + [ '${Qualifier}', cxapi.EnvironmentPlaceholders.CURRENT_REGION, cxapi.EnvironmentPlaceholders.CURRENT_ACCOUNT, @@ -119,11 +118,7 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer { } public bind(stack: Stack): void { - if (this.stack !== undefined) { - throw new Error('A StackSynthesizer can only be used for one Stack: create a new instance to use with a different Stack'); - } - - this.stack = stack; + super.bind(stack); const qualifier = this.props.qualifier ?? stack.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; this.qualifier = qualifier; @@ -139,34 +134,37 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer { } public addFileAsset(asset: FileAssetSource): FileAssetLocation { - assertBound(this.stack); assertBound(this.bucketName); - assertBound(this.bucketPrefix); - return this.assetManifest.addFileAssetDefault(asset, this.stack, this.bucketName, this.bucketPrefix); + const location = this.assetManifest.defaultAddFileAsset(this.boundStack, asset, { + bucketName: this.bucketName, + bucketPrefix: this.bucketPrefix, + }); + return this.cloudFormationLocationFromFileAsset(location); } public addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { - assertBound(this.stack); assertBound(this.repositoryName); - assertBound(this.dockerTagPrefix); - return this.assetManifest.addDockerImageAssetDefault(asset, this.stack, this.repositoryName, this.dockerTagPrefix); + const location = this.assetManifest.defaultAddDockerImageAsset(this.boundStack, asset, { + repositoryName: this.repositoryName, + dockerTagPrefix: this.dockerTagPrefix, + }); + return this.cloudFormationLocationFromDockerImageAsset(location); } /** * Synthesize the associated stack to the session */ public synthesize(session: ISynthesisSession): void { - assertBound(this.stack); assertBound(this.qualifier); - this.synthesizeStackTemplate(this.stack, session); + const templateAssetSource = this.synthesizeTemplate(session); + const templateAsset = this.addFileAsset(templateAssetSource); - const templateAsset = this.addFileAsset(stackTemplateFileAsset(this.stack, session)); - const assetManifestId = this.assetManifest.writeManifest(this.stack, session); + const assetManifestId = this.assetManifest.emitManifest(this.boundStack, session); - this.emitStackArtifact(this.stack, session, { + this.emitArtifact(session, { stackTemplateAssetObjectUrl: templateAsset.s3ObjectUrlWithPlaceholders, additionalDependencies: [assetManifestId], }); diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts index 323dbf413a4d1..0118db4190955 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -1,12 +1,9 @@ import * as cxapi from '@aws-cdk/cx-api'; import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; -import { Fn } from '../cfn-fn'; -import { CfnParameter } from '../cfn-parameter'; -import { CfnRule } from '../cfn-rule'; import { Stack } from '../stack'; import { Token } from '../token'; -import { AssetManifestBuilder } from './_asset-manifest-builder'; -import { assertBound, StringSpecializer, stackTemplateFileAsset } from './_shared'; +import { assertBound, StringSpecializer } from './_shared'; +import { AssetManifestBuilder } from './asset-manifest-builder'; import { StackSynthesizer } from './stack-synthesizer'; import { ISynthesisSession } from './types'; @@ -290,7 +287,6 @@ export class DefaultStackSynthesizer extends StackSynthesizer { */ public static readonly DEFAULT_BOOTSTRAP_STACK_VERSION_SSM_PARAMETER = '/cdk-bootstrap/${Qualifier}/version'; - private _stack?: Stack; private bucketName?: string; private repositoryName?: string; private _deployRoleArn?: string; @@ -303,7 +299,6 @@ export class DefaultStackSynthesizer extends StackSynthesizer { private bucketPrefix?: string; private dockerTagPrefix?: string; private bootstrapStackVersionSsmParameter?: string; - private assetManifest = new AssetManifestBuilder(); constructor(private readonly props: DefaultStackSynthesizerProps = {}) { @@ -319,7 +314,7 @@ export class DefaultStackSynthesizer extends StackSynthesizer { function validateNoToken(key: A) { const prop = props[key]; if (typeof prop === 'string' && Token.isUnresolved(prop)) { - throw new Error(`DefaultSynthesizer property '${key}' cannot contain tokens; only the following placeholder strings are allowed: ` + [ + throw new Error(`DefaultStackSynthesizer property '${key}' cannot contain tokens; only the following placeholder strings are allowed: ` + [ '${Qualifier}', cxapi.EnvironmentPlaceholders.CURRENT_REGION, cxapi.EnvironmentPlaceholders.CURRENT_ACCOUNT, @@ -330,11 +325,7 @@ export class DefaultStackSynthesizer extends StackSynthesizer { } public bind(stack: Stack): void { - if (this._stack !== undefined) { - throw new Error('A StackSynthesizer can only be used for one Stack: create a new instance to use with a different Stack'); - } - - this._stack = stack; + super.bind(stack); const qualifier = this.props.qualifier ?? stack.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; this.qualifier = qualifier; @@ -356,36 +347,53 @@ export class DefaultStackSynthesizer extends StackSynthesizer { } public addFileAsset(asset: FileAssetSource): FileAssetLocation { - assertBound(this.stack); assertBound(this.bucketName); - assertBound(this.bucketPrefix); - return this.assetManifest.addFileAssetDefault(asset, this.stack, this.bucketName, this.bucketPrefix, { - assumeRoleArn: this.fileAssetPublishingRoleArn, - assumeRoleExternalId: this.props.fileAssetPublishingExternalId, + const location = this.assetManifest.defaultAddFileAsset(this.boundStack, asset, { + bucketName: this.bucketName, + bucketPrefix: this.bucketPrefix, + role: this.fileAssetPublishingRoleArn ? { + assumeRoleArn: this.fileAssetPublishingRoleArn, + assumeRoleExternalId: this.props.fileAssetPublishingExternalId, + } : undefined, }); + return this.cloudFormationLocationFromFileAsset(location); } public addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { - assertBound(this.stack); assertBound(this.repositoryName); - assertBound(this.dockerTagPrefix); - return this.assetManifest.addDockerImageAssetDefault(asset, this.stack, this.repositoryName, this.dockerTagPrefix, { - assumeRoleArn: this.imageAssetPublishingRoleArn, - assumeRoleExternalId: this.props.imageAssetPublishingExternalId, + const location = this.assetManifest.defaultAddDockerImageAsset(this.boundStack, asset, { + repositoryName: this.repositoryName, + dockerTagPrefix: this.dockerTagPrefix, + role: this.imageAssetPublishingRoleArn ? { + assumeRoleArn: this.imageAssetPublishingRoleArn, + assumeRoleExternalId: this.props.imageAssetPublishingExternalId, + } : undefined, }); + return this.cloudFormationLocationFromDockerImageAsset(location); } + /** + * Synthesize the stack template to the given session, passing the configured lookup role ARN + */ protected synthesizeStackTemplate(stack: Stack, session: ISynthesisSession) { stack._synthesizeTemplate(session, this.lookupRoleArn); } + /** + * Return the currently bound stack + * + * @deprecated Use `boundStack` instead. + */ + protected get stack(): Stack | undefined { + return this.boundStack; + } + /** * Synthesize the associated stack to the session */ public synthesize(session: ISynthesisSession): void { - assertBound(this.stack); assertBound(this.qualifier); // Must be done here -- if it's done in bind() (called in the Stack's constructor) @@ -394,19 +402,18 @@ export class DefaultStackSynthesizer extends StackSynthesizer { // If it's done AFTER _synthesizeTemplate(), then the template won't contain the // right constructs. if (this.props.generateBootstrapVersionRule ?? true) { - addBootstrapVersionRule(this.stack, MIN_BOOTSTRAP_STACK_VERSION, this.bootstrapStackVersionSsmParameter); + this.addBootstrapVersionRule(MIN_BOOTSTRAP_STACK_VERSION, this.bootstrapStackVersionSsmParameter!); } - this.synthesizeStackTemplate(this.stack, session); + const templateAssetSource = this.synthesizeTemplate(session, this.lookupRoleArn); + const templateAsset = this.addFileAsset(templateAssetSource); - const templateAsset = this.addFileAsset(stackTemplateFileAsset(this.stack, session)); - - const assetManifestId = this.assetManifest.writeManifest(this.stack, session, { + const assetManifestId = this.assetManifest.emitManifest(this.boundStack, session, { requiresBootstrapStackVersion: MIN_BOOTSTRAP_STACK_VERSION, bootstrapStackVersionSsmParameter: this.bootstrapStackVersionSsmParameter, }); - this.emitStackArtifact(this.stack, session, { + this.emitArtifact(session, { assumeRoleExternalId: this.props.deployRoleExternalId, assumeRoleArn: this._deployRoleArn, cloudFormationExecutionRoleArn: this._cloudFormationExecutionRoleArn, @@ -442,48 +449,4 @@ export class DefaultStackSynthesizer extends StackSynthesizer { } return this._cloudFormationExecutionRoleArn; } - - protected get stack(): Stack | undefined { - return this._stack; - } -} - -/** - * Add a CfnRule to the Stack which checks the current version of the bootstrap stack this template is targeting - * - * The CLI normally checks this, but in a pipeline the CLI is not involved - * so we encode this rule into the template in a way that CloudFormation will check it. - */ -function addBootstrapVersionRule(stack: Stack, requiredVersion: number, bootstrapStackVersionSsmParameter: string) { - // Because of https://github.com/aws/aws-cdk/blob/main/packages/assert-internal/lib/synth-utils.ts#L74 - // synthesize() may be called more than once on a stack in unit tests, and the below would break - // if we execute it a second time. Guard against the constructs already existing. - if (stack.node.tryFindChild('BootstrapVersion')) { return; } - - const param = new CfnParameter(stack, 'BootstrapVersion', { - type: 'AWS::SSM::Parameter::Value', - description: `Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. ${cxapi.SSMPARAM_NO_INVALIDATE}`, - default: bootstrapStackVersionSsmParameter, - }); - - // There is no >= check in CloudFormation, so we have to check the number - // is NOT in [1, 2, 3, ... - 1] - const oldVersions = range(1, requiredVersion).map(n => `${n}`); - - new CfnRule(stack, 'CheckBootstrapVersion', { - assertions: [ - { - assert: Fn.conditionNot(Fn.conditionContains(oldVersions, param.valueAsString)), - assertDescription: `CDK bootstrap stack version ${requiredVersion} required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.`, - }, - ], - }); -} - -function range(startIncl: number, endExcl: number) { - const ret = new Array(); - for (let i = startIncl; i < endExcl; i++) { - ret.push(i); - } - return ret; } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts index 2a7a4060dcf53..6b16861016c99 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts @@ -5,3 +5,4 @@ export * from './bootstrapless-synthesizer'; export * from './nested'; export * from './stack-synthesizer'; export * from './cli-credentials-synthesizer'; +export * from './asset-manifest-builder'; diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts index c1652e462df41..c7c76ddc94819 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts @@ -4,7 +4,6 @@ import { Construct } from 'constructs'; import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; import { Fn } from '../cfn-fn'; import { FileAssetParameters } from '../private/asset-parameters'; -import { Stack } from '../stack'; import { assertBound } from './_shared'; import { StackSynthesizer } from './stack-synthesizer'; import { ISynthesisSession } from './types'; @@ -46,7 +45,6 @@ const ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY = 'assets-ecr-repository-n * by overriding `Stack.addFileAsset()` and `Stack.addDockerImageAsset()`. */ export class LegacyStackSynthesizer extends StackSynthesizer { - private stack?: Stack; private cycle = false; /** @@ -60,16 +58,7 @@ export class LegacyStackSynthesizer extends StackSynthesizer { */ private readonly addedImageAssets = new Set(); - public bind(stack: Stack): void { - if (this.stack !== undefined) { - throw new Error('A StackSynthesizer can only be used for one Stack: create a new instance to use with a different Stack'); - } - this.stack = stack; - } - public addFileAsset(asset: FileAssetSource): FileAssetLocation { - assertBound(this.stack); - // Backwards compatibility hack. We have a number of conflicting goals here: // // - We want put the actual logic in this class @@ -88,7 +77,7 @@ export class LegacyStackSynthesizer extends StackSynthesizer { } this.cycle = true; try { - const stack = this.stack; + const stack = this.boundStack; return withoutDeprecationWarnings(() => stack.addFileAsset(asset)); } finally { this.cycle = false; @@ -96,8 +85,6 @@ export class LegacyStackSynthesizer extends StackSynthesizer { } public addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { - assertBound(this.stack); - // See `addFileAsset` for explanation. // @deprecated: this can be removed for v2 if (this.cycle) { @@ -105,7 +92,7 @@ export class LegacyStackSynthesizer extends StackSynthesizer { } this.cycle = true; try { - const stack = this.stack; + const stack = this.boundStack; return withoutDeprecationWarnings(() => stack.addDockerImageAsset(asset)); } finally { this.cycle = false; @@ -116,19 +103,15 @@ export class LegacyStackSynthesizer extends StackSynthesizer { * Synthesize the associated stack to the session */ public synthesize(session: ISynthesisSession): void { - assertBound(this.stack); - - this.synthesizeStackTemplate(this.stack, session); + this.synthesizeTemplate(session); // Just do the default stuff, nothing special - this.emitStackArtifact(this.stack, session); + this.emitArtifact(session); } private doAddDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { - assertBound(this.stack); - // check if we have an override from context - const repositoryNameOverride = this.stack.node.tryGetContext(ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY); + const repositoryNameOverride = this.boundStack.node.tryGetContext(ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY); const repositoryName = asset.repositoryName ?? repositoryNameOverride ?? ASSETS_ECR_REPOSITORY_NAME; const imageTag = asset.sourceHash; const assetId = asset.sourceHash; @@ -153,19 +136,17 @@ export class LegacyStackSynthesizer extends StackSynthesizer { platform: asset.platform, }; - this.stack.node.addMetadata(cxschema.ArtifactMetadataEntryType.ASSET, metadata); + this.boundStack.node.addMetadata(cxschema.ArtifactMetadataEntryType.ASSET, metadata); this.addedImageAssets.add(assetId); } return { - imageUri: `${this.stack.account}.dkr.ecr.${this.stack.region}.${this.stack.urlSuffix}/${repositoryName}:${imageTag}`, + imageUri: `${this.boundStack.account}.dkr.ecr.${this.boundStack.region}.${this.boundStack.urlSuffix}/${repositoryName}:${imageTag}`, repositoryName, }; } private doAddFileAsset(asset: FileAssetSource): FileAssetLocation { - assertBound(this.stack); - let params = this.assetParameters.node.tryFindChild(asset.sourceHash) as FileAssetParameters; if (!params) { params = new FileAssetParameters(this.assetParameters, asset.sourceHash); @@ -185,7 +166,7 @@ export class LegacyStackSynthesizer extends StackSynthesizer { artifactHashParameter: params.artifactHashParameter.logicalId, }; - this.stack.node.addMetadata(cxschema.ArtifactMetadataEntryType.ASSET, metadata); + this.boundStack.node.addMetadata(cxschema.ArtifactMetadataEntryType.ASSET, metadata); } const bucketName = params.bucketNameParameter.valueAsString; @@ -197,17 +178,17 @@ export class LegacyStackSynthesizer extends StackSynthesizer { const s3Filename = Fn.select(1, Fn.split(cxapi.ASSET_PREFIX_SEPARATOR, encodedKey)); const objectKey = `${s3Prefix}${s3Filename}`; - const httpUrl = `https://s3.${this.stack.region}.${this.stack.urlSuffix}/${bucketName}/${objectKey}`; + const httpUrl = `https://s3.${this.boundStack.region}.${this.boundStack.urlSuffix}/${bucketName}/${objectKey}`; const s3ObjectUrl = `s3://${bucketName}/${objectKey}`; return { bucketName, objectKey, httpUrl, s3ObjectUrl, s3Url: httpUrl }; } private get assetParameters() { - assertBound(this.stack); + assertBound(this.boundStack); if (!this._assetParameters) { - this._assetParameters = new Construct(this.stack, 'AssetParameters'); + this._assetParameters = new Construct(this.boundStack, 'AssetParameters'); } return this._assetParameters; } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts index d9c86fc782745..c04867f86695c 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts @@ -1,6 +1,4 @@ import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; -import { Stack } from '../stack'; -import { assertBound } from './_shared'; import { StackSynthesizer } from './stack-synthesizer'; import { IStackSynthesizer, ISynthesisSession } from './types'; @@ -13,19 +11,10 @@ import { IStackSynthesizer, ISynthesisSession } from './types'; * App builder do not need to use this class directly. */ export class NestedStackSynthesizer extends StackSynthesizer { - private stack?: Stack; - constructor(private readonly parentDeployment: IStackSynthesizer) { super(); } - public bind(stack: Stack): void { - if (this.stack !== undefined) { - throw new Error('A StackSynthesizer can only be used for one Stack: create a new instance to use with a different Stack'); - } - this.stack = stack; - } - public addFileAsset(asset: FileAssetSource): FileAssetLocation { // Forward to parent deployment. By the magic of cross-stack references any parameter // returned and used will magically be forwarded to the nested stack. @@ -39,9 +28,8 @@ export class NestedStackSynthesizer extends StackSynthesizer { } public synthesize(session: ISynthesisSession): void { - assertBound(this.stack); // Synthesize the template, but don't emit as a cloud assembly artifact. // It will be registered as an S3 asset of its parent instead. - this.synthesizeStackTemplate(this.stack, session); + this.synthesizeTemplate(session); } } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts index 6253df8caf0d8..624d22b173eea 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts @@ -1,7 +1,13 @@ +import * as fs from 'fs'; +import * as path from 'path'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; +import * as cxapi from '@aws-cdk/cx-api'; +import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource, FileAssetPackaging } from '../assets'; +import { Fn } from '../cfn-fn'; +import { CfnParameter } from '../cfn-parameter'; +import { CfnRule } from '../cfn-rule'; import { Stack } from '../stack'; -import { addStackArtifactToAssembly } from './_shared'; +import { addStackArtifactToAssembly, contentHash, resolvedOr } from './_shared'; import { IStackSynthesizer, ISynthesisSession } from './types'; /** @@ -13,17 +19,31 @@ import { IStackSynthesizer, ISynthesisSession } from './types'; * and could not be accessed by external implementors. */ export abstract class StackSynthesizer implements IStackSynthesizer { + private _boundStack?: Stack; + /** * Bind to the stack this environment is going to be used on * * Must be called before any of the other methods are called. */ - public abstract bind(stack: Stack): void; + public bind(stack: Stack): void { + if (this._boundStack !== undefined) { + throw new Error('A StackSynthesizer can only be used for one Stack: create a new instance to use with a different Stack'); + } + + this._boundStack = stack; + } /** * Register a File Asset * * Returns the parameters that can be used to refer to the asset inside the template. + * + * The synthesizer must rely on some out-of-band mechanism to make sure the given files + * are actually placed in the returned location before the deployment happens. This can + * be by writing the intructions to the asset manifest (for use by the `cdk-assets` tool), + * by relying on the CLI to upload files (legacy behavior), or some other operator controlled + * mechanism. */ public abstract addFileAsset(asset: FileAssetSource): FileAssetLocation; @@ -31,6 +51,12 @@ export abstract class StackSynthesizer implements IStackSynthesizer { * Register a Docker Image Asset * * Returns the parameters that can be used to refer to the asset inside the template. + * + * The synthesizer must rely on some out-of-band mechanism to make sure the given files + * are actually placed in the returned location before the deployment happens. This can + * be by writing the intructions to the asset manifest (for use by the `cdk-assets` tool), + * by relying on the CLI to upload files (legacy behavior), or some other operator controlled + * mechanism. */ public abstract addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation; @@ -41,20 +67,126 @@ export abstract class StackSynthesizer implements IStackSynthesizer { /** * Have the stack write out its template + * + * @deprecated Use `synthesizeTemplate` instead */ protected synthesizeStackTemplate(stack: Stack, session: ISynthesisSession): void { stack._synthesizeTemplate(session); } + /** + * Write the stack template to the given session + * + * Return a descriptor that represents the stack template as a file asset + * source, for adding to an asset manifest (if desired). This can be used to + * have the asset manifest system (`cdk-assets`) upload the template to S3 + * using the appropriate role, so that afterwards only a CloudFormation + * deployment is necessary. + * + * If the template is uploaded as an asset, the `stackTemplateAssetObjectUrl` + * property should be set when calling `emitArtifact.` + * + * If the template is *NOT* uploaded as an asset first and the template turns + * out to be >50KB, it will need to be uploaded to S3 anyway. At that point + * the credentials will be the same identity that is doing the `UpdateStack` + * call, which may not have the right permissions to write to S3. + */ + protected synthesizeTemplate(session: ISynthesisSession, lookupRoleArn?: string): FileAssetSource { + this.boundStack._synthesizeTemplate(session, lookupRoleArn); + return stackTemplateFileAsset(this.boundStack, session); + } + /** * Write the stack artifact to the session * * Use default settings to add a CloudFormationStackArtifact artifact to * the given synthesis session. + * + * @deprecated Use `emitArtifact` instead */ protected emitStackArtifact(stack: Stack, session: ISynthesisSession, options: SynthesizeStackArtifactOptions = {}) { addStackArtifactToAssembly(session, stack, options ?? {}, options.additionalDependencies ?? []); } + + /** + * Write the CloudFormation stack artifact to the session + * + * Use default settings to add a CloudFormationStackArtifact artifact to + * the given synthesis session. The Stack artifact will control the settings for the + * CloudFormation deployment. + */ + protected emitArtifact(session: ISynthesisSession, options: SynthesizeStackArtifactOptions = {}) { + addStackArtifactToAssembly(session, this.boundStack, options ?? {}, options.additionalDependencies ?? []); + } + + /** + * Add a CfnRule to the bound stack that checks whether an SSM parameter exceeds a given version + * + * This will modify the template, so must be called before the stack is synthesized. + */ + protected addBootstrapVersionRule(requiredVersion: number, bootstrapStackVersionSsmParameter: string) { + addBootstrapVersionRule(this.boundStack, requiredVersion, bootstrapStackVersionSsmParameter); + } + + /** + * Retrieve the bound stack + * + * Fails if the stack hasn't been bound yet. + */ + protected get boundStack(): Stack { + if (!this._boundStack) { + throw new Error('The StackSynthesizer must be bound to a Stack first before boundStack() can be called'); + } + return this._boundStack; + } + + /** + * Turn a file asset location into a CloudFormation representation of that location + * + * If any of the fields contain placeholders, the result will be wrapped in a `Fn.sub`. + */ + protected cloudFormationLocationFromFileAsset(location: cxschema.FileDestination): FileAssetLocation { + const { region, urlSuffix } = stackLocationOrInstrinsics(this.boundStack); + const httpUrl = cfnify( + `https://s3.${region}.${urlSuffix}/${location.bucketName}/${location.objectKey}`, + ); + const s3ObjectUrlWithPlaceholders = `s3://${location.bucketName}/${location.objectKey}`; + + // Return CFN expression + // + // 's3ObjectUrlWithPlaceholders' is intended for the CLI. The CLI ultimately needs a + // 'https://s3.REGION.amazonaws.com[.cn]/name/hash' URL to give to CloudFormation. + // However, there's no way for us to actually know the URL_SUFFIX in the framework, so + // we can't construct that URL. Instead, we record the 's3://.../...' form, and the CLI + // transforms it to the correct 'https://.../' URL before calling CloudFormation. + return { + bucketName: cfnify(location.bucketName), + objectKey: cfnify(location.objectKey), + httpUrl, + s3ObjectUrl: cfnify(s3ObjectUrlWithPlaceholders), + s3ObjectUrlWithPlaceholders, + s3Url: httpUrl, + }; + } + + /** + * Turn a docker asset location into a CloudFormation representation of that location + * + * If any of the fields contain placeholders, the result will be wrapped in a `Fn.sub`. + */ + protected cloudFormationLocationFromDockerImageAsset(dest: cxschema.DockerImageDestination): DockerImageAssetLocation { + const { account, region, urlSuffix } = stackLocationOrInstrinsics(this.boundStack); + + // Return CFN expression + return { + repositoryName: cfnify(dest.repositoryName), + imageUri: cfnify( + `${account}.dkr.ecr.${region}.${urlSuffix}/${dest.repositoryName}:${dest.imageTag}`, + ), + imageTag: cfnify(dest.imageTag), + }; + } + } /** @@ -135,3 +267,91 @@ export interface SynthesizeStackArtifactOptions { */ readonly bootstrapStackVersionSsmParameter?: string; } + +function stackTemplateFileAsset(stack: Stack, session: ISynthesisSession): FileAssetSource { + const templatePath = path.join(session.assembly.outdir, stack.templateFile); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Stack template ${stack.stackName} not written yet: ${templatePath}`); + } + + const template = fs.readFileSync(templatePath, { encoding: 'utf-8' }); + + const sourceHash = contentHash(template); + + return { + fileName: stack.templateFile, + packaging: FileAssetPackaging.FILE, + sourceHash, + }; +} + +/** + * Add a CfnRule to the Stack which checks the current version of the bootstrap stack this template is targeting + * + * The CLI normally checks this, but in a pipeline the CLI is not involved + * so we encode this rule into the template in a way that CloudFormation will check it. + */ +function addBootstrapVersionRule(stack: Stack, requiredVersion: number, bootstrapStackVersionSsmParameter: string) { + // Because of https://github.com/aws/aws-cdk/blob/main/packages/assert-internal/lib/synth-utils.ts#L74 + // synthesize() may be called more than once on a stack in unit tests, and the below would break + // if we execute it a second time. Guard against the constructs already existing. + if (stack.node.tryFindChild('BootstrapVersion')) { return; } + + const param = new CfnParameter(stack, 'BootstrapVersion', { + type: 'AWS::SSM::Parameter::Value', + description: `Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. ${cxapi.SSMPARAM_NO_INVALIDATE}`, + default: bootstrapStackVersionSsmParameter, + }); + + // There is no >= check in CloudFormation, so we have to check the number + // is NOT in [1, 2, 3, ... - 1] + const oldVersions = range(1, requiredVersion).map(n => `${n}`); + + new CfnRule(stack, 'CheckBootstrapVersion', { + assertions: [ + { + assert: Fn.conditionNot(Fn.conditionContains(oldVersions, param.valueAsString)), + assertDescription: `CDK bootstrap stack version ${requiredVersion} required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.`, + }, + ], + }); +} + +function range(startIncl: number, endExcl: number) { + const ret = new Array(); + for (let i = startIncl; i < endExcl; i++) { + ret.push(i); + } + return ret; +} + +/** + * Return the stack locations if they're concrete, or the original CFN intrisics otherwise + * + * We need to return these instead of the tokenized versions of the strings, + * since we must accept those same ${AWS::AccountId}/${AWS::Region} placeholders + * in bucket names and role names (in order to allow environment-agnostic stacks). + * + * We'll wrap a single {Fn::Sub} around the final string in order to replace everything, + * but we can't have the token system render part of the string to {Fn::Join} because + * the CFN specification doesn't allow the {Fn::Sub} template string to be an arbitrary + * expression--it must be a string literal. + */ +function stackLocationOrInstrinsics(stack: Stack) { + return { + account: resolvedOr(stack.account, '${AWS::AccountId}'), + region: resolvedOr(stack.region, '${AWS::Region}'), + urlSuffix: resolvedOr(stack.urlSuffix, '${AWS::URLSuffix}'), + }; +} + +/** + * If the string still contains placeholders, wrap it in a Fn::Sub so they will be substituted at CFN deployment time + * + * (This happens to work because the placeholders we picked map directly onto CFN + * placeholders. If they didn't we'd have to do a transformation here). + */ +function cfnify(s: string): string { + return s.indexOf('${') > -1 ? Fn.sub(s) : s; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts b/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts index f7480265fdbc7..0208d01e42bb9 100644 --- a/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts +++ b/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts @@ -139,17 +139,14 @@ describe('new style synthesis', () => { * Synthesize the associated bootstrap stack to the session. */ public synthesize(session: ISynthesisSession): void { - if (!this.stack) { - throw new Error('You must call bind() with a stack instance first'); - } - this.synthesizeStackTemplate(this.stack, session); + this.synthesizeTemplate(session); session.assembly.addArtifact('FAKE_ARTIFACT_ID', { type: ArtifactType.ASSET_MANIFEST, properties: { file: 'FAKE_ARTIFACT_ID.json', }, }); - this.emitStackArtifact(this.stack, session, { + this.emitArtifact(session, { additionalDependencies: ['FAKE_ARTIFACT_ID'], }); } diff --git a/packages/@aws-cdk/core/test/synthesis.test.ts b/packages/@aws-cdk/core/test/synthesis.test.ts index 70bfc05042a82..359601dac62f7 100644 --- a/packages/@aws-cdk/core/test/synthesis.test.ts +++ b/packages/@aws-cdk/core/test/synthesis.test.ts @@ -229,8 +229,10 @@ describe('synthesis', () => { const calls = new Array(); class SynthesizeMe extends cdk.Stack { + public readonly templateFile = 'hey.json'; + constructor() { - super(undefined as any, 'id', { + super(undefined as any, 'hey', { synthesizer: new cdk.LegacyStackSynthesizer(), }); this.node.addValidation({ diff --git a/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts index b3195847b2f92..ee79eb2c638d3 100644 --- a/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as path from 'path'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { CloudArtifact } from '../cloud-artifact'; @@ -47,6 +48,8 @@ export class AssetManifestArtifact extends CloudArtifact { */ public readonly bootstrapStackVersionSsmParameter?: string; + private _contents?: cxschema.AssetManifest; + constructor(assembly: CloudAssembly, name: string, artifact: cxschema.ArtifactManifest) { super(assembly, name, artifact); @@ -58,6 +61,19 @@ export class AssetManifestArtifact extends CloudArtifact { this.requiresBootstrapStackVersion = properties.requiresBootstrapStackVersion; this.bootstrapStackVersionSsmParameter = properties.bootstrapStackVersionSsmParameter; } + + /** + * The Asset Manifest contents + */ + public get contents(): cxschema.AssetManifest { + if (this._contents !== undefined) { + return this._contents; + } + + const contents = this._contents = JSON.parse(fs.readFileSync(this.file, 'utf-8')); + return contents; + } + } /**