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

chore(cli): add more capabilities to the hotswap CFN evaluate sub-system #16696

Merged
merged 12 commits into from
Sep 30, 2021
Merged
Show file tree
Hide file tree
Changes from 6 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
43 changes: 26 additions & 17 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 { CloudFormationExecutableTemplate } from './hotswap/cloudformation-executable-template';
import { ChangeHotswapImpact, HotswapOperation, ListStackResources } from './hotswap/common';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { CloudFormationStack } from './util/cloudformation';
Expand All @@ -23,11 +24,7 @@ export async function tryHotswapDeployment(

// resolve the environment, so we can substitute things like AWS::Region in CFN expressions
const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment);
const hotswappableChanges = findAllHotswappableChanges(stackChanges, {
...assetParams,
'AWS::Region': resolvedEnv.region,
'AWS::AccountId': resolvedEnv.account,
});
const hotswappableChanges = findAllHotswappableChanges(stackChanges);
if (!hotswappableChanges) {
// this means there were changes to the template that cannot be short-circuited
return undefined;
Expand All @@ -36,19 +33,34 @@ export async function tryHotswapDeployment(
// create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis -
// it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions
const sdk = await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting);

// The current resources of the Stack.
// We need them to figure out the physical name of a function in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all updated Lambdas have their names set.
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
const cfnExecutableTemplate = new CloudFormationExecutableTemplate({
stackArtifact,
parameters: assetParams,
account: resolvedEnv.account,
region: resolvedEnv.region,
// ToDo make this better:
partition: 'aws',
// ToDo make this better:
urlSuffix: 'amazonaws.com',
listStackResources,
});

// apply the short-circuitable changes
await applyAllHotswappableChanges(sdk, stackArtifact, hotswappableChanges);
await applyAllHotswappableChanges(sdk, cfnExecutableTemplate, hotswappableChanges);

return { noOp: hotswappableChanges.length === 0, stackArn: cloudFormationStack.stackId, outputs: cloudFormationStack.outputs, stackArtifact };
}

