Skip to content

Commit

Permalink
feat(cli): hotswap deployments for StepFunctions State Machines (#16489)
Browse files Browse the repository at this point in the history
This adds support for `StepFunctions::StateMachines` to be hotswapped. Only changes to the `DefinitionString` property will trigger hotswaps. Changes to other properties (or resources, except Lambda functions) will require full deployments.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
comcalvi authored and njlynch committed Oct 11, 2021
1 parent f43c8df commit b885fef
Show file tree
Hide file tree
Showing 11 changed files with 1,173 additions and 141 deletions.
1 change: 1 addition & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ Hotswapping is currently supported for the following changes
(additional changes will be supported in the future):

- Code asset changes of AWS Lambda functions.
- Definition changes of AWS Step Functions State Machines.

**⚠ 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.
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ISDK {
elbv2(): AWS.ELBv2;
secretsManager(): AWS.SecretsManager;
kms(): AWS.KMS;
stepFunctions(): AWS.StepFunctions;
}

/**
Expand Down Expand Up @@ -128,6 +129,10 @@ export class SDK implements ISDK {
return this.wrapServiceErrorHandling(new AWS.KMS(this.config));
}

public stepFunctions(): AWS.StepFunctions {
return this.wrapServiceErrorHandling(new AWS.StepFunctions(this.config));
}

public async currentAccount(): Promise<Account> {
// Get/refresh if necessary before we can access `accessKeyId`
await this.forceCredentialRetrieval();
Expand Down
87 changes: 72 additions & 15 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import * as cxapi from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import { ISDK, Mode, SdkProvider } from './aws-auth';
import { DeployStackResult } from './deploy-stack';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, ListStackResources } from './hotswap/common';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, ListStackResources, HotswappableChangeCandidate } from './hotswap/common';
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
import { CloudFormationStack } from './util/cloudformation';

/**
Expand Down Expand Up @@ -57,24 +58,80 @@ export async function tryHotswapDeployment(
async function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapOperation[] | undefined> {
const promises = new Array<Promise<ChangeHotswapResult>>();
stackChanges.resources.forEachDifference(async (logicalId: string, change: cfn_diff.ResourceDifference) => {
promises.push(isHotswappableLambdaFunctionChange(logicalId, change, evaluateCfnTemplate));
let foundNonHotswappableChange = false;
const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];

// gather the results of the detector functions
stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => {
const resourceHotswapEvaluation = isCandidateForHotswapping(change);

if (resourceHotswapEvaluation === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
} else if (resourceHotswapEvaluation === ChangeHotswapImpact.IRRELEVANT) {
// empty 'if' just for flow-aware typing to kick in...
} else {
promises.push([
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
return Promise.all(promises).then(hotswapDetectionResults => {
const hotswappableResources = new Array<HotswapOperation>();
let foundNonHotswappableChange = false;
for (const lambdaFunctionShortCircuitChange of hotswapDetectionResults) {
if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {

const changesDetectionResults: Array<Array<ChangeHotswapResult>> = [];
for (const detectorResultPromises of promises) {
const hotswapDetectionResults = await Promise.all(detectorResultPromises);
changesDetectionResults.push(hotswapDetectionResults);
}

const hotswappableResources = new Array<HotswapOperation>();

// resolve all detector results
for (const hotswapDetectionResults of changesDetectionResults) {
const perChangeHotswappableResources = new Array<HotswapOperation>();

for (const result of hotswapDetectionResults) {
if (typeof result !== 'string') {
perChangeHotswappableResources.push(result);
}
}

// if we found any hotswappable changes, return now
if (perChangeHotswappableResources.length > 0) {
hotswappableResources.push(...perChangeHotswappableResources);
continue;
}

// no hotswappable changes found, so any REQUIRES_FULL_DEPLOYMENTs imply a non-hotswappable change
for (const result of hotswapDetectionResults) {
if (result === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
} else if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.IRRELEVANT) {
// empty 'if' just for flow-aware typing to kick in...
} else {
hotswappableResources.push(lambdaFunctionShortCircuitChange);
}
}
return foundNonHotswappableChange ? undefined : hotswappableResources;
});
// no REQUIRES_FULL_DEPLOYMENT implies that all results are IRRELEVANT
}

return foundNonHotswappableChange ? undefined : hotswappableResources;
}

/**
* returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if a resource was deleted, or a change that we cannot short-circuit occured.
* Returns `ChangeHotswapImpact.IRRELEVANT` if a change that does not impact shortcircuiting occured, such as a metadata change.
*/
export function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
// a resource has been removed OR a resource has been added; we can't short-circuit that change
if (!change.newValue || !change.oldValue) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

// Ignore Metadata changes
if (change.newValue.Type === 'AWS::CDK::Metadata') {
return ChangeHotswapImpact.IRRELEVANT;
}

return {
newValue: change.newValue,
propertyUpdates: change.propertyUpdates,
};
}

