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

import { format } from 'util'; import * as cxapi from '@aws-cdk/cx-api'; import { SSMPARAM_NO_INVALIDATE } from '@aws-cdk/cx-api'; import type { DescribeChangeSetCommandOutput, Parameter, ResourceToImport, Tag, } from '@aws-sdk/client-cloudformation'; import { ChangeSetStatus, } from '@aws-sdk/client-cloudformation'; import type { FileManifestEntry } from 'cdk-assets'; import { AssetManifest } from 'cdk-assets'; import { AssetManifestBuilder } from './asset-manifest-builder'; import type { Deployments } from './deployments'; import type { ICloudFormationClient, SdkProvider } from '../aws-auth/private'; import type { Template, TemplateBodyParameter, TemplateParameter } from '../cloudformation'; import { CloudFormationStack, makeBodyParameter } from '../cloudformation'; import { IO, type IoHelper } from '../io/private'; import type { ResourcesToImport } from '../resource-import'; import { ToolkitError } from '../toolkit-error'; /** * Describe a changeset in CloudFormation, regardless of its current state. * * @param cfn a CloudFormation client * @param stackName the name of the Stack the ChangeSet belongs to * @param changeSetName the name of the ChangeSet * @param fetchAll if true, fetches all pages of the change set description. * * @returns CloudFormation information about the ChangeSet */ async function describeChangeSet( cfn: ICloudFormationClient, stackName: string, changeSetName: string, { fetchAll }: { fetchAll: boolean }, ): Promise<DescribeChangeSetCommandOutput> { const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName, }); // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { const nextPage = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: response.ChangeSetId ?? changeSetName, NextToken: response.NextToken, }); // Consolidate the changes if (nextPage.Changes != null) { response.Changes = response.Changes != null ? response.Changes.concat(nextPage.Changes) : nextPage.Changes; } // Forward the new NextToken response.NextToken = nextPage.NextToken; } return response; } /** * Waits for a function to return non-+undefined+ before returning. * * @param valueProvider a function that will return a value that is not +undefined+ once the wait should be over * @param timeout the time to wait between two calls to +valueProvider+ * * @returns the value that was returned by +valueProvider+ */ async function waitFor<T>( valueProvider: () => Promise<T | null | undefined>, timeout: number = 5000, ): Promise<T | undefined> { while (true) { const result = await valueProvider(); if (result === null) { return undefined; } else if (result !== undefined) { return result; } await new Promise((cb) => setTimeout(cb, timeout)); } } /** * Waits for a ChangeSet to be available for triggering a StackUpdate. * * Will return a changeset that is either ready to be executed or has no changes. * Will throw in other cases. * * @param cfn a CloudFormation client * @param stackName the name of the Stack that the ChangeSet belongs to * @param changeSetName the name of the ChangeSet * @param fetchAll if true, fetches all pages of the ChangeSet before returning. * * @returns the CloudFormation description of the ChangeSet */ export async function waitForChangeSet( cfn: ICloudFormationClient, ioHelper: IoHelper, stackName: string, changeSetName: string, { fetchAll }: { fetchAll: boolean }, ): Promise<DescribeChangeSetCommandOutput> { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Waiting for changeset %s on stack %s to finish creating...', changeSetName, stackName))); const ret = await waitFor(async () => { const description = await describeChangeSet(cfn, stackName, changeSetName, { fetchAll, }); // The following doesn't use a switch because tsc will not allow fall-through, UNLESS it is allows // EVERYWHERE that uses this library directly or indirectly, which is undesirable. if (description.Status === 'CREATE_PENDING' || description.Status === 'CREATE_IN_PROGRESS') { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Changeset %s on stack %s is still creating', changeSetName, stackName))); return undefined; } if (description.Status === ChangeSetStatus.CREATE_COMPLETE || changeSetHasNoChanges(description)) { return description; } // eslint-disable-next-line max-len throw new ToolkitError( `Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`, ); }); if (!ret) { throw new ToolkitError('Change set took too long to be created; aborting'); } return ret; } export type PrepareChangeSetOptions = { stack: cxapi.CloudFormationStackArtifact; deployments: Deployments; uuid: string; willExecute: boolean; sdkProvider: SdkProvider; parameters: { [name: string]: string | undefined }; resourcesToImport?: ResourcesToImport; /** * Default behavior is to log AWS CloudFormation errors and move on. Set this property to true to instead * fail on errors received by AWS CloudFormation. * * @default false */ failOnError?: boolean; } export type CreateChangeSetOptions = { cfn: ICloudFormationClient; changeSetName: string; willExecute: boolean; exists: boolean; uuid: string; stack: cxapi.CloudFormationStackArtifact; bodyParameter: TemplateBodyParameter; parameters: { [name: string]: string | undefined }; resourcesToImport?: ResourceToImport[]; role?: string; }; /** * Create a changeset for a diff operation */ export async function createDiffChangeSet( ioHelper: IoHelper, options: PrepareChangeSetOptions, ): Promise<DescribeChangeSetCommandOutput | undefined> { // `options.stack` has been modified to include any nested stack templates directly inline with its own template, under a special `NestedTemplate` property. // Thus the parent template's Resources section contains the nested template's CDK metadata check, which uses Fn::Equals. // This causes CreateChangeSet to fail with `Template Error: Fn::Equals cannot be partially collapsed`. for (const resource of Object.values(options.stack.template.Resources ?? {})) { if ((resource as any).Type === 'AWS::CloudFormation::Stack') { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg('This stack contains one or more nested stacks, falling back to template-only diff...')); return undefined; } } return uploadBodyParameterAndCreateChangeSet(ioHelper, options); } /** * Returns all file entries from an AssetManifestArtifact that look like templates. * * This is used in the `uploadBodyParameterAndCreateChangeSet` function to find * all template asset files to build and publish. * * Returns a tuple of [AssetManifest, FileManifestEntry[]] */ function templatesFromAssetManifestArtifact( artifact: cxapi.AssetManifestArtifact, ): [AssetManifest, FileManifestEntry[]] { const assets: FileManifestEntry[] = []; const fileName = artifact.file; const assetManifest = AssetManifest.fromFile(fileName); assetManifest.entries.forEach((entry) => { if (entry.type === 'file') { const source = (entry as FileManifestEntry).source; if (source.path && source.path.endsWith('.template.json')) { assets.push(entry as FileManifestEntry); } } }); return [assetManifest, assets]; } async function uploadBodyParameterAndCreateChangeSet( ioHelper: IoHelper, options: PrepareChangeSetOptions, ): Promise<DescribeChangeSetCommandOutput | undefined> { try { await uploadStackTemplateAssets(options.stack, options.deployments); const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); const bodyParameter = await makeBodyParameter( ioHelper, options.stack, env.resolvedEnvironment, new AssetManifestBuilder(), env.resources, ); const cfn = env.sdk.cloudFormation(); const exists = (await CloudFormationStack.lookup(cfn, options.stack.stackName, false)).exists; const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn); await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg( 'Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)\n', )); return await createChangeSet(ioHelper, { cfn, changeSetName: 'cdk-diff-change-set', stack: options.stack, exists, uuid: options.uuid, willExecute: options.willExecute, bodyParameter, parameters: options.parameters, resourcesToImport: options.resourcesToImport, role: executionRoleArn, }); } catch (e: any) { // This function is currently only used by diff so these messages are diff-specific if (!options.failOnError) { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(String(e))); await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg( 'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n', )); return undefined; } throw new ToolkitError('Could not create a change set and failOnError is set. (run again with failOnError off to base the diff on template differences)\n', e); } } /** * Uploads the assets that look like templates for this CloudFormation stack * * This is necessary for any CloudFormation call that needs the template, it may need * to be uploaded to an S3 bucket first. We have to follow the instructions in the * asset manifest, because technically that is the only place that knows about * bucket and assumed roles and such. */ export async function uploadStackTemplateAssets(stack: cxapi.CloudFormationStackArtifact, deployments: Deployments) { for (const artifact of stack.dependencies) { // Skip artifact if it is not an Asset Manifest Artifact if (!cxapi.AssetManifestArtifact.isAssetManifestArtifact(artifact)) { continue; } const [assetManifest, file_entries] = templatesFromAssetManifestArtifact(artifact); for (const entry of file_entries) { await deployments.buildSingleAsset(artifact, assetManifest, entry, { stack, }); await deployments.publishSingleAsset(assetManifest, entry, { stack, }); } } } export async function createChangeSet( ioHelper: IoHelper, options: CreateChangeSetOptions, ): Promise<DescribeChangeSetCommandOutput> { await cleanupOldChangeset(options.cfn, ioHelper, options.changeSetName, options.stack.stackName); await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Attempting to create ChangeSet with name ${options.changeSetName} for stack ${options.stack.stackName}`)); const templateParams = TemplateParameters.fromTemplate(options.stack.template); const stackParams = templateParams.supplyAll(options.parameters); const changeSet = await options.cfn.createChangeSet({ StackName: options.stack.stackName, ChangeSetName: options.changeSetName, ChangeSetType: options.resourcesToImport ? 'IMPORT' : options.exists ? 'UPDATE' : 'CREATE', Description: `CDK Changeset for diff ${options.uuid}`, ClientToken: `diff${options.uuid}`, TemplateURL: options.bodyParameter.TemplateURL, TemplateBody: options.bodyParameter.TemplateBody, Parameters: stackParams.apiParameters, ResourcesToImport: options.resourcesToImport, RoleARN: options.role, Tags: toCfnTags(options.stack.tags), Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], }); await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id))); // Fetching all pages if we'll execute, so we can have the correct change count when monitoring. const createdChangeSet = await waitForChangeSet(options.cfn, ioHelper, options.stack.stackName, options.changeSetName, { fetchAll: options.willExecute, }); await cleanupOldChangeset(options.cfn, ioHelper, options.changeSetName, options.stack.stackName); return createdChangeSet; } function toCfnTags(tags: { [id: string]: string }): Tag[] { return Object.entries(tags).map(([k, v]) => ({ Key: k, Value: v, })); } async function cleanupOldChangeset( cfn: ICloudFormationClient, ioHelper: IoHelper, changeSetName: string, stackName: string, ) { // Delete any existing change sets generated by CDK since change set names must be unique. // The delete request is successful as long as the stack exists (even if the change set does not exist). await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Removing existing change set with name ${changeSetName} if it exists`)); await cfn.deleteChangeSet({ StackName: stackName, ChangeSetName: changeSetName, }); } /** * Return true if the given change set has no changes * * This must be determined from the status, not the 'Changes' array on the * object; the latter can be empty because no resources were changed, but if * there are changes to Outputs, the change set can still be executed. */ export function changeSetHasNoChanges(description: DescribeChangeSetCommandOutput) { const noChangeErrorPrefixes = [ // Error message for a regular template "The submitted information didn't contain changes.", // Error message when a Transform is involved (see #10650) 'No updates are to be performed.', ]; return ( description.Status === 'FAILED' && noChangeErrorPrefixes.some((p) => (description.StatusReason ?? '').startsWith(p)) ); } /** * Waits for a CloudFormation stack to stabilize in a complete/available state * after a delete operation is issued. * * Fails if the stack is in a FAILED state. Will not fail if the stack was * already deleted. * * @param cfn a CloudFormation client * @param stackName the name of the stack to wait for after a delete * * @returns the CloudFormation description of the stabilized stack after the delete attempt */ export async function waitForStackDelete( cfn: ICloudFormationClient, ioHelper: IoHelper, stackName: string, ): Promise<CloudFormationStack | undefined> { const stack = await stabilizeStack(cfn, ioHelper, stackName); if (!stack) { return undefined; } const status = stack.stackStatus; if (status.isFailure) { throw new ToolkitError( `The stack named ${stackName} is in a failed state. You may need to delete it from the AWS console : ${status}`, ); } else if (status.isDeleted) { return undefined; } return stack; } /** * Waits for a CloudFormation stack to stabilize in a complete/available state * after an update/create operation is issued. * * Fails if the stack is in a FAILED state, ROLLBACK state, or DELETED state. * * @param cfn a CloudFormation client * @param stackName the name of the stack to wait for after an update * * @returns the CloudFormation description of the stabilized stack after the update attempt */ export async function waitForStackDeploy( cfn: ICloudFormationClient, ioHelper: IoHelper, stackName: string, ): Promise<CloudFormationStack | undefined> { const stack = await stabilizeStack(cfn, ioHelper, stackName); if (!stack) { return undefined; } const status = stack.stackStatus; if (status.isCreationFailure) { throw new ToolkitError( `The stack named ${stackName} failed creation, it may need to be manually deleted from the AWS console: ${status}`, ); } else if (!status.isDeploySuccess) { throw new ToolkitError(`The stack named ${stackName} failed to deploy: ${status}`); } return stack; } /** * Wait for a stack to become stable (no longer _IN_PROGRESS), returning it */ export async function stabilizeStack( cfn: ICloudFormationClient, ioHelper: IoHelper, stackName: string, ) { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Waiting for stack %s to finish creating or updating...', stackName))); return waitFor(async () => { const stack = await CloudFormationStack.lookup(cfn, stackName); if (!stack.exists) { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Stack %s does not exist', stackName))); return null; } const status = stack.stackStatus; if (status.isInProgress) { await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Stack %s has an ongoing operation in progress and is not stable (%s)', stackName, status))); return undefined; } else if (status.isReviewInProgress) { // This may happen if a stack creation operation is interrupted before the ChangeSet execution starts. Recovering // from this would requiring manual intervention (deleting or executing the pending ChangeSet), and failing to do // so will result in an endless wait here (the ChangeSet wont delete or execute itself). Instead of blocking // "forever" we proceed as if the stack was existing and stable. If there is a concurrent operation that just // hasn't finished proceeding just yet, either this operation or the concurrent one may fail due to the other one // having made progress. Which is fine. I guess. await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Stack %s is in REVIEW_IN_PROGRESS state. Considering this is a stable status (%s)', stackName, status))); } return stack; }); } /** * The set of (formal) parameters that have been declared in a template */ export class TemplateParameters { public static fromTemplate(template: Template) { return new TemplateParameters(template.Parameters || {}); } constructor(private readonly params: Record<string, TemplateParameter>) { } /** * Calculate stack parameters to pass from the given desired parameter values * * Will throw if parameters without a Default value or a Previous value are not * supplied. */ public supplyAll(updates: Record<string, string | undefined>): ParameterValues { return new ParameterValues(this.params, updates); } /** * From the template, the given desired values and the current values, calculate the changes to the stack parameters * * Will take into account parameters already set on the template (will emit * 'UsePreviousValue: true' for those unless the value is changed), and will * throw if parameters without a Default value or a Previous value are not * supplied. */ public updateExisting( updates: Record<string, string | undefined>, previousValues: Record<string, string>, ): ParameterValues { return new ParameterValues(this.params, updates, previousValues); } } /** * The set of parameters we're going to pass to a Stack */ export class ParameterValues { public readonly values: Record<string, string> = {}; public readonly apiParameters: Parameter[] = []; constructor( private readonly formalParams: Record<string, TemplateParameter>, updates: Record<string, string | undefined>, previousValues: Record<string, string> = {}, ) { const missingRequired = new Array<string>(); for (const [key, formalParam] of Object.entries(this.formalParams)) { // Check updates first, then use the previous value (if available), then use // the default (if available). // // If we don't find a parameter value using any of these methods, then that's an error. const updatedValue = updates[key]; if (updatedValue !== undefined) { this.values[key] = updatedValue; this.apiParameters.push({ ParameterKey: key, ParameterValue: updates[key], }); continue; } if (key in previousValues) { this.values[key] = previousValues[key]; this.apiParameters.push({ ParameterKey: key, UsePreviousValue: true }); continue; } if (formalParam.Default !== undefined) { this.values[key] = formalParam.Default; continue; } // Oh no missingRequired.push(key); } if (missingRequired.length > 0) { throw new ToolkitError(`The following CloudFormation Parameters are missing a value: ${missingRequired.join(', ')}`); } // Just append all supplied overrides that aren't really expected (this // will fail CFN but maybe people made typos that they want to be notified // of) const unknownParam = ([key, _]: [string, any]) => this.formalParams[key] === undefined; const hasValue = ([_, value]: [string, any]) => !!value; for (const [key, value] of Object.entries(updates).filter(unknownParam).filter(hasValue)) { this.values[key] = value!; this.apiParameters.push({ ParameterKey: key, ParameterValue: value }); } } /** * Whether this set of parameter updates will change the actual stack values */ public hasChanges(currentValues: Record<string, string>): ParameterChanges { // If any of the parameters are SSM parameters, deploying must always happen // because we can't predict what the values will be. We will allow some // parameters to opt out of this check by having a magic string in their description. if ( Object.values(this.formalParams).some( (p) => p.Type.startsWith('AWS::SSM::Parameter::') && !p.Description?.includes(SSMPARAM_NO_INVALIDATE), ) ) { return 'ssm'; } // Otherwise we're dirty if: // - any of the existing values are removed, or changed if (Object.entries(currentValues).some(([key, value]) => !(key in this.values) || value !== this.values[key])) { return true; } // - any of the values we're setting are new if (Object.keys(this.values).some((key) => !(key in currentValues))) { return true; } return false; } } export type ParameterChanges = boolean | 'ssm';