function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, assetParamsWithEnv: { [key: string]: string },
): HotswapOperation[] | undefined {
function findAllHotswappableChanges(stackChanges: cfn_diff.TemplateDiff): HotswapOperation[] | undefined {
const hotswappableResources = new Array<HotswapOperation>();
let foundNonHotswappableChange = false;
stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => {
const lambdaFunctionShortCircuitChange = isHotswappableLambdaFunctionChange(logicalId, change, assetParamsWithEnv);
const lambdaFunctionShortCircuitChange = isHotswappableLambdaFunctionChange(logicalId, change);
if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
} else if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.IRRELEVANT) {
Expand All @@ -61,14 +73,11 @@ function findAllHotswappableChanges(
}

async function applyAllHotswappableChanges(
sdk: ISDK, stackArtifact: cxapi.CloudFormationStackArtifact, hotswappableChanges: HotswapOperation[],
sdk: ISDK, cfnExecutableTemplate: CloudFormationExecutableTemplate, hotswappableChanges: HotswapOperation[],
): Promise<void[]> {
// The current resources of the Stack.
// We need them to figure out the physical name of a function in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all updated Lambdas have their names set.
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);

return Promise.all(hotswappableChanges.map(hotswapOperation => hotswapOperation.apply(sdk, listStackResources)));
return Promise.all(hotswappableChanges.map(hotswapOperation => {
return hotswapOperation.apply(sdk, cfnExecutableTemplate);
}));
}

class LazyListStackResources implements ListStackResources {
Expand Down
230 changes: 230 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/cloudformation-executable-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import * as cxapi from '@aws-cdk/cx-api';
import * as AWS from 'aws-sdk';
import { ListStackResources } from './common';

export interface CloudFormationExecutableTemplateProps {
readonly stackArtifact: cxapi.CloudFormationStackArtifact;
readonly parameters: { [parameterName: string]: string };
readonly account: string;
readonly region: string;
readonly partition: string;
readonly urlSuffix: string;

readonly listStackResources: ListStackResources;
}

export class CloudFormationExecutableTemplate {
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
private readonly stackResources: ListStackResources;
private readonly context: { [k: string]: string };
private readonly account: string;
private readonly region: string;
private readonly partition: string;

constructor(props: CloudFormationExecutableTemplateProps) {
this.stackResources = props.listStackResources;
this.context = {
'AWS::AccountId': props.account,
'AWS::Region': props.region,
'AWS::Partition': props.partition,
'AWS::URLSuffix': props.urlSuffix,
...props.parameters,
};
this.account = props.account;
this.region = props.region;
this.partition = props.partition;
}

public async findPhysicalNameFor(logicalId: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
}

public async evaluateCfnExpression(cfnExpression: any): Promise<any> {
if (cfnExpression == null) {
return cfnExpression;
}

if (Array.isArray(cfnExpression)) {
return Promise.all(cfnExpression.map(expr => this.evaluateCfnExpression(expr)));
}

if (typeof cfnExpression === 'object') {
const intrinsic = this.parseIntrinsic(cfnExpression);
if (intrinsic) {
return this.evaluateIntrinsic(intrinsic);
} else {
const ret: { [key: string]: any } = {};
for (const [key, val] of Object.entries(cfnExpression)) {
ret[key] = await this.evaluateCfnExpression(val);
}
return ret;
}
}

return cfnExpression;
}

async 'Fn::Join'(separator: string, args: any[]): Promise<string> {
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
const evaluatedArgs = await this.evaluateCfnExpression(args);
return evaluatedArgs.join(separator);
}

async 'Fn::Split'(separator: string, args: any): Promise<string> {
const evaluatedArgs = await this.evaluateCfnExpression(args);
return evaluatedArgs.split(separator);
}

async 'Fn::Select'(index: number, args: any[]): Promise<string> {
const evaluatedArgs = await this.evaluateCfnExpression(args);
return evaluatedArgs[index];
}

async 'Ref'(logicalId: string): Promise<string> {
const refTarget = await this.findRefTarget(logicalId);
if (refTarget) {
return refTarget;
} else {
throw new Error(`Reference target '${logicalId}' was not found`);
}
}

async 'Fn::GetAtt'(logicalId: string, attributeName: string): Promise<string> {
// ToDo handle the 'logicalId.attributeName' form of Fn::GetAtt
const attrValue = await this.findGetAttTarget(logicalId, attributeName);
if (attrValue) {
return attrValue;
} else {
throw new Error(`Trying to evaluate Fn::GetAtt of '${logicalId}.${attributeName}' but not in context!`);
}
}

async 'Fn::Sub'(template: string, explicitPlaceholders?: { [variable: string]: string }): Promise<string> {
const placeholders = explicitPlaceholders
? { ...this.context, ...(await this.evaluateCfnExpression(explicitPlaceholders)) }
: this.context;

return template.replace(/\${([^}]*)}/g, (_: string, key: string) => {
if (key in placeholders) {
return placeholders[key];
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
} else {
throw new Error(`Fn::Sub target '${key}' was not found`);
}
});
}

private parseIntrinsic(x: any): Intrinsic | undefined {
const keys = Object.keys(x);
if (keys.length === 1 && (keys[0].startsWith('Fn::') || keys[0] === 'Ref')) {
return {
name: keys[0],
args: x[keys[0]],
};
}
return undefined;
}

private evaluateIntrinsic(intrinsic: Intrinsic): any {
const intrinsicFunc = (this as any)[intrinsic.name];
if (!intrinsicFunc) {
throw new Error(`CloudFormation function ${intrinsic.name} is not supported`);
}

const argsAsArray = Array.isArray(intrinsic.args) ? intrinsic.args : [intrinsic.args];

return intrinsicFunc.apply(this, argsAsArray);
}

private async findRefTarget(logicalId: string): Promise<string | undefined> {
// first, check to see if the Ref is a Parameter who's value we have
const parameterTarget = this.context[logicalId];
if (parameterTarget) {
return parameterTarget;
}
// if it's not a Parameter, we need to search in the current Stack resources
return this.findGetAttTarget(logicalId);
}

private async findGetAttTarget(logicalId: string, attribute?: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
const foundResource = stackResources.find(sr => sr.LogicalResourceId === logicalId);
if (!foundResource) {
return undefined;
}
// now, we need to format the appropriate identifier depending on the resource type,
// and the requested attribute name
return this.formatResourceAttribute(foundResource, attribute);
}

private formatResourceAttribute(resource: AWS.CloudFormation.StackResourceSummary, attribute: string | undefined): string | undefined {
const physicalId = resource.PhysicalResourceId;

// no attribute means Ref expression, for which we use the physical ID directly
if (!attribute) {
return physicalId;
}

const resourceTypeFormats = RESOURCE_TYPE_ATTRIBUTES_FORMATS[resource.ResourceType];
if (!resourceTypeFormats) {
return physicalId;
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
}
const attributeFmtFunc = resourceTypeFormats[attribute];
if (!attributeFmtFunc) {
return physicalId;
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
}
const service = this.getServiceOfResource(resource);
const resourceTypeArnPart = this.getResourceTypeArnPartOfResource(resource);
return attributeFmtFunc({
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
partition: this.partition,
service,
region: this.region,
account: this.account,
resourceType: resourceTypeArnPart,
resourceName: physicalId!,
});
}

private getServiceOfResource(resource: AWS.CloudFormation.StackResourceSummary): string {
return resource.ResourceType.split('::')[1].toLowerCase();
}

private getResourceTypeArnPartOfResource(resource: AWS.CloudFormation.StackResourceSummary): string {
return resource.ResourceType.split('::')[2].toLowerCase();
}
}

interface ArnParts {
readonly partition: string;
readonly service: string;
readonly region: string;
readonly account: string;
readonly resourceType: string;
readonly resourceName: string;
}

const RESOURCE_TYPE_ATTRIBUTES_FORMATS: { [type: string]: { [attribute: string]: (parts: ArnParts) => string } } = {
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
'AWS::IAM::Role': { Arn: iamArnFmt },
'AWS::IAM::User': { Arn: iamArnFmt },
'AWS::IAM::Group': { Arn: iamArnFmt },
'AWS::S3::Bucket': { Arn: s3ArnFmt },
'AWS::Lambda::Function': { Arn: stdColonResourceArnFmt },
};

function iamArnFmt(parts: ArnParts): string {
// we skip region for IAM resources
return `arn:${parts.partition}:${parts.service}::${parts.account}:${parts.resourceType}/${parts.resourceName}`;
}

function s3ArnFmt(parts: ArnParts): string {
// we skip account, region and resourceType for S3 resources
return `arn:${parts.partition}:${parts.service}:::${parts.resourceName}`;
}

function stdColonResourceArnFmt(parts: ArnParts): string {
// this is a standard format for ARNs like: arn:aws:service:region:account:resourceType:resourceName
return `arn:${parts.partition}:${parts.service}:${parts.region}:${parts.account}:${parts.resourceType}:${parts.resourceName}`;
}

interface Intrinsic {
readonly name: string;
readonly args: any;
}
22 changes: 2 additions & 20 deletions packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import { CloudFormation } from 'aws-sdk';
import { ISDK } from '../aws-auth';
import { evaluateCfn } from '../util/cloudformation/evaluate-cfn';
import { CloudFormationExecutableTemplate } from './cloudformation-executable-template';

export interface ListStackResources {
listStackResources(): Promise<CloudFormation.StackResourceSummary[]>;
Expand All @@ -11,7 +11,7 @@ export interface ListStackResources {
* An interface that represents a change that can be deployed in a short-circuit manner.
*/
export interface HotswapOperation {
apply(sdk: ISDK, stackResources: ListStackResources): Promise<any>;
apply(sdk: ISDK, cfnExecutableTemplate: CloudFormationExecutableTemplate): Promise<any>;
}

/**
Expand All @@ -34,24 +34,6 @@ export enum ChangeHotswapImpact {

export type ChangeHotswapResult = HotswapOperation | ChangeHotswapImpact;

/**
* For old-style synthesis which uses CFN Parameters,
* the Code properties can have the values of complex CFN expressions.
* For new-style synthesis of env-agnostic stacks,
* the Fn::Sub expression is used for the Asset bucket.
* Evaluate the CFN expressions to concrete string values which we need for the
* updateFunctionCode() service call.
*/
export function stringifyPotentialCfnExpression(value: any, assetParamsWithEnv: { [key: string]: string }): string {
// if we already have a string, nothing to do
if (value == null || typeof value === 'string') {
return value;
}

// otherwise, we assume this is a CloudFormation expression that we need to evaluate
return evaluateCfn(value, assetParamsWithEnv);
}

export function assetMetadataChanged(change: cfn_diff.ResourceDifference): boolean {
return !!change.newValue?.Metadata['aws:asset:path'];
}
Loading