async function applyAllHotswappableChanges(
Expand Down
40 changes: 39 additions & 1 deletion packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import { CloudFormation } from 'aws-sdk';
import { ISDK } from '../aws-auth';
import { CfnEvaluationException, EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

export interface ListStackResources {
listStackResources(): Promise<CloudFormation.StackResourceSummary[]>;
Expand Down Expand Up @@ -33,6 +34,43 @@ export enum ChangeHotswapImpact {

export type ChangeHotswapResult = HotswapOperation | ChangeHotswapImpact;

export function assetMetadataChanged(change: cfn_diff.ResourceDifference): boolean {
/**
* Represents a change that can be hotswapped.
*/
export class HotswappableChangeCandidate {
/**
* The value the resource is being updated to.
*/
public readonly newValue: cfn_diff.Resource;

/**
* The changes made to the resource properties.
*/
public readonly propertyUpdates: { [key: string]: cfn_diff.PropertyDifference<any> };

public constructor(newValue: cfn_diff.Resource, propertyUpdates: { [key: string]: cfn_diff.PropertyDifference<any> }) {
this.newValue = newValue;
this.propertyUpdates = propertyUpdates;
}
}

export async function establishResourcePhysicalName(
logicalId: string, physicalNameInCfnTemplate: any, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<string | undefined> {
if (physicalNameInCfnTemplate != null) {
try {
return await evaluateCfnTemplate.evaluateCfnExpression(physicalNameInCfnTemplate);
} catch (e) {
// If we can't evaluate the resource's name CloudFormation expression,
// just look it up in the currently deployed Stack
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
}
}
return evaluateCfnTemplate.findPhysicalNameFor(logicalId);
}

export function assetMetadataChanged(change: HotswappableChangeCandidate): boolean {
return !!change.newValue?.Metadata['aws:asset:path'];
}
49 changes: 7 additions & 42 deletions packages/aws-cdk/lib/api/hotswap/lambda-functions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import { ISDK } from '../aws-auth';
import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation } from './common';
import { CfnEvaluationException, EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

/**
* Returns `false` if the change cannot be short-circuited,
Expand All @@ -10,7 +9,7 @@ import { CfnEvaluationException, EvaluateCloudFormationTemplate } from './evalua
* or a LambdaFunctionResource if the change can be short-circuited.
*/
export async function isHotswappableLambdaFunctionChange(
logicalId: string, change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
const lambdaCodeChange = await isLambdaFunctionCodeOnlyChange(change, evaluateCfnTemplate);
if (typeof lambdaCodeChange === 'string') {
Expand All @@ -24,7 +23,7 @@ export async function isHotswappableLambdaFunctionChange(
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const functionName = await establishFunctionPhysicalName(logicalId, change, evaluateCfnTemplate);
const functionName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.FunctionName, evaluateCfnTemplate);
if (!functionName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
Expand All @@ -37,34 +36,21 @@ export async function isHotswappableLambdaFunctionChange(
}

/**
* Returns `true` if the change is not for a AWS::Lambda::Function,
* Returns `ChangeHotswapImpact.IRRELEVANT` if the change is not for a AWS::Lambda::Function,
* but doesn't prevent short-circuiting
* (like a change to CDKMetadata resource),
* `false` if the change is to a AWS::Lambda::Function,
* `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if the change is to a AWS::Lambda::Function,
* but not only to its Code property,
* or a LambdaFunctionCode if the change is to a AWS::Lambda::Function,
* and only affects its Code property.
*/
async function isLambdaFunctionCodeOnlyChange(
change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<LambdaFunctionCode | ChangeHotswapImpact> {
if (!change.newValue) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
const newResourceType = change.newValue.Type;
// Ignore Metadata changes
if (newResourceType === 'AWS::CDK::Metadata') {
return ChangeHotswapImpact.IRRELEVANT;
}
// The only other resource change we should see is a Lambda function
if (newResourceType !== 'AWS::Lambda::Function') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
if (change.oldValue?.Type == null) {
// this means this is a brand-new Lambda function -
// obviously, we can't short-circuit that!
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

/*
* On first glance, we would want to initialize these using the "previous" values (change.oldValue),
Expand All @@ -83,9 +69,6 @@ async function isLambdaFunctionCodeOnlyChange(
const propertyUpdates = change.propertyUpdates;
for (const updatedPropName in propertyUpdates) {
const updatedProp = propertyUpdates[updatedPropName];
if (updatedProp.newValue === undefined) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
for (const newPropName in updatedProp.newValue) {
switch (newPropName) {
case 'S3Bucket':
Expand Down Expand Up @@ -132,21 +115,3 @@ class LambdaFunctionHotswapOperation implements HotswapOperation {
}).promise();
}
}

async function establishFunctionPhysicalName(
logicalId: string, change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<string | undefined> {
const functionNameInCfnTemplate = change.newValue?.Properties?.FunctionName;
if (functionNameInCfnTemplate != null) {
try {
return await evaluateCfnTemplate.evaluateCfnExpression(functionNameInCfnTemplate);
} catch (e) {
// If we can't evaluate the function's name CloudFormation expression,
// just look it up in the currently deployed Stack
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
}
}
return evaluateCfnTemplate.findPhysicalNameFor(logicalId);
}
62 changes: 62 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

export async function isHotswappableStateMachineChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
const stateMachineDefinitionChange = await isStateMachineDefinitionOnlyChange(change, evaluateCfnTemplate);
if (stateMachineDefinitionChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT ||
stateMachineDefinitionChange === ChangeHotswapImpact.IRRELEVANT) {
return stateMachineDefinitionChange;
}

const machineNameInCfnTemplate = change.newValue?.Properties?.StateMachineName;
const machineName = await establishResourcePhysicalName(logicalId, machineNameInCfnTemplate, evaluateCfnTemplate);
if (!machineName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

return new StateMachineHotswapOperation({
definition: stateMachineDefinitionChange,
stateMachineName: machineName,
});
}

async function isStateMachineDefinitionOnlyChange(
change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<string | ChangeHotswapImpact> {
const newResourceType = change.newValue.Type;
if (newResourceType !== 'AWS::StepFunctions::StateMachine') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const propertyUpdates = change.propertyUpdates;
for (const updatedPropName in propertyUpdates) {
// ensure that only changes to the definition string result in a hotswap
if (updatedPropName !== 'DefinitionString') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}

return evaluateCfnTemplate.evaluateCfnExpression(propertyUpdates.DefinitionString.newValue);
}

interface StateMachineResource {
readonly stateMachineName: string;
readonly definition: string;
}

class StateMachineHotswapOperation implements HotswapOperation {
constructor(private readonly stepFunctionResource: StateMachineResource) {
}

public async apply(sdk: ISDK): Promise<any> {
// not passing the optional properties leaves them unchanged
return sdk.stepFunctions().updateStateMachine({
// even though the name of the property is stateMachineArn, passing the name of the state machine is allowed here
stateMachineArn: this.stepFunctionResource.stateMachineName,
definition: this.stepFunctionResource.definition,
}).promise();
}
}
Loading

0 comments on commit b885fef

Please sign in to comment.