packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts (416 lines of code) (raw):

import { randomUUID } from 'crypto'; import type * as cxapi from '@aws-cdk/cx-api'; import * as cdk_assets from 'cdk-assets'; import * as chalk from 'chalk'; import { AssetManifestBuilder } from './asset-manifest-builder'; import { BasePublishProgressListener, PublishingAws, } from './asset-publishing'; import { stabilizeStack, uploadStackTemplateAssets, } from './cfn-api'; import { determineAllowCrossAccountAssetPublishing } from './checks'; import { deployStack, destroyStack } from './deploy-stack'; import type { DeploymentMethod } from './deployment-method'; import type { DeployStackResult } from './deployment-result'; import { formatErrorMessage } from '../../util'; import type { SdkProvider } from '../aws-auth/private'; import type { Template, RootTemplateWithNestedStacks, } from '../cloudformation'; import { CloudFormationStack, loadCurrentTemplate, loadCurrentTemplateWithNestedStacks, makeBodyParameter, } from '../cloudformation'; import { type EnvironmentResources, EnvironmentAccess } from '../environment'; import type { HotswapMode, HotswapPropertyOverrides } from '../hotswap/common'; import { IO, type IoHelper } from '../io/private'; import type { ResourceIdentifierSummaries, ResourcesToImport } from '../resource-import'; import { StackActivityMonitor, StackEventPoller, RollbackChoice } from '../stack-events'; import type { Tag } from '../tags'; import { ToolkitError } from '../toolkit-error'; import { DEFAULT_TOOLKIT_STACK_NAME } from '../toolkit-info'; const BOOTSTRAP_STACK_VERSION_FOR_ROLLBACK = 23; export interface DeployStackOptions { /** * Stack to deploy */ readonly stack: cxapi.CloudFormationStackArtifact; /** * Execution role for the deployment (pass through to CloudFormation) * * @default - Current role */ readonly roleArn?: string; /** * Topic ARNs to send a message when deployment finishes (pass through to CloudFormation) * * @default - No notifications */ readonly notificationArns?: string[]; /** * Override name under which stack will be deployed * * @default - Use artifact default */ readonly deployName?: string; /** * Name of the toolkit stack, if not the default name * * @default 'CDKToolkit' */ readonly toolkitStackName?: string; /** * List of asset IDs which should NOT be built or uploaded * * @default - Build all assets */ readonly reuseAssets?: string[]; /** * Stack tags (pass through to CloudFormation) */ readonly tags?: Tag[]; /** * Stage the change set but don't execute it * * @default - true * @deprecated Use 'deploymentMethod' instead */ readonly execute?: boolean; /** * Optional name to use for the CloudFormation change set. * If not provided, a name will be generated automatically. * * @deprecated Use 'deploymentMethod' instead */ readonly changeSetName?: string; /** * Select the deployment method (direct or using a change set) * * @default - Change set with default options */ readonly deploymentMethod?: DeploymentMethod; /** * Force deployment, even if the deployed template is identical to the one we are about to deploy. * @default false deployment will be skipped if the template is identical */ readonly forceDeployment?: boolean; /** * Extra parameters for CloudFormation * @default - no additional parameters will be passed to the template */ readonly parameters?: { [name: string]: string | undefined }; /** * Use previous values for unspecified parameters * * If not set, all parameters must be specified for every deployment. * * @default true */ readonly usePreviousParameters?: boolean; /** * Rollback failed deployments * * @default true */ readonly rollback?: boolean; /* * Whether to perform a 'hotswap' deployment. * A 'hotswap' deployment will attempt to short-circuit CloudFormation * and update the affected resources like Lambda functions directly. * * @default - `HotswapMode.FULL_DEPLOYMENT` for regular deployments, `HotswapMode.HOTSWAP_ONLY` for 'watch' deployments */ readonly hotswap?: HotswapMode; /** * Properties that configure hotswap behavior */ readonly hotswapPropertyOverrides?: HotswapPropertyOverrides; /** * The extra string to append to the User-Agent header when performing AWS SDK calls. * * @default - nothing extra is appended to the User-Agent header */ readonly extraUserAgent?: string; /** * List of existing resources to be IMPORTED into the stack, instead of being CREATED */ readonly resourcesToImport?: ResourcesToImport; /** * If present, use this given template instead of the stored one * * @default - Use the stored template */ readonly overrideTemplate?: any; /** * Whether to build/publish assets in parallel * * @default true To remain backward compatible. */ readonly assetParallelism?: boolean; /** * Whether to deploy if the app contains no stacks. * * @deprecated this option seems to be unsed inside deployments * @default false */ readonly ignoreNoStacks?: boolean; } export interface RollbackStackOptions { /** * Stack to roll back */ readonly stack: cxapi.CloudFormationStackArtifact; /** * Execution role for the deployment (pass through to CloudFormation) * * @default - Current role */ readonly roleArn?: string; /** * Name of the toolkit stack, if not the default name * * @default 'CDKToolkit' */ readonly toolkitStackName?: string; /** * Whether to automatically orphan all failed resources during the rollback * * This will force a rollback that otherwise would have failed. * * @default false */ readonly orphanFailedResources?: boolean; /** * Orphan the resources with the given logical IDs * * @default - No orphaning */ readonly orphanLogicalIds?: string[]; /** * Whether to validate the version of the bootstrap stack permissions * * @default true */ readonly validateBootstrapStackVersion?: boolean; } export type RollbackStackResult = { readonly stackArn: string } & ( | { readonly notInRollbackableState: true } | { readonly success: true; notInRollbackableState?: undefined } ); interface AssetOptions { /** * Stack with assets to build. */ readonly stack: cxapi.CloudFormationStackArtifact; /** * Execution role for the building. * * @default - Current role */ readonly roleArn?: string; } export interface BuildStackAssetsOptions extends AssetOptions { /** * Stack name this asset is for */ readonly stackName?: string; } interface PublishStackAssetsOptions extends AssetOptions { /** * Stack name this asset is for */ readonly stackName?: string; /** * Always publish, even if it already exists * * @default false */ readonly forcePublish?: boolean; } export interface DestroyStackOptions { stack: cxapi.CloudFormationStackArtifact; deployName?: string; roleArn?: string; } export interface StackExistsOptions { stack: cxapi.CloudFormationStackArtifact; deployName?: string; tryLookupRole?: boolean; } export interface DeploymentsProps { readonly sdkProvider: SdkProvider; readonly toolkitStackName?: string; readonly ioHelper: IoHelper; } /** * Scope for a single set of deployments from a set of Cloud Assembly Artifacts * * Manages lookup of SDKs, Bootstrap stacks, etc. */ export class Deployments { public readonly envs: EnvironmentAccess; /** * SDK provider for asset publishing (do not use for anything else). * * This SDK provider is only allowed to be used for that purpose, nothing else. * * It's not a different object, but the field name should imply that this * object should not be used directly, except to pass to asset handling routines. */ private readonly assetSdkProvider: SdkProvider; /** * SDK provider for passing to deployStack * * This SDK provider is only allowed to be used for that purpose, nothing else. * * It's not a different object, but the field name should imply that this * object should not be used directly, except to pass to `deployStack`. */ private readonly deployStackSdkProvider: SdkProvider; private readonly publisherCache = new Map<cdk_assets.AssetManifest, cdk_assets.AssetPublishing>(); private _allowCrossAccountAssetPublishing: boolean | undefined; private readonly ioHelper: IoHelper; constructor(private readonly props: DeploymentsProps) { this.assetSdkProvider = props.sdkProvider; this.deployStackSdkProvider = props.sdkProvider; this.ioHelper = props.ioHelper; this.envs = new EnvironmentAccess( props.sdkProvider, props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME, this.ioHelper, ); } /** * Resolves the environment for a stack. */ public async resolveEnvironment(stack: cxapi.CloudFormationStackArtifact): Promise<cxapi.Environment> { return this.envs.resolveStackEnvironment(stack); } public async readCurrentTemplateWithNestedStacks( rootStackArtifact: cxapi.CloudFormationStackArtifact, retrieveProcessedTemplate: boolean = false, ): Promise<RootTemplateWithNestedStacks> { const env = await this.envs.accessStackForLookupBestEffort(rootStackArtifact); return loadCurrentTemplateWithNestedStacks(rootStackArtifact, env.sdk, retrieveProcessedTemplate); } public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> { await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Reading existing template for stack ${stackArtifact.displayName}.`)); const env = await this.envs.accessStackForLookupBestEffort(stackArtifact); return loadCurrentTemplate(stackArtifact, env.sdk); } public async resourceIdentifierSummaries( stackArtifact: cxapi.CloudFormationStackArtifact, ): Promise<ResourceIdentifierSummaries> { await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Retrieving template summary for stack ${stackArtifact.displayName}.`)); // Currently, needs to use `deploy-role` since it may need to read templates in the staging // bucket which have been encrypted with a KMS key (and lookup-role may not read encrypted things) const env = await this.envs.accessStackForReadOnlyStackOperations(stackArtifact); const cfn = env.sdk.cloudFormation(); await uploadStackTemplateAssets(stackArtifact, this); // Upload the template, if necessary, before passing it to CFN const builder = new AssetManifestBuilder(); const cfnParam = await makeBodyParameter( this.ioHelper, stackArtifact, env.resolvedEnvironment, builder, env.resources, ); // If the `makeBodyParameter` before this added assets, make sure to publish them before // calling the API. const addedAssets = builder.toManifest(stackArtifact.assembly.directory); for (const entry of addedAssets.entries) { await this.buildSingleAsset('no-version-validation', addedAssets, entry, { stack: stackArtifact, }); await this.publishSingleAsset(addedAssets, entry, { stack: stackArtifact, }); } const response = await cfn.getTemplateSummary(cfnParam); if (!response.ResourceIdentifierSummaries) { await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg('GetTemplateSummary API call did not return "ResourceIdentifierSummaries"')); } return response.ResourceIdentifierSummaries ?? []; } public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> { let deploymentMethod = options.deploymentMethod; if (options.changeSetName || options.execute !== undefined) { if (deploymentMethod) { throw new ToolkitError( "You cannot supply both 'deploymentMethod' and 'changeSetName/execute'. Supply one or the other.", ); } deploymentMethod = { method: 'change-set', changeSetName: options.changeSetName, execute: options.execute, }; } const env = await this.envs.accessStackForMutableStackOperations(options.stack); // Do a verification of the bootstrap stack version await this.validateBootstrapStackVersion( options.stack.stackName, options.stack.requiresBootstrapStackVersion, options.stack.bootstrapStackVersionSsmParameter, env.resources); const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn); return deployStack({ stack: options.stack, resolvedEnvironment: env.resolvedEnvironment, deployName: options.deployName, notificationArns: options.notificationArns, sdk: env.sdk, sdkProvider: this.deployStackSdkProvider, roleArn: executionRoleArn, reuseAssets: options.reuseAssets, envResources: env.resources, tags: options.tags, deploymentMethod, forceDeployment: options.forceDeployment, parameters: options.parameters, usePreviousParameters: options.usePreviousParameters, rollback: options.rollback, hotswap: options.hotswap, hotswapPropertyOverrides: options.hotswapPropertyOverrides, extraUserAgent: options.extraUserAgent, resourcesToImport: options.resourcesToImport, overrideTemplate: options.overrideTemplate, assetParallelism: options.assetParallelism, }, this.ioHelper); } public async rollbackStack(options: RollbackStackOptions): Promise<RollbackStackResult> { let resourcesToSkip: string[] = options.orphanLogicalIds ?? []; if (options.orphanFailedResources && resourcesToSkip.length > 0) { throw new ToolkitError('Cannot combine --force with --orphan'); } const env = await this.envs.accessStackForMutableStackOperations(options.stack); if (options.validateBootstrapStackVersion ?? true) { // Do a verification of the bootstrap stack version await this.validateBootstrapStackVersion( options.stack.stackName, BOOTSTRAP_STACK_VERSION_FOR_ROLLBACK, options.stack.bootstrapStackVersionSsmParameter, env.resources); } const cfn = env.sdk.cloudFormation(); const deployName = options.stack.stackName; // We loop in case of `--force` and the stack ends up in `CONTINUE_UPDATE_ROLLBACK`. let maxLoops = 10; while (maxLoops--) { const cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName); const stackArn = cloudFormationStack.stackId; const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn); switch (cloudFormationStack.stackStatus.rollbackChoice) { case RollbackChoice.NONE: await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`Stack ${deployName} does not need a rollback: ${cloudFormationStack.stackStatus}`)); return { stackArn: cloudFormationStack.stackId, notInRollbackableState: true }; case RollbackChoice.START_ROLLBACK: await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Initiating rollback of stack ${deployName}`)); await cfn.rollbackStack({ StackName: deployName, RoleARN: executionRoleArn, ClientRequestToken: randomUUID(), // Enabling this is just the better overall default, the only reason it isn't the upstream default is backwards compatibility RetainExceptOnCreate: true, }); break; case RollbackChoice.CONTINUE_UPDATE_ROLLBACK: if (options.orphanFailedResources) { // Find the failed resources from the deployment and automatically skip them // (Using deployment log because we definitely have `DescribeStackEvents` permissions, and we might not have // `DescribeStackResources` permissions). const poller = new StackEventPoller(cfn, { stackName: deployName, stackStatuses: ['ROLLBACK_IN_PROGRESS', 'UPDATE_ROLLBACK_IN_PROGRESS'], }); await poller.poll(); resourcesToSkip = poller.resourceErrors .filter((r) => !r.isStackEvent && r.parentStackLogicalIds.length === 0) .map((r) => r.event.LogicalResourceId ?? ''); } const skipDescription = resourcesToSkip.length > 0 ? ` (orphaning: ${resourcesToSkip.join(', ')})` : ''; await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`Continuing rollback of stack ${deployName}${skipDescription}`)); await cfn.continueUpdateRollback({ StackName: deployName, ClientRequestToken: randomUUID(), RoleARN: executionRoleArn, ResourcesToSkip: resourcesToSkip, }); break; case RollbackChoice.ROLLBACK_FAILED: await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg( `Stack ${deployName} failed creation and rollback. This state cannot be rolled back. You can recreate this stack by running 'cdk deploy'.`, )); return { stackArn, notInRollbackableState: true }; default: throw new ToolkitError(`Unexpected rollback choice: ${cloudFormationStack.stackStatus.rollbackChoice}`); } const monitor = new StackActivityMonitor({ cfn, stack: options.stack, stackName: deployName, ioHelper: this.ioHelper, }); await monitor.start(); let stackErrorMessage: string | undefined = undefined; let finalStackState = cloudFormationStack; try { const successStack = await stabilizeStack(cfn, this.ioHelper, deployName); // This shouldn't really happen, but catch it anyway. You never know. if (!successStack) { throw new ToolkitError('Stack deploy failed (the stack disappeared while we were rolling it back)'); } finalStackState = successStack; const errors = monitor.errors.join(', '); if (errors) { stackErrorMessage = errors; } } catch (e: any) { stackErrorMessage = suffixWithErrors(formatErrorMessage(e), monitor.errors); } finally { await monitor.stop(); } if (finalStackState.stackStatus.isRollbackSuccess || !stackErrorMessage) { return { stackArn, success: true }; } // Either we need to ignore some resources to continue the rollback, or something went wrong if (finalStackState.stackStatus.rollbackChoice === RollbackChoice.CONTINUE_UPDATE_ROLLBACK && options.orphanFailedResources) { // Do another loop-de-loop continue; } throw new ToolkitError( `${stackErrorMessage} (fix problem and retry, or orphan these resources using --orphan or --force)`, ); } throw new ToolkitError( "Rollback did not finish after a large number of iterations; stopping because it looks like we're not making progress anymore. You can retry if rollback was progressing as expected.", ); } public async destroyStack(options: DestroyStackOptions) { const env = await this.envs.accessStackForMutableStackOperations(options.stack); const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn); return destroyStack({ sdk: env.sdk, roleArn: executionRoleArn, stack: options.stack, deployName: options.deployName, }, this.ioHelper); } public async stackExists(options: StackExistsOptions): Promise<boolean> { let env; if (options.tryLookupRole) { env = await this.envs.accessStackForLookupBestEffort(options.stack); } else { env = await this.envs.accessStackForReadOnlyStackOperations(options.stack); } const stack = await CloudFormationStack.lookup(env.sdk.cloudFormation(), options.deployName ?? options.stack.stackName); return stack.exists; } /** * Build a single asset from an asset manifest * * If an assert manifest artifact is given, the bootstrap stack version * will be validated according to the constraints in that manifest artifact. * If that is not necessary, `'no-version-validation'` can be passed. */ // eslint-disable-next-line max-len public async buildSingleAsset( assetArtifact: cxapi.AssetManifestArtifact | 'no-version-validation', assetManifest: cdk_assets.AssetManifest, asset: cdk_assets.IManifestEntry, options: BuildStackAssetsOptions, ) { if (assetArtifact !== 'no-version-validation') { const env = await this.envs.accessStackForReadOnlyStackOperations(options.stack); await this.validateBootstrapStackVersion( options.stack.stackName, assetArtifact.requiresBootstrapStackVersion, assetArtifact.bootstrapStackVersionSsmParameter, env.resources); } const resolvedEnvironment = await this.envs.resolveStackEnvironment(options.stack); const publisher = this.cachedPublisher(assetManifest, resolvedEnvironment, options.stackName); await publisher.buildEntry(asset); if (publisher.hasFailures) { throw new ToolkitError(`Failed to build asset ${asset.displayName(false)}`); } } /** * Publish a single asset from an asset manifest */ public async publishSingleAsset( assetManifest: cdk_assets.AssetManifest, asset: cdk_assets.IManifestEntry, options: PublishStackAssetsOptions, ) { const stackEnv = await this.envs.resolveStackEnvironment(options.stack); // No need to validate anymore, we already did that during build const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); await publisher.publishEntry(asset, { allowCrossAccount: await this.allowCrossAccountAssetPublishingForEnv(options.stack), force: options.forcePublish, }); if (publisher.hasFailures) { throw new ToolkitError(`Failed to publish asset ${asset.displayName(true)}`); } } private async allowCrossAccountAssetPublishingForEnv(stack: cxapi.CloudFormationStackArtifact): Promise<boolean> { if (this._allowCrossAccountAssetPublishing === undefined) { const env = await this.envs.accessStackForReadOnlyStackOperations(stack); this._allowCrossAccountAssetPublishing = await determineAllowCrossAccountAssetPublishing(env.sdk, this.ioHelper, this.props.toolkitStackName); } return this._allowCrossAccountAssetPublishing; } /** * Return whether a single asset has been published already */ public async isSingleAssetPublished( assetManifest: cdk_assets.AssetManifest, asset: cdk_assets.IManifestEntry, options: PublishStackAssetsOptions, ) { const stackEnv = await this.envs.resolveStackEnvironment(options.stack); const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); return publisher.isEntryPublished(asset); } /** * Validate that the bootstrap stack has the right version for this stack * * Call into envResources.validateVersion, but prepend the stack name in case of failure. */ private async validateBootstrapStackVersion( stackName: string, requiresBootstrapStackVersion: number | undefined, bootstrapStackVersionSsmParameter: string | undefined, envResources: EnvironmentResources, ) { try { await envResources.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter); } catch (e: any) { throw new ToolkitError(`${stackName}: ${formatErrorMessage(e)}`); } } private cachedPublisher(assetManifest: cdk_assets.AssetManifest, env: cxapi.Environment, stackName?: string) { const existing = this.publisherCache.get(assetManifest); if (existing) { return existing; } const prefix = stackName ? `${chalk.bold(stackName)}: ` : ''; const publisher = new cdk_assets.AssetPublishing(assetManifest, { // The AssetPublishing class takes care of role assuming etc, so it's okay to // give it a direct `SdkProvider`. aws: new PublishingAws(this.assetSdkProvider, env), progressListener: new ParallelSafeAssetProgress(prefix, this.ioHelper), }); this.publisherCache.set(assetManifest, publisher); return publisher; } } /** * Asset progress that doesn't do anything with percentages (currently) */ class ParallelSafeAssetProgress extends BasePublishProgressListener { private readonly prefix: string; constructor(prefix: string, ioHelper: IoHelper) { super(ioHelper); this.prefix = prefix; } protected getMessage(type: cdk_assets.EventType, event: cdk_assets.IPublishProgress): string { return `${this.prefix}${type}: ${event.message}`; } } function suffixWithErrors(msg: string, errors?: string[]) { return errors && errors.length > 0 ? `${msg}: ${errors.join(', ')}` : msg; }