diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 9690c6f9d9258..e4566b7bbb690 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -363,6 +363,7 @@ Hotswapping is currently supported for the following changes - Code asset changes of AWS Lambda functions. - Definition changes of AWS Step Functions State Machines. +- Container asset changes of AWS ECS Services. **⚠ 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. diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 9090a59c8d792..91fcdc2fede7d 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -29,6 +29,7 @@ export interface ISDK { s3(): AWS.S3; route53(): AWS.Route53; ecr(): AWS.ECR; + ecs(): AWS.ECS; elbv2(): AWS.ELBv2; secretsManager(): AWS.SecretsManager; kms(): AWS.KMS; @@ -117,6 +118,10 @@ export class SDK implements ISDK { return this.wrapServiceErrorHandling(new AWS.ECR(this.config)); } + public ecs(): AWS.ECS { + return this.wrapServiceErrorHandling(new AWS.ECS(this.config)); + } + public elbv2(): AWS.ELBv2 { return this.wrapServiceErrorHandling(new AWS.ELBv2(this.config)); } diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index 4888d639ff394..f58f441560e3e 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -258,7 +258,6 @@ export async function deployStack(options: DeployStackOptions): Promise { + // the only resource change we should allow is an ECS TaskDefinition + if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + for (const updatedPropName in change.propertyUpdates) { + // 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! + if (updatedPropName !== 'ContainerDefinitions') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + const containerDefinitionsDifference = (change.propertyUpdates)[updatedPropName]; + if (containerDefinitionsDifference.newValue === undefined) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } + // at this point, we know the TaskDefinition can be hotswapped + + // find all ECS Services that reference the TaskDefinition that changed + const resourcesReferencingTaskDef = evaluateCfnTemplate.findReferencesTo(logicalId); + const ecsServiceResourcesReferencingTaskDef = resourcesReferencingTaskDef.filter(r => r.Type === 'AWS::ECS::Service'); + const ecsServicesReferencingTaskDef = new Array(); + for (const ecsServiceResource of ecsServiceResourcesReferencingTaskDef) { + const serviceArn = await evaluateCfnTemplate.findPhysicalNameFor(ecsServiceResource.LogicalId); + if (serviceArn) { + ecsServicesReferencingTaskDef.push({ serviceArn }); + } + } + if (ecsServicesReferencingTaskDef.length === 0 || + resourcesReferencingTaskDef.length > ecsServicesReferencingTaskDef.length) { + // if there are either no resources referencing the TaskDefinition, + // or something besides an ECS Service is referencing it, + // hotswap is not possible + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const taskDefinitionResource = change.newValue.Properties; + // first, let's get the name of the family + const familyNameOrArn = await establishResourcePhysicalName(logicalId, taskDefinitionResource?.Family, evaluateCfnTemplate); + if (!familyNameOrArn) { + // if the Family property has not bee provided, and we can't find it in the current Stack, + // this means hotswapping is not possible + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + // 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/:' + // 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) + const evaluatedTaskDef = { + ...await evaluateCfnTemplate.evaluateCfnExpression({ + ...(taskDefinitionResource ?? {}), + Family: undefined, + }), + Family: family, + }; + return new EcsServiceHotswapOperation(evaluatedTaskDef, ecsServicesReferencingTaskDef); +} + +interface EcsService { + readonly serviceArn: string; +} + +class EcsServiceHotswapOperation implements HotswapOperation { + constructor( + private readonly taskDefinitionResource: any, + private readonly servicesReferencingTaskDef: EcsService[], + ) {} + + public async apply(sdk: ISDK): Promise { + // 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 + const lowercasedTaskDef = lowerCaseFirstCharacterOfObjectKeys(this.taskDefinitionResource); + const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise(); + const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn; + + // Step 2 - update the services using that TaskDefinition to point to the new TaskDefinition Revision + const servicePerClusterUpdates: { [cluster: string]: Array<{ promise: Promise, ecsService: EcsService }> } = {}; + for (const ecsService of this.servicesReferencingTaskDef) { + const clusterName = ecsService.serviceArn.split('/')[1]; + + const existingClusterPromises = servicePerClusterUpdates[clusterName]; + let clusterPromises: Array<{ promise: Promise, ecsService: EcsService }>; + if (existingClusterPromises) { + clusterPromises = existingClusterPromises; + } else { + clusterPromises = []; + servicePerClusterUpdates[clusterName] = clusterPromises; + } + + clusterPromises.push({ + promise: sdk.ecs().updateService({ + service: ecsService.serviceArn, + taskDefinition: taskDefRevArn, + cluster: clusterName, + forceNewDeployment: true, + deploymentConfiguration: { + minimumHealthyPercent: 0, + }, + }).promise(), + ecsService: ecsService, + }); + } + await Promise.all(Object.values(servicePerClusterUpdates) + .map(clusterUpdates => { + return Promise.all(clusterUpdates.map(serviceUpdate => serviceUpdate.promise)); + }), + ); + + // Step 3 - wait for the service deployments triggered in Step 2 to finish + // configure a custom Waiter + (sdk.ecs() as any).api.waiters.deploymentToFinish = { + name: 'DeploymentToFinish', + operation: 'describeServices', + delay: 10, + maxAttempts: 60, + acceptors: [ + { + matcher: 'pathAny', + argument: 'failures[].reason', + expected: 'MISSING', + state: 'failure', + }, + { + matcher: 'pathAny', + argument: 'services[].status', + expected: 'DRAINING', + state: 'failure', + }, + { + matcher: 'pathAny', + argument: 'services[].status', + expected: 'INACTIVE', + state: 'failure', + }, + { + matcher: 'path', + argument: "length(services[].deployments[? status == 'PRIMARY' && runningCount < desiredCount][]) == `0`", + expected: true, + state: 'success', + }, + ], + }; + // create a custom Waiter that uses the deploymentToFinish configuration added above + const deploymentWaiter = new (AWS as any).ResourceWaiter(sdk.ecs(), 'deploymentToFinish'); + // wait for all of the waiters to finish + return Promise.all(Object.entries(servicePerClusterUpdates).map(([clusterName, serviceUpdates]) => { + return deploymentWaiter.wait({ + cluster: clusterName, + services: serviceUpdates.map(serviceUpdate => serviceUpdate.ecsService.serviceArn), + }).promise(); + })); + } +} + +function lowerCaseFirstCharacterOfObjectKeys(val: any): any { + if (val == null || typeof val !== 'object') { + return val; + } + if (Array.isArray(val)) { + return val.map(lowerCaseFirstCharacterOfObjectKeys); + } + const ret: { [k: string]: any; } = {}; + for (const [k, v] of Object.entries(val)) { + ret[lowerCaseFirstCharacter(k)] = lowerCaseFirstCharacterOfObjectKeys(v); + } + return ret; +} + +function lowerCaseFirstCharacter(str: string): string { + return str.length > 0 ? `${str[0].toLowerCase()}${str.substr(1)}` : str; +} diff --git a/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts index dc1541ed74771..59d8d7df19445 100644 --- a/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts @@ -4,6 +4,12 @@ import { ListStackResources } from './common'; export class CfnEvaluationException extends Error {} +export interface ResourceDefinition { + readonly LogicalId: string; + readonly Type: string; + readonly Properties: { [p: string]: any }; +} + export interface EvaluateCloudFormationTemplateProps { readonly stackArtifact: cxapi.CloudFormationStackArtifact; readonly parameters: { [parameterName: string]: string }; @@ -11,12 +17,12 @@ export interface EvaluateCloudFormationTemplateProps { readonly region: string; readonly partition: string; readonly urlSuffix: string; - readonly listStackResources: ListStackResources; } export class EvaluateCloudFormationTemplate { private readonly stackResources: ListStackResources; + private readonly template: { [section: string]: { [headings: string]: any } }; private readonly context: { [k: string]: string }; private readonly account: string; private readonly region: string; @@ -24,6 +30,7 @@ export class EvaluateCloudFormationTemplate { constructor(props: EvaluateCloudFormationTemplateProps) { this.stackResources = props.listStackResources; + this.template = props.stackArtifact.template; this.context = { 'AWS::AccountId': props.account, 'AWS::Region': props.region, @@ -41,6 +48,19 @@ export class EvaluateCloudFormationTemplate { return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId; } + public findReferencesTo(logicalId: string): Array { + const ret = new Array(); + for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) { + if (logicalId !== resourceLogicalId && this.references(logicalId, resourceDef)) { + ret.push({ + ...(resourceDef as any), + LogicalId: resourceLogicalId, + }); + } + } + return ret; + } + public async evaluateCfnExpression(cfnExpression: any): Promise { const self = this; class CfnIntrinsics { @@ -131,6 +151,26 @@ export class EvaluateCloudFormationTemplate { return cfnExpression; } + private references(logicalId: string, templateElement: any): boolean { + if (typeof templateElement === 'string') { + return logicalId === templateElement; + } + + if (templateElement == null) { + return false; + } + + if (Array.isArray(templateElement)) { + return templateElement.some(el => this.references(logicalId, el)); + } + + if (typeof templateElement === 'object') { + return Object.values(templateElement).some(el => this.references(logicalId, el)); + } + + return false; + } + private parseIntrinsic(x: any): Intrinsic | undefined { const keys = Object.keys(x); if (keys.length === 1 && (keys[0].startsWith('Fn::') || keys[0] === 'Ref')) { diff --git a/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts new file mode 100644 index 0000000000000..42ceba90b4839 --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts @@ -0,0 +1,364 @@ +import * as AWS from 'aws-sdk'; +import * as setup from './hotswap-test-setup'; + +let mockSdkProvider: setup.CfnMockProvider; +let mockRegisterTaskDef: jest.Mock; +let mockUpdateService: (params: AWS.ECS.UpdateServiceRequest) => AWS.ECS.UpdateServiceResponse; + +beforeEach(() => { + mockSdkProvider = setup.setupHotswapTests(); + + mockRegisterTaskDef = jest.fn(); + mockUpdateService = jest.fn(); + mockSdkProvider.stubEcs({ + registerTaskDefinition: mockRegisterTaskDef, + updateService: mockUpdateService, + }, { + // these are needed for the waiter API that the ECS service hotswap uses + api: { + waiters: {}, + }, + makeRequest() { + return { + promise: () => Promise.resolve({}), + response: {}, + addListeners: () => {}, + }; + }, + }); +}); + +test('should call registerTaskDefinition and updateService for a difference only in the TaskDefinition with a Family property', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image1' }, + ], + }, + }, + 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', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image2' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await mockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockRegisterTaskDef).toBeCalledWith({ + family: 'my-task-def', + containerDefinitions: [ + { image: 'image2' }, + ], + }); + 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('any other TaskDefinition property change besides ContainerDefinition cannot be hotswapped', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image1' }, + ], + Cpu: '256', + }, + }, + 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', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image2' }, + ], + Cpu: '512', + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await mockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); +}); + +test('should call registerTaskDefinition and updateService for a difference only in the TaskDefinition without a Family property', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + ContainerDefinitions: [ + { Image: 'image1' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('TaskDef', 'AWS::ECS::TaskDefinition', + 'arn:aws:ecs:region:account:task-definition/my-task-def:2'), + 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', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + ContainerDefinitions: [ + { Image: 'image2' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await mockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockRegisterTaskDef).toBeCalledWith({ + family: 'my-task-def', + containerDefinitions: [ + { image: 'image2' }, + ], + }); + 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('a difference just in a TaskDefinition, without any services using it, is not hotswappable', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + ContainerDefinitions: [ + { Image: 'image1' }, + ], + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('TaskDef', 'AWS::ECS::TaskDefinition', + 'arn:aws:ecs:region:account:task-definition/my-task-def:2'), + ); + mockRegisterTaskDef.mockReturnValue({ + taskDefinition: { + taskDefinitionArn: 'arn:aws:ecs:region:account:task-definition/my-task-def:3', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + ContainerDefinitions: [ + { Image: 'image2' }, + ], + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await mockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockRegisterTaskDef).not.toHaveBeenCalled(); +}); + +test('if anything besides an ECS Service references the changed TaskDefinition, hotswapping is not possible', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image1' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + Function: { + Type: 'AWS::Lambda::Function', + Properties: { + Environment: { + Variables: { + TaskDefRevArn: { 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', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image2' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + Function: { + Type: 'AWS::Lambda::Function', + Properties: { + Environment: { + Variables: { + TaskDefRevArn: { Ref: 'TaskDef' }, + }, + }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await mockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockRegisterTaskDef).not.toHaveBeenCalled(); +}); diff --git a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts index 00d1a706a66e7..5939b749b9a38 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -1,12 +1,13 @@ import * as cxapi from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; +import * as AWS from 'aws-sdk'; import * as lambda from 'aws-sdk/clients/lambda'; import * as stepfunctions from 'aws-sdk/clients/stepfunctions'; import { DeployStackResult } from '../../../lib'; import * as deployments from '../../../lib/api/hotswap-deployments'; import { Template } from '../../../lib/api/util/cloudformation'; import { testStack, TestStackArtifact } from '../../util'; -import { MockSdkProvider } from '../../util/mock-sdk'; +import { MockSdkProvider, SyncHandlerSubsetOf } from '../../util/mock-sdk'; import { FakeCloudformationStack } from '../fake-cloudformation-stack'; const STACK_NAME = 'withouterrors'; @@ -72,9 +73,9 @@ export class CfnMockProvider { }); } - public setUpdateStateMachineMock(mockUpdateMachineDefinition: - (input: stepfunctions.UpdateStateMachineInput) => - stepfunctions.UpdateStateMachineOutput) { + public setUpdateStateMachineMock( + mockUpdateMachineDefinition: (input: stepfunctions.UpdateStateMachineInput) => stepfunctions.UpdateStateMachineOutput, + ) { this.mockSdkProvider.stubStepFunctions({ updateStateMachine: mockUpdateMachineDefinition, }); @@ -86,6 +87,10 @@ export class CfnMockProvider { }); } + public stubEcs(stubs: SyncHandlerSubsetOf, additionalProperties: { [key: string]: any } = {}): void { + this.mockSdkProvider.stubEcs(stubs, additionalProperties); + } + public tryHotswapDeployment( stackArtifact: cxapi.CloudFormationStackArtifact, assetParams: { [key: string]: string } = {}, diff --git a/packages/aws-cdk/test/aws-sdk-non-public-apis.test.ts b/packages/aws-cdk/test/aws-sdk-non-public-apis.test.ts new file mode 100644 index 0000000000000..7b7c5b42d8ecb --- /dev/null +++ b/packages/aws-cdk/test/aws-sdk-non-public-apis.test.ts @@ -0,0 +1,25 @@ +// The ECS hotswapping functionality in lib/api/hotswap/ecs-services.ts +// uses some non-public APIs of the JS AWS SDK for waiting on the deployment to finish. +// These unit tests are here to confirm the non-public elements are present and working as expected, +// and do not get changed in a new version of the aws-sdk package + +import * as AWS from 'aws-sdk'; + +let ecsService: AWS.ECS; +beforeEach(() => { + ecsService = new AWS.ECS(); +}); + +test("the 'waiters' API is available in the current AWS SDK", () => { + const waiters = (ecsService as any).api?.waiters; + + expect(waiters).not.toBeUndefined(); + expect(typeof waiters).toBe('object'); +}); + +test("the 'ResourceWaiter' API is available in the current AWS SDK", () => { + const resourceWaiter = new (AWS as any).ResourceWaiter(ecsService, 'servicesStable'); + + // make sure the 'wait' method is available + expect(typeof resourceWaiter.wait).toBe('function'); +}); diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index c6075853c78ba..7b9b4f6fb8b1a 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -70,6 +70,10 @@ export class MockSdkProvider extends SdkProvider { (this.sdk as any).ecr = jest.fn().mockReturnValue(partialAwsService(stubs)); } + public stubEcs(stubs: SyncHandlerSubsetOf, additionalProperties: { [key: string]: any } = {}) { + (this.sdk as any).ecs = jest.fn().mockReturnValue(partialAwsService(stubs, additionalProperties)); + } + /** * Replace the S3 client with the given object */ @@ -116,6 +120,7 @@ export class MockSdk implements ISDK { public readonly s3 = jest.fn(); public readonly route53 = jest.fn(); public readonly ecr = jest.fn(); + public readonly ecs = jest.fn(); public readonly elbv2 = jest.fn(); public readonly secretsManager = jest.fn(); public readonly kms = jest.fn(); @@ -175,7 +180,7 @@ export class MockSdk implements ISDK { * types of the handlers on the input object from the ACTUAL AWS Service class, * so that you don't have to declare them. */ -function partialAwsService(fns: SyncHandlerSubsetOf): S { +function partialAwsService(fns: SyncHandlerSubsetOf, additionalProperties: { [key: string]: any } = {}): S { // Super unsafe in here because I don't know how to make TypeScript happy, // but at least the outer types make sure everything that happens in here works out. const ret: any = {}; @@ -183,6 +188,9 @@ function partialAwsService(fns: SyncHandlerSubsetOf): S { for (const [key, handler] of Object.entries(fns)) { ret[key] = (args: any) => new FakeAWSResponse((handler as any)(args)); } + for (const [key, value] of Object.entries(additionalProperties)) { + ret[key] = value; + } return ret; }