From da00e1a204cfcfb006d7cb955bb0fec0fe104d78 Mon Sep 17 00:00:00 2001 From: Calvin Combs Date: Tue, 12 Dec 2023 17:17:15 -0800 Subject: [PATCH] CLI options --- packages/aws-cdk/lib/api/deploy-stack.ts | 141 +---------------- packages/aws-cdk/lib/api/deployments.ts | 3 +- .../aws-cdk/lib/api/util/cloudformation.ts | 2 +- .../lib/api/util/template-body-parameter.ts | 146 ++++++++++++++++++ packages/aws-cdk/lib/cdk-toolkit.ts | 17 +- packages/aws-cdk/lib/cli.ts | 4 +- packages/aws-cdk/lib/diff.ts | 2 +- 7 files changed, 166 insertions(+), 149 deletions(-) create mode 100644 packages/aws-cdk/lib/api/util/template-body-parameter.ts diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index 15fa6a846d9a2..f2fb67b92cc7d 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -1,7 +1,6 @@ import * as cxapi from '@aws-cdk/cx-api'; import type { CloudFormation } from 'aws-sdk'; import * as chalk from 'chalk'; -import * as fs from 'fs-extra'; import * as uuid from 'uuid'; import { ISDK, SdkProvider } from './aws-auth'; import { EnvironmentResources } from './environment-resources'; @@ -13,18 +12,12 @@ import { waitForStackDeploy, waitForStackDelete, ParameterValues, ParameterChanges, ResourcesToImport, } from './util/cloudformation'; import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor'; +import { TemplateBodyParameter, makeBodyParameter } from './util/template-body-parameter'; import { addMetadataAssetsToManifest } from '../assets'; import { Tag } from '../cdk-toolkit'; -import { debug, error, print, warning } from '../logging'; -import { toYAML } from '../serialize'; +import { debug, print, warning } from '../logging'; import { AssetManifestBuilder } from '../util/asset-manifest-builder'; import { publishAssets } from '../util/asset-publishing'; -import { contentHash } from '../util/content-hash'; - -export type TemplateBodyParameter = { - TemplateBody?: string - TemplateURL?: string -}; export interface DeployStackResult { readonly noOp: boolean; @@ -233,8 +226,6 @@ export interface ChangeSetDeploymentMethod { readonly changeSetName?: string; } -const LARGE_TEMPLATE_SIZE_KB = 50; - export async function deployStack(options: DeployStackOptions): Promise { const stackArtifact = options.stack; @@ -559,100 +550,6 @@ class FullCloudFormationDeployment { } } -/** - * Prepares the body parameter for +CreateChangeSet+. - * - * If the template is small enough to be inlined into the API call, just return - * it immediately. - * - * Otherwise, add it to the asset manifest to get uploaded to the staging - * bucket and return its coordinates. If there is no staging bucket, an error - * is thrown. - * - * @param stack the synthesized stack that provides the CloudFormation template - * @param toolkitInfo information about the toolkit stack - */ -export async function makeBodyParameter( - stack: cxapi.CloudFormationStackArtifact, - resolvedEnvironment: cxapi.Environment, - assetManifest: AssetManifestBuilder, - resources: EnvironmentResources, - sdk: ISDK, - overrideTemplate?: any, -): Promise { - - // If the template has already been uploaded to S3, just use it from there. - if (stack.stackTemplateAssetObjectUrl && !overrideTemplate) { - return { TemplateURL: restUrlFromManifest(stack.stackTemplateAssetObjectUrl, resolvedEnvironment, sdk) }; - } - - // Otherwise, pass via API call (if small) or upload here (if large) - const templateJson = toYAML(overrideTemplate ?? stack.template); - - if (templateJson.length <= LARGE_TEMPLATE_SIZE_KB * 1024) { - return { TemplateBody: templateJson }; - } - - const toolkitInfo = await resources.lookupToolkit(); - if (!toolkitInfo.found) { - error( - `The template for stack "${stack.displayName}" is ${Math.round(templateJson.length / 1024)}KiB. ` + - `Templates larger than ${LARGE_TEMPLATE_SIZE_KB}KiB must be uploaded to S3.\n` + - 'Run the following command in order to setup an S3 bucket in this environment, and then re-deploy:\n\n', - chalk.blue(`\t$ cdk bootstrap ${resolvedEnvironment.name}\n`)); - - throw new Error('Template too large to deploy ("cdk bootstrap" is required)'); - } - - const templateHash = contentHash(templateJson); - const key = `cdk/${stack.id}/${templateHash}.yml`; - - let templateFile = stack.templateFile; - if (overrideTemplate) { - // Add a variant of this template - templateFile = `${stack.templateFile}-${templateHash}.yaml`; - await fs.writeFile(templateFile, templateJson, { encoding: 'utf-8' }); - } - - assetManifest.addFileAsset(templateHash, { - path: templateFile, - }, { - bucketName: toolkitInfo.bucketName, - objectKey: key, - }); - - const templateURL = `${toolkitInfo.bucketUrl}/${key}`; - debug('Storing template in S3 at:', templateURL); - return { TemplateURL: templateURL }; -} - -/** - * Prepare a body parameter for CFN, performing the upload - * - * Return it as-is if it is small enough to pass in the API call, - * upload to S3 and return the coordinates if it is not. - */ -export async function makeBodyParameterAndUpload( - stack: cxapi.CloudFormationStackArtifact, - resolvedEnvironment: cxapi.Environment, - resources: EnvironmentResources, - sdkProvider: SdkProvider, - sdk: ISDK, - overrideTemplate?: any): Promise { - - // We don't have access to the actual asset manifest here, so pretend that the - // stack doesn't have a pre-published URL. - const forceUploadStack = Object.create(stack, { - stackTemplateAssetObjectUrl: { value: undefined }, - }); - - const builder = new AssetManifestBuilder(); - const bodyparam = await makeBodyParameter(forceUploadStack, resolvedEnvironment, builder, resources, sdk, overrideTemplate); - const manifest = builder.toManifest(stack.assembly.directory); - await publishAssets(manifest, sdkProvider, resolvedEnvironment, { quiet: true }); - return bodyparam; -} - export interface DestroyStackOptions { /** * The stack to be destroyed @@ -783,40 +680,6 @@ function compareTags(a: Tag[], b: Tag[]): boolean { return true; } -/** - * Format an S3 URL in the manifest for use with CloudFormation - * - * Replaces environment placeholders (which this field may contain), - * and reformats s3://.../... urls into S3 REST URLs (which CloudFormation - * expects) - */ -function restUrlFromManifest(url: string, environment: cxapi.Environment, sdk: ISDK): string { - const doNotUseMarker = '**DONOTUSE**'; - // This URL may contain placeholders, so still substitute those. - url = cxapi.EnvironmentPlaceholders.replace(url, { - accountId: environment.account, - region: environment.region, - partition: doNotUseMarker, - }); - - // Yes, this is extremely crude, but we don't actually need this so I'm not inclined to spend - // a lot of effort trying to thread the right value to this location. - if (url.indexOf(doNotUseMarker) > -1) { - throw new Error('Cannot use \'${AWS::Partition}\' in the \'stackTemplateAssetObjectUrl\' field'); - } - - const s3Url = url.match(/s3:\/\/([^/]+)\/(.*)$/); - if (!s3Url) { return url; } - - // We need to pass an 'https://s3.REGION.amazonaws.com[.cn]/bucket/object' URL to CloudFormation, but we - // got an 's3://bucket/object' URL instead. Construct the rest API URL here. - const bucketName = s3Url[1]; - const objectKey = s3Url[2]; - - const urlSuffix: string = sdk.getEndpointSuffix(environment.region); - return `https://s3.${environment.region}.${urlSuffix}/${bucketName}/${objectKey}`; -} - function suffixWithErrors(msg: string, errors?: string[]) { return errors && errors.length > 0 ? `${msg}: ${errors.join(', ')}` diff --git a/packages/aws-cdk/lib/api/deployments.ts b/packages/aws-cdk/lib/api/deployments.ts index e04dbec9ac93e..4da0d27837c92 100644 --- a/packages/aws-cdk/lib/api/deployments.ts +++ b/packages/aws-cdk/lib/api/deployments.ts @@ -4,13 +4,14 @@ import { AssetManifest, IManifestEntry } from 'cdk-assets'; import { Mode } from './aws-auth/credentials'; import { ISDK } from './aws-auth/sdk'; import { CredentialsOptions, SdkForEnvironment, SdkProvider } from './aws-auth/sdk-provider'; -import { deployStack, DeployStackResult, destroyStack, makeBodyParameterAndUpload, DeploymentMethod } from './deploy-stack'; +import { deployStack, DeployStackResult, destroyStack, DeploymentMethod } from './deploy-stack'; import { EnvironmentResources, EnvironmentResourcesRegistry } from './environment-resources'; import { HotswapMode } from './hotswap/common'; import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, flattenNestedStackNames, TemplateWithNestedStackCount } from './nested-stack-helpers'; import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries } from './util/cloudformation'; import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor'; import { replaceEnvPlaceholders } from './util/placeholders'; +import { makeBodyParameterAndUpload } from './util/template-body-parameter'; import { Tag } from '../cdk-toolkit'; import { debug, warning } from '../logging'; import { buildAssets, publishAssets, BuildAssetsOptions, PublishAssetsOptions, PublishingAws, EVENT_TO_LOGGER } from '../util/asset-publishing'; diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index 1fbee6a41778c..c63cc797e1c13 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -2,9 +2,9 @@ import { SSMPARAM_NO_INVALIDATE } from '@aws-cdk/cx-api'; import * as cxapi from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import { StackStatus } from './cloudformation/stack-status'; +import { makeBodyParameterAndUpload, TemplateBodyParameter } from './template-body-parameter'; import { debug } from '../../logging'; import { deserializeStructure } from '../../serialize'; -import { TemplateBodyParameter, makeBodyParameterAndUpload } from '../deploy-stack'; import { Deployments } from '../deployments'; export type Template = { diff --git a/packages/aws-cdk/lib/api/util/template-body-parameter.ts b/packages/aws-cdk/lib/api/util/template-body-parameter.ts new file mode 100644 index 0000000000000..02f1d8c81dfb9 --- /dev/null +++ b/packages/aws-cdk/lib/api/util/template-body-parameter.ts @@ -0,0 +1,146 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; +import * as fs from 'fs-extra'; +import { debug, error } from '../../logging'; +import { toYAML } from '../../serialize'; +import { AssetManifestBuilder } from '../../util/asset-manifest-builder'; +import { publishAssets } from '../../util/asset-publishing'; +import { contentHash } from '../../util/content-hash'; +import { ISDK, SdkProvider } from '../aws-auth'; +import { EnvironmentResources } from '../environment-resources'; + +export type TemplateBodyParameter = { + TemplateBody?: string + TemplateURL?: string +}; + +const LARGE_TEMPLATE_SIZE_KB = 50; + +/** + * Prepares the body parameter for +CreateChangeSet+. + * + * If the template is small enough to be inlined into the API call, just return + * it immediately. + * + * Otherwise, add it to the asset manifest to get uploaded to the staging + * bucket and return its coordinates. If there is no staging bucket, an error + * is thrown. + * + * @param stack the synthesized stack that provides the CloudFormation template + * @param toolkitInfo information about the toolkit stack + */ +export async function makeBodyParameter( + stack: cxapi.CloudFormationStackArtifact, + resolvedEnvironment: cxapi.Environment, + assetManifest: AssetManifestBuilder, + resources: EnvironmentResources, + sdk: ISDK, + overrideTemplate?: any, +): Promise { + + // If the template has already been uploaded to S3, just use it from there. + if (stack.stackTemplateAssetObjectUrl && !overrideTemplate) { + return { TemplateURL: restUrlFromManifest(stack.stackTemplateAssetObjectUrl, resolvedEnvironment, sdk) }; + } + + // Otherwise, pass via API call (if small) or upload here (if large) + const templateJson = toYAML(overrideTemplate ?? stack.template); + + if (templateJson.length <= LARGE_TEMPLATE_SIZE_KB * 1024) { + return { TemplateBody: templateJson }; + } + + const toolkitInfo = await resources.lookupToolkit(); + if (!toolkitInfo.found) { + error( + `The template for stack "${stack.displayName}" is ${Math.round(templateJson.length / 1024)}KiB. ` + + `Templates larger than ${LARGE_TEMPLATE_SIZE_KB}KiB must be uploaded to S3.\n` + + 'Run the following command in order to setup an S3 bucket in this environment, and then re-deploy:\n\n', + chalk.blue(`\t$ cdk bootstrap ${resolvedEnvironment.name}\n`)); + + throw new Error('Template too large to deploy ("cdk bootstrap" is required)'); + } + + const templateHash = contentHash(templateJson); + const key = `cdk/${stack.id}/${templateHash}.yml`; + + let templateFile = stack.templateFile; + if (overrideTemplate) { + // Add a variant of this template + templateFile = `${stack.templateFile}-${templateHash}.yaml`; + await fs.writeFile(templateFile, templateJson, { encoding: 'utf-8' }); + } + + assetManifest.addFileAsset(templateHash, { + path: templateFile, + }, { + bucketName: toolkitInfo.bucketName, + objectKey: key, + }); + + const templateURL = `${toolkitInfo.bucketUrl}/${key}`; + debug('Storing template in S3 at:', templateURL); + return { TemplateURL: templateURL }; +} + +/** + * Prepare a body parameter for CFN, performing the upload + * + * Return it as-is if it is small enough to pass in the API call, + * upload to S3 and return the coordinates if it is not. + */ +export async function makeBodyParameterAndUpload( + stack: cxapi.CloudFormationStackArtifact, + resolvedEnvironment: cxapi.Environment, + resources: EnvironmentResources, + sdkProvider: SdkProvider, + sdk: ISDK, + overrideTemplate?: any): Promise { + + // We don't have access to the actual asset manifest here, so pretend that the + // stack doesn't have a pre-published URL. + const forceUploadStack = Object.create(stack, { + stackTemplateAssetObjectUrl: { value: undefined }, + }); + + const builder = new AssetManifestBuilder(); + const bodyparam = await makeBodyParameter(forceUploadStack, resolvedEnvironment, builder, resources, sdk, overrideTemplate); + const manifest = builder.toManifest(stack.assembly.directory); + await publishAssets(manifest, sdkProvider, resolvedEnvironment, { quiet: true }); + + return bodyparam; +} + +/** + * Format an S3 URL in the manifest for use with CloudFormation + * + * Replaces environment placeholders (which this field may contain), + * and reformats s3://.../... urls into S3 REST URLs (which CloudFormation + * expects) + */ +function restUrlFromManifest(url: string, environment: cxapi.Environment, sdk: ISDK): string { + const doNotUseMarker = '**DONOTUSE**'; + // This URL may contain placeholders, so still substitute those. + url = cxapi.EnvironmentPlaceholders.replace(url, { + accountId: environment.account, + region: environment.region, + partition: doNotUseMarker, + }); + + // Yes, this is extremely crude, but we don't actually need this so I'm not inclined to spend + // a lot of effort trying to thread the right value to this location. + if (url.indexOf(doNotUseMarker) > -1) { + throw new Error('Cannot use \'${AWS::Partition}\' in the \'stackTemplateAssetObjectUrl\' field'); + } + + const s3Url = url.match(/s3:\/\/([^/]+)\/(.*)$/); + if (!s3Url) { return url; } + + // We need to pass an 'https://s3.REGION.amazonaws.com[.cn]/bucket/object' URL to CloudFormation, but we + // got an 's3://bucket/object' URL instead. Construct the rest API URL here. + const bucketName = s3Url[1]; + const objectKey = s3Url[2]; + + const urlSuffix: string = sdk.getEndpointSuffix(environment.region); + return `https://s3.${environment.region}.${urlSuffix}/${bucketName}/${objectKey}`; +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 72ec51f4c0fda..dcc07e2082da6 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -133,15 +133,13 @@ export class CdkToolkit { throw new Error(`There is no file at ${options.templatePath}`); } - /////////////////////////////// - const changeSet = await prepareAndCreateChangeSet({ + const changeSet = options.changeSet ? await prepareAndCreateChangeSet({ resourcesToImport: options.resourcesToImport, stack: stacks.firstStack, uuid: uuid.v4(), willExecute: false, deployments: this.props.deployments, - }); - /////////////////////////////// + }) : undefined; const template = deserializeStructure(await fs.readFile(options.templatePath, { encoding: 'UTF-8' })); diffs = options.securityOnly @@ -160,13 +158,13 @@ export class CdkToolkit { const currentTemplate = templateWithNames.deployedTemplate; const nestedStackCount = templateWithNames.nestedStackCount; - const changeSet = await prepareAndCreateChangeSet({ + const changeSet = options.changeSet ? await prepareAndCreateChangeSet({ resourcesToImport: options.resourcesToImport, stack, uuid: uuid.v4(), deployments: this.props.deployments, willExecute: false, - }); + }) : undefined; const stackCount = options.securityOnly @@ -957,6 +955,13 @@ export interface DiffOptions { * @default - no resources to import */ resourcesToImport?: ResourcesToImport; + + /** + * Whether or not to create, analyze, and subsequently delete a changeset + * + * @default true + */ + changeSet?: boolean; } interface CfnDeployOptions { diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 85e3bdaaf4996..88ab8cbab9fc4 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -262,7 +262,8 @@ async function parseCommandLineArguments(args: string[]) { .option('security-only', { type: 'boolean', desc: 'Only diff for broadened security changes', default: false }) .option('fail', { type: 'boolean', desc: 'Fail with exit code 1 in case of diff' }) .option('processed', { type: 'boolean', desc: 'Whether to compare against the template with Transforms already processed', default: false }) - .option('quiet', { type: 'boolean', alias: 'q', desc: 'Do not print stack name and default message when there is no diff to stdout', default: false })) + .option('quiet', { type: 'boolean', alias: 'q', desc: 'Do not print stack name and default message when there is no diff to stdout', default: false }) + .option('change-set', { type: 'boolean', desc: 'Whether to create a changeset to analyze resource replacements', default: true })) .command('metadata [STACK]', 'Returns all metadata associated with this stack') .command(['acknowledge [ID]', 'ack [ID]'], 'Acknowledge a notice so that it does not show up anymore') .command('notices', 'Returns a list of relevant notices') @@ -495,6 +496,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise