packages/@aws-cdk/toolkit-lib/lib/api/hotswap/ecs-services.ts (163 lines of code) (raw):

import type { HotswapPropertyOverrides, HotswapChange, } from './common'; import { classifyChanges, nonHotswappableChange, } from './common'; import { NonHotswappableReason, type ResourceChange } from '../../payloads/hotswap'; import { lowerCaseFirstCharacter, transformObjectKeys } from '../../util'; import type { SDK } from '../aws-auth/private'; import type { EvaluateCloudFormationTemplate } from '../cloudformation'; const ECS_SERVICE_RESOURCE_TYPE = 'AWS::ECS::Service'; export async function isHotswappableEcsServiceChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise<HotswapChange[]> { // the only resource change we can evaluate here is an ECS TaskDefinition if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') { return []; } const ret: HotswapChange[] = []; // We only allow a change in the ContainerDefinitions of the TaskDefinition for now - // it contains the image and environment variables, so seems like a safe bet for now. // We might revisit this decision in the future though! const classifiedChanges = classifyChanges(change, ['ContainerDefinitions']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); // find all ECS Services that reference the TaskDefinition that changed const resourcesReferencingTaskDef = evaluateCfnTemplate.findReferencesTo(logicalId); const ecsServiceResourcesReferencingTaskDef = resourcesReferencingTaskDef.filter( (r) => r.Type === ECS_SERVICE_RESOURCE_TYPE, ); const ecsServicesReferencingTaskDef = new Array<EcsService>(); for (const ecsServiceResource of ecsServiceResourcesReferencingTaskDef) { const serviceArn = await evaluateCfnTemplate.findPhysicalNameFor(ecsServiceResource.LogicalId); if (serviceArn) { ecsServicesReferencingTaskDef.push({ logicalId: ecsServiceResource.LogicalId, serviceArn, }); } } if (ecsServicesReferencingTaskDef.length === 0) { /** * ECS Services can have a task definition that doesn't refer to the task definition being updated. * We have to log this as a non-hotswappable change to the task definition, but when we do, * we wind up hotswapping the task definition and logging it as a non-hotswappable change. * * This logic prevents us from logging that change as non-hotswappable when we hotswap it. */ ret.push(nonHotswappableChange( change, NonHotswappableReason.DEPENDENCY_UNSUPPORTED, 'No ECS services reference the changed task definition', undefined, false, )); } 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 !== ECS_SERVICE_RESOURCE_TYPE); for (const taskRef of nonEcsServiceTaskDefRefs) { ret.push(nonHotswappableChange( change, NonHotswappableReason.DEPENDENCY_UNSUPPORTED, `A resource '${taskRef.LogicalId}' with Type '${taskRef.Type}' that is not an ECS Service was found referencing the changed TaskDefinition '${logicalId}'`, )); } } const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); if (namesOfHotswappableChanges.length > 0) { const taskDefinitionResource = await prepareTaskDefinitionChange(evaluateCfnTemplate, logicalId, change); ret.push({ change: { cause: change, resources: [ { logicalId, resourceType: change.newValue.Type, physicalName: await taskDefinitionResource.Family, metadata: evaluateCfnTemplate.metadataFor(logicalId), }, ...ecsServicesReferencingTaskDef.map((ecsService) => ({ resourceType: ECS_SERVICE_RESOURCE_TYPE, physicalName: ecsService.serviceArn.split('/')[2], logicalId: ecsService.logicalId, metadata: evaluateCfnTemplate.metadataFor(ecsService.logicalId), })), ], }, hotswappable: true, service: 'ecs-service', apply: async (sdk: SDK) => { // Step 1 - update the changed TaskDefinition, creating a new TaskDefinition Revision // we need to lowercase the evaluated TaskDef from CloudFormation, // as the AWS SDK uses lowercase property names for these // 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" } // https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_RegisterTaskDefinition.html#API_RegisterTaskDefinition_RequestSyntax ContainerDefinitions: { DockerLabels: true, FirelensConfiguration: { Options: true, }, LogConfiguration: { Options: true, }, }, Volumes: { DockerVolumeConfiguration: { DriverOpts: true, Labels: true, }, }, }); const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef); 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 // Forcing New Deployment and setting Minimum Healthy Percent to 0. // As CDK HotSwap is development only, this seems the most efficient way to ensure all tasks are replaced immediately, regardless of original amount // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism await Promise.all( ecsServicesReferencingTaskDef.map(async (service) => { const cluster = service.serviceArn.split('/')[1]; const update = await sdk.ecs().updateService({ service: service.serviceArn, taskDefinition: taskDefRevArn, cluster, forceNewDeployment: true, deploymentConfiguration: { minimumHealthyPercent: minimumHealthyPercent !== undefined ? minimumHealthyPercent : 0, maximumPercent: maximumHealthyPercent !== undefined ? maximumHealthyPercent : undefined, }, }); await sdk.ecs().waitUntilServicesStable({ cluster: update.service?.clusterArn, services: [service.serviceArn], }); }), ); }, }); } return ret; } interface EcsService { readonly logicalId: string; readonly serviceArn: string; } async function prepareTaskDefinitionChange( evaluateCfnTemplate: EvaluateCloudFormationTemplate, logicalId: string, change: ResourceChange, ) { const taskDefinitionResource: { [name: string]: any } = { ...change.oldValue.Properties, ContainerDefinitions: change.newValue.Properties?.ContainerDefinitions, }; // first, let's get the name of the family const familyNameOrArn = await evaluateCfnTemplate.establishResourcePhysicalName( logicalId, taskDefinitionResource?.Family, ); if (!familyNameOrArn) { // if the Family property has not been provided, and we can't find it in the current Stack, // this means hotswapping is not possible return; } // the physical name of the Task Definition in CloudFormation includes its current revision number at the end, // remove it if needed const familyNameOrArnParts = familyNameOrArn.split(':'); const family = familyNameOrArnParts.length > 1 ? // familyNameOrArn is actually an ARN, of the format 'arn:aws:ecs:region:account:task-definition/<family-name>:<revision-nr>' // so, take the 6th element, at index 5, and split it on '/' familyNameOrArnParts[5].split('/')[1] : // 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({ ...(taskDefinitionResource ?? {}), Family: undefined, })), Family: family, }; }