import { CloudFormationClient, CloudFormationClientConfig, CreateChangeSetCommand, DescribeChangeSetCommand, DescribeChangeSetOutput, ExecuteChangeSetCommand, paginateDescribeStacks, Stack, StackEvent, } from '@aws-sdk/client-cloudformation'; import { GetObjectCommand, S3Client, S3ClientConfig } from '@aws-sdk/client-s3'; import { AdaptiveRetryStrategy } from '@aws-sdk/middleware-retry'; import { assert } from '@fmtk/decoders'; import { ParameterDefinition } from '@awboost/cfntemplate'; import stream from 'stream'; import { TypedEmitter } from 'tiny-typed-emitter'; import { v4 as uuid } from 'uuid'; import { BaseTemplate, decodeBaseTemplate } from './BaseTemplate.js'; import { AwsCommonServiceConfig } from './CommonServiceConfig.js'; import { DownloadTemplateProgressStats } from './DownloadTemplateProgressStats.js'; import { assertCondition } from './internal/assertCondition.js'; import { delay } from './internal/delay.js'; import { stripUndefined } from './internal/stripUndefined.js'; import { ParameterValue } from './ParameterValue.js'; import { ParameterValueProviders, setParameters, SetParameterValues, setProvideParameters, } from './setParameters.js'; import { streamChangeSetEvents } from './streamChangeSetEvents.js'; import { TemplateLocation } from './TemplateLocation.js'; export interface CreateChangeSetOptions

