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

Merged
merged 22 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6857cc9
feat: Add ability to configure hotswap properties for ECS
atanaspam Jun 10, 2024
4100689
clean up unused parameters
atanaspam Jun 10, 2024
e4c0d7d
Fix typo in comments
atanaspam Jul 3, 2024
2f6fc67
improve parameter validation
atanaspam Jul 8, 2024
aaf3f4f
Merge branch 'main' into ecs-hotswap-healthy-percent
rexrafa Jul 8, 2024
36ef283
improve parameter validation
atanaspam Jul 9, 2024
24c15a0
changed to use cdk.json information
rexrafa Jul 25, 2024
6ab3b04
Revert "changed to use cdk.json information"
atanaspam Jul 29, 2024
eb4536d
attempt to read hotswap properties from cdk.json
atanaspam Aug 15, 2024
5a18a33
adding cdk.json override
rexrafa Aug 15, 2024
7a456e6
Merge branch 'main' into ecs-hotswap-healthy-percent
rexrafa Aug 16, 2024
6cde237
cleanup references to cli options
atanaspam Aug 16, 2024
305b920
updated inline comments
atanaspam Sep 3, 2024
7438996
Merge branch 'main' into ecs-hotswap-healthy-percent
atanaspam Sep 20, 2024
13a435d
Fix tests to conform to changes from #31226
atanaspam Sep 20, 2024
1d5a523
address PR feedback
atanaspam Sep 27, 2024
34bd7ff
Merge branch 'main' into ecs-hotswap-healthy-percent
atanaspam Oct 4, 2024
091b6b0
Merge branch 'main' into ecs-hotswap-healthy-percent
comcalvi Oct 10, 2024
1adbf8a
Merge branch 'main' into ecs-hotswap-healthy-percent
comcalvi Oct 10, 2024
7204ab9
Merge branch 'main' into ecs-hotswap-healthy-percent
comcalvi Oct 16, 2024
3a2a777
Merge branch 'main' into ecs-hotswap-healthy-percent
atanaspam Oct 23, 2024
6d9b5c7
Merge branch 'main' into ecs-hotswap-healthy-percent
atanaspam Oct 23, 2024
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 @@ -2301,6 +2301,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 @@ -451,6 +451,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 @@ -173,6 +173,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 @@ -264,6 +269,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 @@ -303,7 +309,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 { determineAllowCrossAccountAssetPublishing } from './util/checks';
import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries, stabilizeStack, uploadStackTemplateAssets } from './util/cloudformation';
Expand Down Expand Up @@ -182,6 +182,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 @@ -498,6 +503,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 @@ -20,7 +20,10 @@ import { CloudFormationStack } from './util/cloudformation';
const pLimit: typeof import('p-limit') = require('p-limit');

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 @@ -62,7 +65,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 @@ -86,7 +89,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 @@ -113,6 +116,7 @@ async function classifyResourceChanges(
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
sdk: ISDK,
nestedStackNames: { [nestedStackName: string]: NestedStackTemplates },
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ClassifiedResourceChanges> {
const resourceDifferences = getStackResourceDifferences(stackChanges);

Expand All @@ -131,7 +135,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 @@ -151,7 +162,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 @@ -233,6 +244,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 @@ -256,7 +268,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 @@ -13,7 +13,7 @@ import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollectio
import { CloudExecutable } from './api/cxapp/cloud-executable';
import { Deployments } from './api/deployments';
import { GarbageCollector } from './api/garbage-collection/garbage-collector';
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 @@ -237,6 +237,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 @@ -347,6 +355,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 @@ -292,6 +292,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,
},
},
unstable: argv.unstable,
});
}
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 @@ -151,7 +151,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 @@ -181,7 +181,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