Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add ability to configure hotswap properties for ECS #30511

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,58 @@ integTest('hotswap deployment supports AppSync APIs with many functions',
}),
);

integTest('hotswap ECS deployment respects properties override', withDefaultFixture(async (fixture) => {
// Update the CDK context with the new ECS properties
let ecsMinimumHealthyPercent = 100;
let ecsMaximumHealthyPercent = 200;
let cdkJson = JSON.parse(await fs.readFile(path.join(fixture.integTestDir, 'cdk.json'), 'utf8'));
cdkJson = {
...cdkJson,
hotswap: {
ecs: {
minimumHealthyPercent: ecsMinimumHealthyPercent,
maximumHealthyPercent: ecsMaximumHealthyPercent,
},
},
};

await fs.writeFile(path.join(fixture.integTestDir, 'cdk.json'), JSON.stringify(cdkJson));

// GIVEN
const stackArn = await fixture.cdkDeploy('ecs-hotswap', {
captureStderr: false,
});

// WHEN
await fixture.cdkDeploy('ecs-hotswap', {
options: [
'--hotswap',
],
modEnv: {
DYNAMIC_ECS_PROPERTY_VALUE: 'new value',
},
});

const describeStacksResponse = await fixture.aws.cloudFormation.send(
new DescribeStacksCommand({
StackName: stackArn,
}),
);

const clusterName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ClusterName')?.OutputValue!;
const serviceName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ServiceName')?.OutputValue!;

// THEN
const describeServicesResponse = await fixture.aws.ecs.send(
new DescribeServicesCommand({
cluster: clusterName,
services: [serviceName],
}),
);
expect(describeServicesResponse.services?.[0].deploymentConfiguration?.minimumHealthyPercent).toEqual(ecsMinimumHealthyPercent);
expect(describeServicesResponse.services?.[0].deploymentConfiguration?.maximumPercent).toEqual(ecsMaximumHealthyPercent);
}));

