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

fix(cli): cannot hotswap ECS task definitions containing certain intrinsics #26404

Merged
merged 24 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
158 changes: 148 additions & 10 deletions packages/aws-cdk/lib/api/hotswap/ecs-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export async function isHotswappableEcsServiceChange(
// if there are no resources referencing the TaskDefinition,
// hotswap is not possible in FALL_BACK mode
reportNonHotswappableChange(ret, change, undefined, 'No ECS services reference the changed task definition', false);
} if (resourcesReferencingTaskDef.length > ecsServicesReferencingTaskDef.length) {
}
if (resourcesReferencingTaskDef.length > ecsServicesReferencingTaskDef.length) {
// if something besides an ECS Service is referencing the TaskDefinition,
// hotswap is not possible in FALL_BACK mode
const nonEcsServiceTaskDefRefs = resourcesReferencingTaskDef.filter(r => r.Type !== 'AWS::ECS::Service');
Expand All @@ -45,14 +46,18 @@ export async function isHotswappableEcsServiceChange(
const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps);
if (namesOfHotswappableChanges.length > 0) {
const taskDefinitionResource = await prepareTaskDefinitionChange(evaluateCfnTemplate, logicalId, change);
if (taskDefinitionResource === undefined) {
reportNonHotswappableChange(ret, change, undefined, 'Found unsupported changes to the task definition', false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if we can report the property path that we found unsupported changes in.

return ret;
}
ret.push({
hotswappable: true,
resourceType: change.newValue.Type,
propsChanged: namesOfHotswappableChanges,
service: 'ecs-service',
resourceNames: [
`ECS Task Definition '${await taskDefinitionResource.Family}'`,
...ecsServicesReferencingTaskDef.map(ecsService => `ECS Service '${ecsService.serviceArn.split('/')[2]}'`),
`ECS Task Definition '${taskDefinitionResource.family}'`,
],
apply: async (sdk: ISDK) => {
// Step 1 - update the changed TaskDefinition, creating a new TaskDefinition Revision
Expand All @@ -61,8 +66,8 @@ export async function isHotswappableEcsServiceChange(

// The SDK requires more properties here than its worth doing explicit typing for
// instead, just use all the old values in the diff to fill them in implicitly
const lowercasedTaskDef = transformObjectKeys(taskDefinitionResource, lowerCaseFirstCharacter, {
// All the properties that take arbitrary string as keys i.e. { "string" : "string" }
let lowercasedTaskDef = transformObjectKeys(taskDefinitionResource.taskDefinition, lowerCaseFirstCharacter, {
tmokmss marked this conversation as resolved.
Show resolved Hide resolved
// Don't transform the properties that take arbitrary string as keys i.e. { "string" : "string" }
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_RegisterTaskDefinition.html#API_RegisterTaskDefinition_RequestSyntax
ContainerDefinitions: {
DockerLabels: true,
Expand All @@ -80,6 +85,23 @@ export async function isHotswappableEcsServiceChange(
},
},
});

if (taskDefinitionResource.hotswappableWithMerge) {
// get the latest task definition of the family
const describeTaskDefinitionResponse = await sdk
.ecs()
.describeTaskDefinition({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No other hotswappable resource does a describe on the service. What is missing from EvaluateCfnTemplate that forces us to do this? We already have these prior definitions from the previous form of the CFN template.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @comcalvi, thanks for looking at this!

I'm curious what you mean by this. EvaluteCfnTemplate doesn't explicitly check any of these resources, it instead looks up every arg to GetAtt in the current deployed template.

We can't know the actual values for a GetAtt just by looking up the template. For example, given this string: "Fn::GetAtt": ["SecurityGroupC96ED6A7", "GroupId"], how do we know that it should resolve to the security group id? In the current implementation, we have a manually created mapping table for the GetAtt functions, and it can only evaluate one of the resource attributes defined here:

const RESOURCE_TYPE_ATTRIBUTES_FORMATS: { [type: string]: { [attribute: string]: (parts: ArnParts) => string } } = {
'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 },
'AWS::Events::EventBus': {
Arn: stdSlashResourceArnFmt,
// the name attribute of the EventBus is the same as the Ref
Name: parts => parts.resourceName,
},
'AWS::DynamoDB::Table': { Arn: stdSlashResourceArnFmt },
'AWS::AppSync::GraphQLApi': { ApiId: appsyncGraphQlApiApiIdFmt },
};

If we want to evaluate all attributes, we have to manually define these mappings one by one, for all resources and attributes, which is hardly a realistic solution. Since this problem makes ECS hotswap almost unusable for many real use cases, I believe it's worth optimizing the hotswap logic specifically for ECS.

So instead of going to CFN to get the values, which is what EvaluteCfnTemplate does, we go to the ECS service directly. Why? Is there something that EvaluateCfnTemplate can't handle?

Yes, EvaluateCfnTemplate can't handle certain intrinsics, such as GetAtt for the attributes that don't have the mappings described above, or Fn::ImportValue, etc. An easy way to get the actual values for the attributes is to query the ECS service, which is done by this PR.

taskDefinition: taskDefinitionResource.family,
include: ['TAGS'],
})
.promise();
if (describeTaskDefinitionResponse.taskDefinition === undefined) {
throw new Error(`Could not find TaskDefinition ${taskDefinitionResource.family}`);
}
mergeTaskDefinitions(lowercasedTaskDef, describeTaskDefinitionResponse);
lowercasedTaskDef = describeTaskDefinitionResponse.taskDefinition;
}

const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise();
const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn;

Expand Down Expand Up @@ -167,13 +189,47 @@ export async function isHotswappableEcsServiceChange(
return ret;
}

function mergeTaskDefinitions(patch: {containerDefinitions: {[key:string]: any}[]}, target: AWS.ECS.DescribeTaskDefinitionResponse) {
tmokmss marked this conversation as resolved.
Show resolved Hide resolved
const src = patch.containerDefinitions;
const dst = target.taskDefinition?.containerDefinitions;
if (dst === undefined) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (dst === undefined) return;
if (dst === undefined) {
return;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK! Shouldn't we enforce this in eslintrc?

'curly': ['error', 'multi-line', 'consistent'], // require curly braces for multiline control statements

// schema: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
for (const i in src) {
for (const key of Object.keys(src[i])) {
(dst[i] as any)[key] = src[i][key];
}
}
tmokmss marked this conversation as resolved.
Show resolved Hide resolved

// The describeTaskDefinition response contains several keys that must not exist in a registerTaskDefinition request.
// We remove these keys here, comparing these two structs:
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_RegisterTaskDefinition.html#API_RegisterTaskDefinition_RequestSyntax
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_DescribeTaskDefinition.html#API_DescribeTaskDefinition_ResponseSyntax
[
'compatibilities',
'taskDefinitionArn',
'revision',
'status',
'requiresAttributes',
'compatibilities',
'registeredAt',
'registeredBy',
].forEach(key=> delete (target.taskDefinition as any)[key]);

if (target.tags !== undefined && target.tags.length > 0) {
// the tags field is in a different location in describeTaskDefinition response, moving it as intended for registerTaskDefinition request.
(target.taskDefinition as any).tags = target.tags;
}
}

interface EcsService {
readonly serviceArn: string;
}

async function prepareTaskDefinitionChange(
evaluateCfnTemplate: EvaluateCloudFormationTemplate, logicalId: string, change: HotswappableChangeCandidate,
) {
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
logicalId: string,
change: HotswappableChangeCandidate,
): Promise<undefined | { taskDefinition: any; family: string; hotswappableWithMerge: boolean }> {
tmokmss marked this conversation as resolved.
Show resolved Hide resolved
const taskDefinitionResource: { [name: string]: any } = {
...change.oldValue.Properties,
ContainerDefinitions: change.newValue.Properties?.ContainerDefinitions,
Expand All @@ -195,11 +251,93 @@ async function prepareTaskDefinitionChange(
// otherwise, familyNameOrArn is just the simple name evaluated from the CloudFormation template
: familyNameOrArn;
// then, let's evaluate the body of the remainder of the TaskDef (without the Family property)
return {
...await evaluateCfnTemplate.evaluateCfnExpression({

let evaluated;
let hotswappableWithMerge = false;
try {
evaluated = await evaluateCfnTemplate.evaluateCfnExpression({
...(taskDefinitionResource ?? {}),
Family: undefined,
}),
Family: family,
});
} catch (e) {
const result = await deepCompareContainerDefinitions(
tmokmss marked this conversation as resolved.
Show resolved Hide resolved
evaluateCfnTemplate,
change.oldValue.Properties?.ContainerDefinitions ?? [],
change.newValue.Properties?.ContainerDefinitions ?? []);
if (result === false) {
throw e;
} else {
evaluated = {
// return a partial definition that should be merged with the current definition
ContainerDefinitions: result,
};
hotswappableWithMerge = true;
}
}

return {
taskDefinition: {
...evaluated,
Family: family,
},
family,
hotswappableWithMerge,
};
}

// return false if the new container definitions cannot be hotswapped
// i.e. there are changes containing unsupported tokens or additions/removals of properties
// If not, we return the properties that should be updated (they will be merged with the deployed task definition later)
async function deepCompareContainerDefinitions(evaluateCfnTemplate: EvaluateCloudFormationTemplate, oldDefinition: any[], newDefinition: any[]) {
const result: any[] = [];
// schema: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
if (oldDefinition.length !== newDefinition.length) {
// one or more containers are added or removed
return false;
}
for (const i in oldDefinition) {
result.push({});
const prev = oldDefinition[i];
const next = newDefinition[i];

if (!deepCompareObject(Object.keys(prev), Object.keys(next))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this assumes that the keys are in the same order. Is that necessarily always true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this compares the old and new CFn templates, I think yes. Otherwise we would have unnecessary diff even on usual CFn deployment.

const result = await deepCompareContainerDefinitions(
evaluateCfnTemplate,
change.oldValue.Properties?.ContainerDefinitions ?? [],
change.newValue.Properties?.ContainerDefinitions ?? []);

// one or more fields are added or removed
return false;
}
for (const key of Object.keys(prev)) {
// compare two properties first
if (deepCompareObject(prev[key], next[key])) {
tmokmss marked this conversation as resolved.
Show resolved Hide resolved
// if there is no difference, skip the field
continue;
}
// if there is any diff found, check if it can be evaluated without raising an error
try {
result[i][key] = await evaluateCfnTemplate.evaluateCfnExpression(next[key]);
} catch (e) {
// Give up hotswap if the diff contains unsupported expression
return false;
}
}
}

return result;
}

// return true when two objects are identical
function deepCompareObject(lhs: object, rhs: object) {
if (typeof lhs !== 'object') {
return lhs === rhs;
}
if (typeof rhs !== 'object') {
return false;
}
if (Object.keys(lhs).length != Object.keys(rhs).length) {
return false;
}
for (const key of Object.keys(lhs)) {
if (!deepCompareObject((lhs as any)[key], (rhs as any)[key])) {
return false;
}
}
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { HotswapMode } from '../../../lib/api/hotswap/common';

let hotswapMockSdkProvider: setup.HotswapMockSdkProvider;
let mockRegisterTaskDef: jest.Mock<AWS.ECS.RegisterTaskDefinitionResponse, AWS.ECS.RegisterTaskDefinitionRequest[]>;
let mockDescribeTaskDef: jest.Mock<AWS.ECS.DescribeTaskDefinitionResponse, AWS.ECS.DescribeTaskDefinitionRequest[]>;
let mockUpdateService: (params: AWS.ECS.UpdateServiceRequest) => AWS.ECS.UpdateServiceResponse;

beforeEach(() => {
hotswapMockSdkProvider = setup.setupHotswapTests();

mockRegisterTaskDef = jest.fn();
mockDescribeTaskDef = jest.fn();
mockUpdateService = jest.fn();
hotswapMockSdkProvider.stubEcs({
registerTaskDefinition: mockRegisterTaskDef,
describeTaskDefinition: mockDescribeTaskDef,
updateService: mockUpdateService,
}, {
// these are needed for the waiter API that the ECS service hotswap uses
Expand Down Expand Up @@ -519,6 +522,122 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot
}
});

test('should call registerTaskDefinition, describeTaskDefinition, and updateService for a difference only in the container image but with environment variables of unsupported intrinsics', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
TaskDef: {
Type: 'AWS::ECS::TaskDefinition',
Properties: {
Family: 'my-task-def',
ContainerDefinitions: [
{
Image: 'image1',
Environment: [
{
Name: 'FOO',
Value: { 'Fn::GetAtt': ['Bar', 'Baz'] },
},
],
},
],
},
},
Service: {
Type: 'AWS::ECS::Service',
Properties: {
TaskDefinition: { Ref: 'TaskDef' },
},
},
},
});
setup.pushStackResourceSummaries(
setup.stackSummaryOf('Service', 'AWS::ECS::Service',
'arn:aws:ecs:region:account:service/my-cluster/my-service'),
);
mockRegisterTaskDef.mockReturnValue({
taskDefinition: {
taskDefinitionArn: 'arn:aws:ecs:region:account:task-definition/my-task-def:3',
},
});
mockDescribeTaskDef.mockReturnValue({
taskDefinition: {
taskDefinitionArn: 'arn:aws:ecs:region:account:task-definition/my-task-def:2',
family: 'my-task-def',
containerDefinitions: [
{
image: 'image1',
environment: [
{
name: 'FOO',
value: 'value',
},
],
},
],
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
TaskDef: {
Type: 'AWS::ECS::TaskDefinition',
Properties: {
Family: 'my-task-def',
ContainerDefinitions: [
{
Image: 'image2',
Environment: [
{
Name: 'FOO',
Value: { 'Fn::GetAtt': ['Bar', 'Baz'] },
},
],
},
],
},
},
Service: {
Type: 'AWS::ECS::Service',
Properties: {
TaskDefinition: { Ref: 'TaskDef' },
},
},
},
},
});

// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockRegisterTaskDef).toBeCalledWith({
family: 'my-task-def',
containerDefinitions: [
{
image: 'image2',
environment: [
{

name: 'FOO',
value: 'value',
},
],
},
],
});
expect(mockUpdateService).toBeCalledWith({
service: 'arn:aws:ecs:region:account:service/my-cluster/my-service',
cluster: 'my-cluster',
taskDefinition: 'arn:aws:ecs:region:account:task-definition/my-task-def:3',
deploymentConfiguration: {
minimumHealthyPercent: 0,
},
forceNewDeployment: true,
});
});

test('should call registerTaskDefinition with certain properties not lowercased', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Expand Down