{ name?: string; parameterDefaults?: SetParameterValues

; parameters?: SetParameterValues

; provideParameterDefaults?: ParameterValueProviders

; provideParameters?: ( current: Record, ) => SetParameterValues

| PromiseLike>; } export enum ChangeSetCreateStatus { ChangeSetCreateComplete = 'ChangeSetCreateComplete', ChangeSetCreateInProgress = 'ChangeSetCreateInProgress', ChangeSetCreating = 'ChangeSetCreating', DescribeStack = 'DescribeStack', PrepareParameters = 'PrepareParameters', } export enum ChangeSetExecuteStatus { ChangeSetCreateComplete = 'ChangeSetCreateComplete', ChangeSetCreateInProgress = 'ChangeSetCreateInProgress', ChangeSetCreating = 'ChangeSetCreating', DescribeStack = 'DescribeStack', PrepareParameters = 'PrepareParameters', } export enum CloudFormationStackEventKey { ChangeSetCreateStatus = 'ChangesetCreateStatus', DownloadTemplateProgress = 'DownloadTemplateProgress', ExecuteEvent = 'ExecuteEvent', } export interface CloudFormationStackEvents { [CloudFormationStackEventKey.ChangeSetCreateStatus]( status: ChangeSetCreateStatus, changeset?: DescribeChangeSetOutput, ): void; [CloudFormationStackEventKey.DownloadTemplateProgress]( stats: DownloadTemplateProgressStats, ): void; [CloudFormationStackEventKey.ExecuteEvent](event: StackEvent): void; } export class CloudFormationStackDeployer extends TypedEmitter { private readonly cfn: CloudFormationClient; private readonly s3: S3Client; private readonly stackName: string; private readonly templateLocation: TemplateLocation | undefined; private _changeset: DescribeChangeSetOutput | undefined; private _template: BaseTemplate | undefined; public get changeset(): DescribeChangeSetOutput | undefined { return this._changeset; } private set changeset(value: DescribeChangeSetOutput | undefined) { this._changeset = value; } constructor(dep: { awsConfig?: AwsCommonServiceConfig; cfn?: CloudFormationClient; cfnConfig?: CloudFormationClientConfig; s3?: S3Client; s3Config?: S3ClientConfig; stackName: string; template?: TemplateLocation; }) { super(); this.stackName = dep.stackName; this.templateLocation = dep.template; this.cfn = dep.cfn ?? new CloudFormationClient({ retryStrategy: dep.cfnConfig?.retryStrategy ?? new AdaptiveRetryStrategy(async () => 10, { delayDecider: (base, attempt) => base * 2 ** attempt, }), ...dep.awsConfig, ...dep.cfnConfig, }); this.s3 = dep.s3 ?? new S3Client({ ...dep.awsConfig, ...dep.s3Config, }); } /** * Create a changeset for the stack. */ public async createChangeSet( opts: CreateChangeSetOptions = {}, ): Promise { const template = await this.getTemplate(); const parameters: Record = {}; const templateParams = (template as any).Parameters as Record< string, ParameterDefinition >; this.emit( CloudFormationStackEventKey.ChangeSetCreateStatus, ChangeSetCreateStatus.DescribeStack, ); const existing = await this.getStack(); this.emit( CloudFormationStackEventKey.ChangeSetCreateStatus, ChangeSetCreateStatus.PrepareParameters, ); assertCondition(this.templateLocation); // set the parameters for the assets if (template.Metadata.DeployAssetManifest) { for (const asset of template.Metadata.DeployAssetManifest.assets) { parameters[asset.bucketParam] = { ParameterValue: this.templateLocation.S3Bucket, }; parameters[asset.keyParam] = { ParameterValue: asset.key }; } } // get the existing parameter values if (existing?.Parameters) { for (const key of Object.keys(templateParams)) { const oldValue = existing.Parameters.find( (x) => x.ParameterKey === key, ); if (!oldValue) { continue; } const newValue = parameters[key]; parameters[key] = { ...newValue, UsePreviousValue: oldValue && !newValue, PreviousValue: oldValue.ParameterValue, }; } } // set the parameter values according to the options if (opts.parameters) { setParameters(parameters, opts.parameters); } if (opts.provideParameters) { setParameters(parameters, await opts.provideParameters(parameters)); } if (opts.parameterDefaults) { setParameters(parameters, opts.parameterDefaults, true); } if (opts.provideParameterDefaults) { await setProvideParameters( parameters, opts.provideParameterDefaults, true, ); } // validate the parameter names const invalid = Object.keys(parameters).filter( (x) => !(x in templateParams), ); if (invalid.length) { throw new Error(`invalid parameter keys: ${invalid.join(', ')}`); } // make sure we have values for all required parameters const notSet = Object.keys(templateParams).filter( (x) => templateParams[x].Default === undefined && (!(x in parameters) || (parameters[x].ParameterValue === undefined && !parameters[x].UsePreviousValue)), ); if (notSet.length) { throw new Error(`required parameters not set: ${notSet.join(', ')}`); } // create changeset let status = ChangeSetCreateStatus.ChangeSetCreating; this.emit(CloudFormationStackEventKey.ChangeSetCreateStatus, status); const result = await this.cfn.send( new CreateChangeSetCommand({ Capabilities: ['CAPABILITY_NAMED_IAM'], ChangeSetName: opts.name ?? `${this.stackName}-${ template.Metadata.DeployStackVersion }-${Date.now()}`, ChangeSetType: existing ? 'UPDATE' : 'CREATE', Parameters: Object.entries(parameters).map(([key, value]) => stripUndefined({ ParameterKey: key, ParameterValue: value.ParameterValue, UsePreviousValue: value.ParameterValue === undefined && value.UsePreviousValue, }), ), StackName: this.stackName, TemplateURL: `https://${this.templateLocation.S3Bucket}.s3.amazonaws.com/${this.templateLocation.S3Key}`, }), ); if (!result.Id) { throw new Error(`unexpected response`); } const changesetId = result.Id; let changeset: DescribeChangeSetOutput; do { await delay(2000); changeset = await this.cfn.send( new DescribeChangeSetCommand({ ChangeSetName: changesetId, StackName: this.stackName, }), ); switch (changeset.Status) { case 'FAILED': throw new Error( `changeset creation failed: ${ changeset.StatusReason ?? 'unknown error' }`, ); case 'CREATE_IN_PROGRESS': if (status !== ChangeSetCreateStatus.ChangeSetCreateInProgress) { status = ChangeSetCreateStatus.ChangeSetCreateInProgress; this.emit( CloudFormationStackEventKey.ChangeSetCreateStatus, status, changeset, ); } break; case 'CREATE_COMPLETE': status = ChangeSetCreateStatus.ChangeSetCreateComplete; this.emit( CloudFormationStackEventKey.ChangeSetCreateStatus, status, changeset, ); break; } } while (status !== ChangeSetCreateStatus.ChangeSetCreateComplete); this.changeset = changeset; return changeset; } /** * Execute the changeset. */ public async executeChangeSet(): Promise { if (!this.changeset) { throw new Error(`no changeset ready: call createChangeSet first`); } assertCondition( this.changeset.ChangeSetId && this.changeset.StackId && this.changeset.Changes, ); const token = uuid(); await this.cfn.send( new ExecuteChangeSetCommand({ ChangeSetName: this.changeset.ChangeSetId, ClientRequestToken: token, StackName: this.changeset.StackId, }), ); return this.streamEvents(this.changeset.StackId, token); } /** * Stream events for the changeset. */ public async streamEvents(stackId: string, token?: string): Promise { const events = streamChangeSetEvents(this.cfn, stackId, token); for await (const event of events) { this.emit(CloudFormationStackEventKey.ExecuteEvent, event); if (event.LogicalResourceId === event.StackName) { if (event.ResourceStatus?.endsWith('FAILED')) { return false; } if (event.ResourceStatus?.endsWith('COMPLETE')) { return !event.ResourceStatus.includes('ROLLBACK'); } } } // we shouldn't get here, but let's guess it succeeded return true; } /** * Get the existing stack info, if any. */ private async getStack(): Promise { const stackPages = paginateDescribeStacks( { client: this.cfn, }, {}, ); for await (const page of stackPages) { if (!page.Stacks) { return; } for (const stack of page.Stacks) { if ( stack.StackName === this.stackName && !['REVIEW_IN_PROGRESS', 'DELETE_COMPLETE'].includes( stack.StackStatus as string, ) ) { return stack; } } } } /** * Download and validate the template data. */ private async getTemplate(): Promise { if (!this.templateLocation) { throw new Error(`template location has not been set`); } if (this._template) { return this._template; } const progress: DownloadTemplateProgressStats = { location: this.templateLocation, }; this.emit(CloudFormationStackEventKey.DownloadTemplateProgress, progress); const response = await this.s3.send( new GetObjectCommand({ Bucket: this.templateLocation.S3Bucket, Key: this.templateLocation.S3Key, }), ); progress.progress = 0; progress.total = response.ContentLength; this.emit(CloudFormationStackEventKey.DownloadTemplateProgress, progress); const responseStream = response.Body as stream.Readable; const chunks: Buffer[] = []; for await (const chunk of responseStream) { chunks.push(chunk); progress.progress += chunk.length; this.emit(CloudFormationStackEventKey.DownloadTemplateProgress, progress); } progress.complete = true; this.emit(CloudFormationStackEventKey.DownloadTemplateProgress, progress); const template = JSON.parse(Buffer.concat(chunks).toString()); this._template = assert(decodeBaseTemplate, template); return this._template; } }