-
Notifications
You must be signed in to change notification settings - Fork 4k
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
Changes from 10 commits
05d2a7c
694adbd
6b42be2
652489e
b79959d
4ff9244
fc897d2
84ebf8c
dd7c3f1
bfd9c5a
64f2c20
f0284c0
c90504e
9e7e7aa
c69cbaa
1c60092
4407b05
4d7ce4a
31427f3
76af98f
f08d1d2
052d479
6814204
754a08e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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'); | ||||||||||||||||||||||||||||||
|
@@ -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); | ||||||||||||||||||||||||||||||
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 | ||||||||||||||||||||||||||||||
|
@@ -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, | ||||||||||||||||||||||||||||||
|
@@ -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({ | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No other hotswappable resource does a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @comcalvi, thanks for looking at this!
We can't know the actual values for a GetAtt just by looking up the template. For example, given this string: aws-cdk/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts Lines 352 to 365 in 5ccc569
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.
Yes, |
||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
|
@@ -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; | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK! Shouldn't we enforce this in eslintrc?
|
||||||||||||||||||||||||||||||
// 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, | ||||||||||||||||||||||||||||||
|
@@ -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))) { | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. aws-cdk/packages/aws-cdk/lib/api/hotswap/ecs-services.ts Lines 265 to 268 in 64f2c20
|
||||||||||||||||||||||||||||||
// 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; | ||||||||||||||||||||||||||||||
} |
There was a problem hiding this comment.
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.