From 498be266c0d5bd30254f55999038ad9973d56d88 Mon Sep 17 00:00:00 2001 From: Matthew Hawkins Date: Thu, 14 Mar 2024 18:16:51 -0700 Subject: [PATCH] feat(appconfig): Constrain environments to a single deployment at a time [Issue #29345] ------------------------------------------------------------------------ [Reason for this change] ------------------------------------------------------------------------ The current L2 AppConfig constructs do not have any guardrails that prevent simultaneous Deployments to a single Environment. This is not allowed, and will result in Cfn deploy-time conflicts. [Description of changes] ------------------------------------------------------------------------ This commit adds a pair of new public methods to IEnvironment that enable the addition of a new Deployment for a given IConfiguration. It then updates the creation of new Deployments in ConfigurationBase to utilize these new methods instead of the current resource creation. These new methods interact with an internal queue. This queue creates a chain of Cfn dependencies between Deployments in order to enforce that only a single Deployment can be in progress for the Environment at any given time. [Description of how you validated changes] ------------------------------------------------------------------------ Added new unit test coverage. --- .../aws-appconfig/lib/configuration.ts | 13 +- .../aws-appconfig/lib/environment.ts | 45 +++- .../aws-appconfig/test/environment.test.ts | 212 +++++++++++++++++- 3 files changed, 257 insertions(+), 13 deletions(-) diff --git a/packages/aws-cdk-lib/aws-appconfig/lib/configuration.ts b/packages/aws-cdk-lib/aws-appconfig/lib/configuration.ts index a974aae0c4203..c0b91fcd51cc4 100644 --- a/packages/aws-cdk-lib/aws-appconfig/lib/configuration.ts +++ b/packages/aws-cdk-lib/aws-appconfig/lib/configuration.ts @@ -2,12 +2,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { Construct, IConstruct } from 'constructs'; -import { CfnConfigurationProfile, CfnDeployment, CfnHostedConfigurationVersion } from './appconfig.generated'; +import { CfnConfigurationProfile, CfnHostedConfigurationVersion } from './appconfig.generated'; import { IApplication } from './application'; import { DeploymentStrategy, IDeploymentStrategy, RolloutStrategy } from './deployment-strategy'; import { IEnvironment } from './environment'; import { ActionPoint, IEventDestination, ExtensionOptions, IExtension, IExtensible, ExtensibleBase } from './extension'; -import { getHash } from './private/hash'; import * as cp from '../../aws-codepipeline'; import * as iam from '../../aws-iam'; import * as kms from '../../aws-kms'; @@ -319,15 +318,7 @@ abstract class ConfigurationBase extends Construct implements IConfiguration, IE if ((this.deployTo && !this.deployTo.includes(environment))) { return; } - new CfnDeployment(this, `Deployment${getHash(environment.name!)}`, { - applicationId: this.application.applicationId, - configurationProfileId: this.configurationProfileId, - deploymentStrategyId: this.deploymentStrategy!.deploymentStrategyId, - environmentId: environment.environmentId, - configurationVersion: this.versionNumber!, - description: this.description, - kmsKeyIdentifier: this.deploymentKey?.keyArn, - }); + environment.addDeployment(this); }); } } diff --git a/packages/aws-cdk-lib/aws-appconfig/lib/environment.ts b/packages/aws-cdk-lib/aws-appconfig/lib/environment.ts index 437f267c1b366..57b22be765cf0 100644 --- a/packages/aws-cdk-lib/aws-appconfig/lib/environment.ts +++ b/packages/aws-cdk-lib/aws-appconfig/lib/environment.ts @@ -1,6 +1,7 @@ import { Construct } from 'constructs'; -import { CfnEnvironment } from './appconfig.generated'; +import { CfnDeployment, CfnEnvironment } from './appconfig.generated'; import { IApplication } from './application'; +import { IConfiguration } from './configuration'; import { ActionPoint, IEventDestination, ExtensionOptions, IExtension, IExtensible, ExtensibleBase } from './extension'; import { getHash } from './private/hash'; import * as cloudwatch from '../../aws-cloudwatch'; @@ -47,7 +48,31 @@ abstract class EnvironmentBase extends Resource implements IEnvironment, IExtens public abstract applicationId: string; public abstract environmentId: string; public abstract environmentArn: string; + public abstract name?: string | undefined; protected extensible!: ExtensibleBase; + protected deploymentQueue: Array = []; + + public addDeployment(configuration: IConfiguration): void { + const queueSize = this.deploymentQueue.push( + new CfnDeployment(configuration, `Deployment${getHash(this.name!)}`, { + applicationId: configuration.application.applicationId, + configurationProfileId: configuration.configurationProfileId, + deploymentStrategyId: configuration.deploymentStrategy!.deploymentStrategyId, + environmentId: this.environmentId, + configurationVersion: configuration.versionNumber!, + description: configuration.description, + kmsKeyIdentifier: configuration.deploymentKey?.keyArn, + }), + ); + + if (queueSize > 1) { + this.deploymentQueue[queueSize - 1].addDependency(this.deploymentQueue[queueSize - 2]); + } + } + + public addDeployments(...configurations: IConfiguration[]): void { + configurations.forEach((config) => this.addDeployment(config)); + } public on(actionPoint: ActionPoint, eventDestination: IEventDestination, options?: ExtensionOptions) { this.extensible.on(actionPoint, eventDestination, options); @@ -154,6 +179,7 @@ export class Environment extends EnvironmentBase { public readonly applicationId = applicationId; public readonly environmentId = environmentId; public readonly environmentArn = environmentArn; + public readonly name?: string | undefined; } return new Import(scope, id, { @@ -413,6 +439,23 @@ export interface IEnvironment extends IResource { */ readonly environmentArn: string; + /** + * Creates a deployment of the supplied configuration to this environment. + * Note that you can only deploy one configuration at a time to an environment. + * However, you can deploy one configuration each to different environments at the same time. + * If more than one deployment is requested for this environment, they will occur in the same order they were provided. + * + * @param configuration The configuration that will be deployed to this environment. + */ + addDeployment(configuration: IConfiguration): void; + + /** + * Creates a deployment for each of the supplied configurations to this environment. + * + * @param configurations The configurations that will be deployed to this environment. + */ + addDeployments(...configurations: Array): void; + /** * Adds an extension defined by the action point and event destination and also * creates an extension association to the environment. diff --git a/packages/aws-cdk-lib/aws-appconfig/test/environment.test.ts b/packages/aws-cdk-lib/aws-appconfig/test/environment.test.ts index dd2e5e278482f..2cbe0fa159c6d 100644 --- a/packages/aws-cdk-lib/aws-appconfig/test/environment.test.ts +++ b/packages/aws-cdk-lib/aws-appconfig/test/environment.test.ts @@ -2,7 +2,7 @@ import { Template } from '../../assertions'; import { Alarm, CompositeAlarm, Metric } from '../../aws-cloudwatch'; import * as iam from '../../aws-iam'; import * as cdk from '../../core'; -import { Application, Environment, Monitor } from '../lib'; +import { Application, ConfigurationContent, Environment, HostedConfiguration, Monitor } from '../lib'; describe('environment', () => { test('default environment', () => { @@ -54,6 +54,216 @@ describe('environment', () => { }); }); + test('environment with single deployment', () => { + const stack = new cdk.Stack(); + const application = new Application(stack, 'MyAppConfig'); + const env = new Environment(stack, 'MyEnvironment', { + application, + }); + + const firstConfig = new HostedConfiguration(stack, 'FirstConfig', { + application, + content: ConfigurationContent.fromInlineText('This is my content 1'), + }); + env.addDeployment(firstConfig); + + const actual = Template.fromStack(stack); + + actual.hasResourceProperties('AWS::AppConfig::Environment', { + Name: 'MyEnvironment', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + }); + + actual.hasResourceProperties('AWS::AppConfig::ConfigurationProfile', { + Name: 'FirstConfig', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + LocationUri: 'hosted', + }); + actual.hasResourceProperties('AWS::AppConfig::HostedConfigurationVersion', { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + ConfigurationProfileId: { + Ref: 'FirstConfigConfigurationProfileDEF37C63', + }, + Content: 'This is my content 1', + ContentType: 'text/plain', + }); + actual.hasResource('AWS::AppConfig::Deployment', { + Properties: { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + EnvironmentId: { + Ref: 'MyEnvironment465E4DEA', + }, + ConfigurationVersion: { + Ref: 'FirstConfigC35E996C', + }, + ConfigurationProfileId: { + Ref: 'FirstConfigConfigurationProfileDEF37C63', + }, + DeploymentStrategyId: { + Ref: 'FirstConfigDeploymentStrategy863BBA9A', + }, + }, + }); + + actual.resourceCountIs('AWS::AppConfig::Deployment', 1); + }); + + test('environment with multiple deployments', () => { + const stack = new cdk.Stack(); + const application = new Application(stack, 'MyAppConfig'); + const env = new Environment(stack, 'MyEnvironment', { + application, + }); + + const firstConfig = new HostedConfiguration(stack, 'FirstConfig', { + application, + content: ConfigurationContent.fromInlineText('This is my content 1'), + }); + const secondConfig = new HostedConfiguration(stack, 'SecondConfig', { + application, + content: ConfigurationContent.fromInlineText('This is my content 2'), + }); + const thirdConfig = new HostedConfiguration(stack, 'ThirdConfig', { + application, + content: ConfigurationContent.fromInlineText('This is my content 3'), + }); + + env.addDeployments(firstConfig, secondConfig); + env.addDeployment(thirdConfig); + + const actual = Template.fromStack(stack); + + actual.hasResourceProperties('AWS::AppConfig::Environment', { + Name: 'MyEnvironment', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + }); + + actual.hasResourceProperties('AWS::AppConfig::ConfigurationProfile', { + Name: 'FirstConfig', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + LocationUri: 'hosted', + }); + actual.hasResourceProperties('AWS::AppConfig::HostedConfigurationVersion', { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + ConfigurationProfileId: { + Ref: 'FirstConfigConfigurationProfileDEF37C63', + }, + Content: 'This is my content 1', + ContentType: 'text/plain', + }); + actual.hasResource('AWS::AppConfig::Deployment', { + Properties: { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + EnvironmentId: { + Ref: 'MyEnvironment465E4DEA', + }, + ConfigurationVersion: { + Ref: 'FirstConfigC35E996C', + }, + ConfigurationProfileId: { + Ref: 'FirstConfigConfigurationProfileDEF37C63', + }, + DeploymentStrategyId: { + Ref: 'FirstConfigDeploymentStrategy863BBA9A', + }, + }, + }); + + actual.hasResourceProperties('AWS::AppConfig::ConfigurationProfile', { + Name: 'SecondConfig', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + LocationUri: 'hosted', + }); + actual.hasResourceProperties('AWS::AppConfig::HostedConfigurationVersion', { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + ConfigurationProfileId: { + Ref: 'SecondConfigConfigurationProfileE64FE7B4', + }, + Content: 'This is my content 2', + ContentType: 'text/plain', + }); + actual.hasResource('AWS::AppConfig::Deployment', { + Properties: { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + EnvironmentId: { + Ref: 'MyEnvironment465E4DEA', + }, + ConfigurationVersion: { + Ref: 'SecondConfig22E40AAE', + }, + ConfigurationProfileId: { + Ref: 'SecondConfigConfigurationProfileE64FE7B4', + }, + DeploymentStrategyId: { + Ref: 'SecondConfigDeploymentStrategy9929738B', + }, + }, + DependsOn: ['FirstConfigDeployment52928BE68587B'], + }); + + actual.hasResourceProperties('AWS::AppConfig::ConfigurationProfile', { + Name: 'ThirdConfig', + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + LocationUri: 'hosted', + }); + actual.hasResourceProperties('AWS::AppConfig::HostedConfigurationVersion', { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + ConfigurationProfileId: { + Ref: 'ThirdConfigConfigurationProfile4945C970', + }, + Content: 'This is my content 3', + ContentType: 'text/plain', + }); + actual.hasResource('AWS::AppConfig::Deployment', { + Properties: { + ApplicationId: { + Ref: 'MyAppConfigB4B63E75', + }, + EnvironmentId: { + Ref: 'MyEnvironment465E4DEA', + }, + ConfigurationVersion: { + Ref: 'ThirdConfig498595D6', + }, + ConfigurationProfileId: { + Ref: 'ThirdConfigConfigurationProfile4945C970', + }, + DeploymentStrategyId: { + Ref: 'ThirdConfigDeploymentStrategy246FBD1A', + }, + }, + DependsOn: ['SecondConfigDeployment5292843F35B55'], + }); + + actual.resourceCountIs('AWS::AppConfig::Deployment', 3); + }); + test('environment with monitors with alarm and alarmRole', () => { const stack = new cdk.Stack(); const app = new Application(stack, 'MyAppConfig');