async function listChildren(parent: string, pred: (x: string) => Promise<boolean>) {
const ret = new Array<string>();
for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) {
Expand Down
13 changes: 13 additions & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,19 @@ Hotswapping is currently supported for the following changes
- VTL mapping template changes for AppSync Resolvers and Functions.
- Schema changes for AppSync GraphQL Apis.

You can optionally configure the behavior of your hotswap deployments in `cdk.json`. Currently you can only configure ECS hotswap behavior:

```json
{
"hotswap": {
"ecs": {
"minimumHealthyPercent": 100,
"maximumHealthyPercent": 250
}
}
}
```

**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
For this reason, only use it for development purposes.
**Never use this flag for your production deployments**!
Expand Down
10 changes: 8 additions & 2 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as uuid from 'uuid';
import { ISDK, SdkProvider } from './aws-auth';
import { EnvironmentResources } from './environment-resources';
import { CfnEvaluationException } from './evaluate-cloudformation-template';
import { HotswapMode, ICON } from './hotswap/common';
import { HotswapMode, HotswapPropertyOverrides, ICON } from './hotswap/common';
import { tryHotswapDeployment } from './hotswap-deployments';
import { addMetadataAssetsToManifest } from '../assets';
import { Tag } from '../cdk-toolkit';
Expand Down Expand Up @@ -172,6 +172,11 @@ export interface DeployStackOptions {
*/
readonly hotswap?: HotswapMode;

/**
* Extra properties that configure hotswap behavior
*/
readonly hotswapPropertyOverrides?: HotswapPropertyOverrides;

/**
* The extra string to append to the User-Agent header when performing AWS SDK calls.
*
Expand Down Expand Up @@ -263,6 +268,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
: templateParams.supplyAll(finalParameterValues);

const hotswapMode = options.hotswap ?? HotswapMode.FULL_DEPLOYMENT;
const hotswapPropertyOverrides = options.hotswapPropertyOverrides ?? new HotswapPropertyOverrides();

if (await canSkipDeploy(options, cloudFormationStack, stackParams.hasChanges(cloudFormationStack.parameters))) {
debug(`${deployName}: skipping deployment (use --force to override)`);
Expand Down Expand Up @@ -295,7 +301,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
// attempt to short-circuit the deployment if possible
try {
const hotswapDeploymentResult = await tryHotswapDeployment(
options.sdkProvider, stackParams.values, cloudFormationStack, stackArtifact, hotswapMode,
options.sdkProvider, stackParams.values, cloudFormationStack, stackArtifact, hotswapMode, hotswapPropertyOverrides,
);
if (hotswapDeploymentResult) {
return hotswapDeploymentResult;
Expand Down
8 changes: 7 additions & 1 deletion packages/aws-cdk/lib/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ISDK } from './aws-auth/sdk';
import { CredentialsOptions, SdkForEnvironment, SdkProvider } from './aws-auth/sdk-provider';
import { deployStack, DeployStackResult, destroyStack, DeploymentMethod } from './deploy-stack';
import { EnvironmentResources, EnvironmentResourcesRegistry } from './environment-resources';
import { HotswapMode } from './hotswap/common';
import { HotswapMode, HotswapPropertyOverrides } from './hotswap/common';
import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, RootTemplateWithNestedStacks } from './nested-stack-helpers';
import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries, stabilizeStack } from './util/cloudformation';
import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
Expand Down Expand Up @@ -181,6 +181,11 @@ export interface DeployStackOptions {
*/
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.
*
Expand Down Expand Up @@ -488,6 +493,7 @@ export class Deployments {
ci: options.ci,
rollback: options.rollback,
hotswap: options.hotswap,
hotswapPropertyOverrides: options.hotswapPropertyOverrides,
extraUserAgent: options.extraUserAgent,
resourcesToImport: options.resourcesToImport,
overrideTemplate: options.overrideTemplate,
Expand Down
31 changes: 24 additions & 7 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-templa
import { print } from '../logging';
import { isHotswappableAppSyncChange } from './hotswap/appsync-mapping-templates';
import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects';
import { ICON, ChangeHotswapResult, HotswapMode, HotswappableChange, NonHotswappableChange, HotswappableChangeCandidate, ClassifiedResourceChanges, reportNonHotswappableChange, reportNonHotswappableResource } from './hotswap/common';
import { ICON, ChangeHotswapResult, HotswapMode, HotswappableChange, NonHotswappableChange, HotswappableChangeCandidate, HotswapPropertyOverrides, ClassifiedResourceChanges, reportNonHotswappableChange, reportNonHotswappableResource } from './hotswap/common';
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { skipChangeForS3DeployCustomResourcePolicy, isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
Expand All @@ -16,7 +16,10 @@ import { NestedStackTemplates, loadCurrentTemplateWithNestedStacks } from './nes
import { CloudFormationStack } from './util/cloudformation';

type HotswapDetector = (
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate
logicalId: string,
change: HotswappableChangeCandidate,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
hotswapPropertyOverrides: HotswapPropertyOverrides,
) => Promise<ChangeHotswapResult>;

const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = {
Expand Down Expand Up @@ -58,7 +61,7 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = {
export async function tryHotswapDeployment(
sdkProvider: SdkProvider, assetParams: { [key: string]: string },
cloudFormationStack: CloudFormationStack, stackArtifact: cxapi.CloudFormationStackArtifact,
hotswapMode: HotswapMode,
hotswapMode: HotswapMode, hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<DeployStackResult | undefined> {
// resolve the environment, so we can substitute things like AWS::Region in CFN expressions
const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment);
Expand All @@ -82,7 +85,7 @@ export async function tryHotswapDeployment(

const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stackArtifact.template);
const { hotswappableChanges, nonHotswappableChanges } = await classifyResourceChanges(
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks,
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks, hotswapPropertyOverrides,
);

logNonHotswappableChanges(nonHotswappableChanges, hotswapMode);
Expand All @@ -109,6 +112,7 @@ async function classifyResourceChanges(
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
sdk: ISDK,
nestedStackNames: { [nestedStackName: string]: NestedStackTemplates },
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ClassifiedResourceChanges> {
const resourceDifferences = getStackResourceDifferences(stackChanges);

Expand All @@ -127,7 +131,14 @@ async function classifyResourceChanges(
// gather the results of the detector functions
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
if (change.newValue?.Type === 'AWS::CloudFormation::Stack' && change.oldValue?.Type === 'AWS::CloudFormation::Stack') {
const nestedHotswappableResources = await findNestedHotswappableChanges(logicalId, change, nestedStackNames, evaluateCfnTemplate, sdk);
const nestedHotswappableResources = await findNestedHotswappableChanges(
logicalId,
change,
nestedStackNames,
evaluateCfnTemplate,
sdk,
hotswapPropertyOverrides,
);
hotswappableResources.push(...nestedHotswappableResources.hotswappableChanges);
nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappableChanges);

Expand All @@ -147,7 +158,7 @@ async function classifyResourceChanges(
const resourceType: string = hotswappableChangeCandidate.newValue.Type;
if (resourceType in RESOURCE_DETECTORS) {
// run detector functions lazily to prevent unhandled promise rejections
promises.push(() => RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate));
promises.push(() => RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate, hotswapPropertyOverrides));
} else {
reportNonHotswappableChange(nonHotswappableResources, hotswappableChangeCandidate, undefined, 'This resource type is not supported for hotswap deployments');
}
Expand Down Expand Up @@ -227,6 +238,7 @@ async function findNestedHotswappableChanges(
nestedStackTemplates: { [nestedStackName: string]: NestedStackTemplates },
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
sdk: ISDK,
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ClassifiedResourceChanges> {
const nestedStack = nestedStackTemplates[logicalId];
if (!nestedStack.physicalName) {
Expand All @@ -250,7 +262,12 @@ async function findNestedHotswappableChanges(
nestedStackTemplates[logicalId].deployedTemplate, nestedStackTemplates[logicalId].generatedTemplate,
);

return classifyResourceChanges(nestedDiff, evaluateNestedCfnTemplate, sdk, nestedStackTemplates[logicalId].nestedStackTemplates);
return classifyResourceChanges(
nestedDiff,
evaluateNestedCfnTemplate,
sdk,
nestedStackTemplates[logicalId].nestedStackTemplates,
hotswapPropertyOverrides);
}

/** Returns 'true' if a pair of changes is for the same resource. */
Expand Down
46 changes: 46 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,52 @@ export class HotswappableChangeCandidate {

type Exclude = { [key: string]: Exclude | true }

/**
* Represents configuration property overrides for hotswap deployments
*/
export class HotswapPropertyOverrides {
// Each supported resource type will have its own properties. Currently this is ECS
ecsHotswapProperties?: EcsHotswapProperties;

public constructor (ecsHotswapProperties?: EcsHotswapProperties) {
this.ecsHotswapProperties = ecsHotswapProperties;
}
}

/**
* Represents configuration properties for ECS hotswap deployments
*/
export class EcsHotswapProperties {
// The lower limit on the number of your service's tasks that must remain in the RUNNING state during a deployment, as a percentage of the desiredCount
readonly minimumHealthyPercent?: number;
// The upper limit on the number of your service's tasks that are allowed in the RUNNING or PENDING state during a deployment, as a percentage of the desiredCount
readonly maximumHealthyPercent?: number;

public constructor (minimumHealthyPercent?: number, maximumHealthyPercent?: number) {
if (minimumHealthyPercent !== undefined && minimumHealthyPercent < 0 ) {
throw new Error('hotswap-ecs-minimum-healthy-percent can\'t be a negative number');
}
if (maximumHealthyPercent !== undefined && maximumHealthyPercent < 0 ) {
throw new Error('hotswap-ecs-maximum-healthy-percent can\'t be a negative number');
}
// In order to preserve the current behaviour, when minimumHealthyPercent is not defined, it will be set to the currently default value of 0
if (minimumHealthyPercent == undefined) {
this.minimumHealthyPercent = 0;
} else {
this.minimumHealthyPercent = minimumHealthyPercent;
}
this.maximumHealthyPercent = maximumHealthyPercent;
}

/**
* Check if any hotswap properties are defined
* @returns true if all properties are undefined, false otherwise
*/
public isEmpty(): boolean {
return this.minimumHealthyPercent === 0 && this.maximumHealthyPercent === undefined;
}
}

/**
* This function transforms all keys (recursively) in the provided `val` object.
*
Expand Down
14 changes: 11 additions & 3 deletions packages/aws-cdk/lib/api/hotswap/ecs-services.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as AWS from 'aws-sdk';
import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common';
import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, HotswapPropertyOverrides, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common';
import { ISDK } from '../aws-auth';
import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template';

export async function isHotswappableEcsServiceChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
logicalId: string,
change: HotswappableChangeCandidate,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ChangeHotswapResult> {
// the only resource change we can evaluate here is an ECS TaskDefinition
if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') {
Expand Down Expand Up @@ -83,6 +86,10 @@ export async function isHotswappableEcsServiceChange(
const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise();
const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn;

let ecsHotswapProperties = hotswapPropertyOverrides.ecsHotswapProperties;
let minimumHealthyPercent = ecsHotswapProperties?.minimumHealthyPercent;
let maximumHealthyPercent = ecsHotswapProperties?.maximumHealthyPercent;

// Step 2 - update the services using that TaskDefinition to point to the new TaskDefinition Revision
const servicePerClusterUpdates: { [cluster: string]: Array<{ promise: Promise<any>; ecsService: EcsService }> } = {};
for (const ecsService of ecsServicesReferencingTaskDef) {
Expand All @@ -105,7 +112,8 @@ export async function isHotswappableEcsServiceChange(
cluster: clusterName,
forceNewDeployment: true,
deploymentConfiguration: {
minimumHealthyPercent: 0,
minimumHealthyPercent: minimumHealthyPercent !== undefined ? minimumHealthyPercent : 0,
maximumPercent: maximumHealthyPercent !== undefined ? maximumHealthyPercent : undefined,
},
}).promise(),
ecsService: ecsService,
Expand Down
11 changes: 10 additions & 1 deletion packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Bootstrapper, BootstrapEnvironmentOptions } from './api/bootstrap';
import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollection, StackSelector } from './api/cxapp/cloud-assembly';
import { CloudExecutable } from './api/cxapp/cloud-executable';
import { Deployments } from './api/deployments';
import { HotswapMode } from './api/hotswap/common';
import { HotswapMode, HotswapPropertyOverrides, EcsHotswapProperties } from './api/hotswap/common';
import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs';
import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor';
import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformation';
Expand Down Expand Up @@ -232,6 +232,14 @@ export class CdkToolkit {
warning('⚠️ They should only be used for development - never use them for your production Stacks!\n');
}

let hotswapPropertiesFromSettings = this.props.configuration.settings.get(['hotswap']) || {};

let hotswapPropertyOverrides = new HotswapPropertyOverrides();
hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties(
hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent,
hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent,
);

const stacks = stackCollection.stackArtifacts;

const stackOutputs: { [key: string]: any } = { };
Expand Down Expand Up @@ -344,6 +352,7 @@ export class CdkToolkit {
ci: options.ci,
rollback: options.rollback,
hotswap: options.hotswap,
hotswapPropertyOverrides: hotswapPropertyOverrides,
extraUserAgent: options.extraUserAgent,
assetParallelism: options.assetParallelism,
ignoreNoStacks: options.ignoreNoStacks,
Expand Down
6 changes: 6 additions & 0 deletions packages/aws-cdk/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ export class Settings {
assetParallelism: argv['asset-parallelism'],
assetPrebuild: argv['asset-prebuild'],
ignoreNoStacks: argv['ignore-no-stacks'],
hotswap: {
ecs: {
minimumEcsHealthyPercent: argv.minimumEcsHealthyPercent,
maximumEcsHealthyPercent: argv.maximumEcsHealthyPercent,
},
},
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/aws-cdk/test/api/deploy-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ test('correctly passes CFN parameters when hotswapping', async () => {
});

// THEN
expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { A: 'A-value', B: 'B=value' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK);
expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { A: 'A-value', B: 'B=value' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK, expect.anything());
});

test('correctly passes SSM parameters when hotswapping', async () => {
Expand Down Expand Up @@ -178,7 +178,7 @@ test('correctly passes SSM parameters when hotswapping', async () => {
});

// THEN
expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { SomeParameter: 'SomeValue' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK);
expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { SomeParameter: 'SomeValue' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK, expect.anything());
});

test('call CreateStack when method=direct and the stack doesnt exist yet', async () => {
Expand Down
Loading