From 0ea6313125c63608a90b175f2f73b2b51d7b2b05 Mon Sep 17 00:00:00 2001 From: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> Date: Thu, 28 Sep 2023 23:17:02 +0200 Subject: [PATCH] feat(cdk): add AppSync GraphQLSchema and pipeline resolvers as hot swappable (#27197) 1. Add GraphQLSchema as another AppSync resource that can be hotswapped 2. For all AppSync resources, accept the change in S3 assets/files instead of just inline code as a candidate for hotswap 3. Make pipeline resolvers hotswappable by resolving the functionId of AppSync functions. Closes #2659, #24112, #24113. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/README.md | 3 +- .../api/evaluate-cloudformation-template.ts | 12 + .../aws-cdk/lib/api/hotswap-deployments.ts | 1 + .../api/hotswap/appsync-mapping-templates.ts | 98 ++- ...ping-templates-hotswap-deployments.test.ts | 674 ++++++++++++++++-- .../test/api/hotswap/hotswap-test-setup.ts | 4 + 6 files changed, 714 insertions(+), 78 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index d76e7d9ea612d..3d892f84f1c94 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -421,7 +421,8 @@ Hotswapping is currently supported for the following changes - Container asset changes of AWS ECS Services. - Website asset changes of AWS S3 Bucket Deployments. - Source and Environment changes of AWS CodeBuild Projects. -- VTL mapping template changes for AppSync Resolvers and Functions +- VTL mapping template changes for AppSync Resolvers and Functions. +- Schema changes for AppSync GraphQL Apis. **⚠ 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/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts index 03daa7b7b8daf..6598287eded5b 100644 --- a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts @@ -413,6 +413,8 @@ const RESOURCE_TYPE_ATTRIBUTES_FORMATS: { [type: string]: { [attribute: string]: }, 'AWS::DynamoDB::Table': { Arn: stdSlashResourceArnFmt }, 'AWS::AppSync::GraphQLApi': { ApiId: appsyncGraphQlApiApiIdFmt }, + 'AWS::AppSync::FunctionConfiguration': { FunctionId: appsyncGraphQlFunctionIDFmt }, + 'AWS::AppSync::DataSource': { Name: appsyncGraphQlDataSourceNameFmt }, }; function iamArnFmt(parts: ArnParts): string { @@ -440,6 +442,16 @@ function appsyncGraphQlApiApiIdFmt(parts: ArnParts): string { return parts.resourceName.split('/')[1]; } +function appsyncGraphQlFunctionIDFmt(parts: ArnParts): string { + // arn:aws:appsync:us-east-1:111111111111:apis//functions/ + return parts.resourceName.split('/')[3]; +} + +function appsyncGraphQlDataSourceNameFmt(parts: ArnParts): string { + // arn:aws:appsync:us-east-1:111111111111:apis//datasources/ + return parts.resourceName.split('/')[3]; +} + interface Intrinsic { readonly name: string; readonly args: any; diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index 5006a39963605..3b7737aa72e1e 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -28,6 +28,7 @@ const RESOURCE_DETECTORS: { [key:string]: HotswapDetector } = { // AppSync 'AWS::AppSync::Resolver': isHotswappableAppSyncChange, 'AWS::AppSync::FunctionConfiguration': isHotswappableAppSyncChange, + 'AWS::AppSync::GraphQLSchema': isHotswappableAppSyncChange, 'AWS::ECS::TaskDefinition': isHotswappableEcsServiceChange, 'AWS::CodeBuild::Project': isHotswappableCodeBuildProjectChange, diff --git a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts index 2ed050c1ed406..1240490b0a8ad 100644 --- a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts +++ b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts @@ -1,5 +1,7 @@ -import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common'; +import { GetSchemaCreationStatusRequest, GetSchemaCreationStatusResponse } from 'aws-sdk/clients/appsync'; +import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys } from './common'; import { ISDK } from '../aws-auth'; + import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; export async function isHotswappableAppSyncChange( @@ -7,23 +9,24 @@ export async function isHotswappableAppSyncChange( ): Promise { const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver'; const isFunction = change.newValue.Type === 'AWS::AppSync::FunctionConfiguration'; + const isGraphQLSchema = change.newValue.Type === 'AWS::AppSync::GraphQLSchema'; - if (!isResolver && !isFunction) { + if (!isResolver && !isFunction && !isGraphQLSchema) { return []; } const ret: ChangeHotswapResult = []; - if (isResolver && change.newValue.Properties?.Kind === 'PIPELINE') { - reportNonHotswappableChange( - ret, - change, - undefined, - 'Pipeline resolvers cannot be hotswapped since they reference the FunctionId of the underlying functions, which cannot be resolved', - ); - return ret; - } - const classifiedChanges = classifyChanges(change, ['RequestMappingTemplate', 'ResponseMappingTemplate']); + const classifiedChanges = classifyChanges(change, [ + 'RequestMappingTemplate', + 'RequestMappingTemplateS3Location', + 'ResponseMappingTemplate', + 'ResponseMappingTemplateS3Location', + 'Code', + 'CodeS3Location', + 'Definition', + 'DefinitionS3Location', + ]); classifiedChanges.reportNonHotswappablePropertyChanges(ret); const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); @@ -49,21 +52,58 @@ export async function isHotswappableAppSyncChange( const sdkProperties: { [name: string]: any } = { ...change.oldValue.Properties, + Definition: change.newValue.Properties?.Definition, + DefinitionS3Location: change.newValue.Properties?.DefinitionS3Location, requestMappingTemplate: change.newValue.Properties?.RequestMappingTemplate, + requestMappingTemplateS3Location: change.newValue.Properties?.RequestMappingTemplateS3Location, responseMappingTemplate: change.newValue.Properties?.ResponseMappingTemplate, + responseMappingTemplateS3Location: change.newValue.Properties?.ResponseMappingTemplateS3Location, + code: change.newValue.Properties?.Code, + codeS3Location: change.newValue.Properties?.CodeS3Location, }; const evaluatedResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression(sdkProperties); const sdkRequestObject = transformObjectKeys(evaluatedResourceProperties, lowerCaseFirstCharacter); + // resolve s3 location files as SDK doesn't take in s3 location but inline code + if (sdkRequestObject.requestMappingTemplateS3Location) { + sdkRequestObject.requestMappingTemplate = (await fetchFileFromS3(sdkRequestObject.requestMappingTemplateS3Location, sdk))?.toString('utf8'); + delete sdkRequestObject.requestMappingTemplateS3Location; + } + if (sdkRequestObject.responseMappingTemplateS3Location) { + sdkRequestObject.responseMappingTemplate = (await fetchFileFromS3(sdkRequestObject.responseMappingTemplateS3Location, sdk))?.toString('utf8'); + delete sdkRequestObject.responseMappingTemplateS3Location; + } + if (sdkRequestObject.definitionS3Location) { + sdkRequestObject.definition = await fetchFileFromS3(sdkRequestObject.definitionS3Location, sdk); + delete sdkRequestObject.definitionS3Location; + } + if (sdkRequestObject.codeS3Location) { + sdkRequestObject.code = await fetchFileFromS3(sdkRequestObject.codeS3Location, sdk); + delete sdkRequestObject.codeS3Location; + } + if (isResolver) { await sdk.appsync().updateResolver(sdkRequestObject).promise(); - } else { + } else if (isFunction) { + const { functions } = await sdk.appsync().listFunctions({ apiId: sdkRequestObject.apiId }).promise(); const { functionId } = functions?.find(fn => fn.name === physicalName) ?? {}; - await sdk.appsync().updateFunction({ - ...sdkRequestObject, - functionId: functionId!, - }).promise(); + await simpleRetry( + () => sdk.appsync().updateFunction({ ...sdkRequestObject, functionId: functionId! }).promise(), + 3, + 'ConcurrentModificationException'); + } else { + let schemaCreationResponse: GetSchemaCreationStatusResponse = await sdk.appsync().startSchemaCreation(sdkRequestObject).promise(); + while (schemaCreationResponse.status && ['PROCESSING', 'DELETING'].some(status => status === schemaCreationResponse.status)) { + await sleep(1000); // poll every second + const getSchemaCreationStatusRequest: GetSchemaCreationStatusRequest = { + apiId: sdkRequestObject.apiId, + }; + schemaCreationResponse = await sdk.appsync().getSchemaCreationStatus(getSchemaCreationStatusRequest).promise(); + } + if (schemaCreationResponse.status === 'FAILED') { + throw new Error(schemaCreationResponse.details); + } } }, }); @@ -71,3 +111,27 @@ export async function isHotswappableAppSyncChange( return ret; } + +async function fetchFileFromS3(s3Url: string, sdk: ISDK) { + const s3PathParts = s3Url.split('/'); + const s3Bucket = s3PathParts[2]; // first two are "s3:" and "" due to s3:// + const s3Key = s3PathParts.splice(3).join('/'); // after removing first three we reconstruct the key + return (await sdk.s3().getObject({ Bucket: s3Bucket, Key: s3Key }).promise()).Body; +} + +async function simpleRetry(fn: () => Promise, numOfRetries: number, errorCodeToRetry: string) { + try { + await fn(); + } catch (error: any) { + if (error && error.code === errorCodeToRetry && numOfRetries > 0) { + await sleep(500); // wait half a second + await simpleRetry(fn, numOfRetries - 1, errorCodeToRetry); + } else { + throw error; + } + } +} + +async function sleep(ms: number) { + return new Promise(ok => setTimeout(ok, ms)); +} diff --git a/packages/aws-cdk/test/api/hotswap/appsync-mapping-templates-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/appsync-mapping-templates-hotswap-deployments.test.ts index f7780b873806b..4882859656a47 100644 --- a/packages/aws-cdk/test/api/hotswap/appsync-mapping-templates-hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/appsync-mapping-templates-hotswap-deployments.test.ts @@ -1,17 +1,25 @@ /* eslint-disable import/order */ -import { AppSync } from 'aws-sdk'; +import { AppSync, S3 } from 'aws-sdk'; import * as setup from './hotswap-test-setup'; import { HotswapMode } from '../../../lib/api/hotswap/common'; let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; let mockUpdateResolver: (params: AppSync.UpdateResolverRequest) => AppSync.UpdateResolverResponse; let mockUpdateFunction: (params: AppSync.UpdateFunctionRequest) => AppSync.UpdateFunctionResponse; +let mockStartSchemaCreation: (params: AppSync.StartSchemaCreationRequest) => AppSync.StartSchemaCreationResponse; +let mockS3GetObject: (params: S3.GetObjectRequest) => S3.GetObjectOutput; beforeEach(() => { hotswapMockSdkProvider = setup.setupHotswapTests(); mockUpdateResolver = jest.fn(); mockUpdateFunction = jest.fn(); - hotswapMockSdkProvider.stubAppSync({ updateResolver: mockUpdateResolver, updateFunction: mockUpdateFunction }); + mockStartSchemaCreation = jest.fn(); + hotswapMockSdkProvider.stubAppSync({ + updateResolver: mockUpdateResolver, + updateFunction: mockUpdateFunction, + startSchemaCreation: mockStartSchemaCreation, + }); + }); describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => { @@ -29,19 +37,15 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { expect(deployStackResult).toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(deployStackResult?.noOp).toEqual(true); expect(mockUpdateFunction).not.toHaveBeenCalled(); @@ -115,7 +119,216 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); }); - test('does not call the updateResolver() API when it receives only a mapping template difference in a Pipeline Resolver', async () => { + test('calls the updateResolver() API when it receives only a mapping template difference s3 location in a Unit Resolver', async () => { + // GIVEN + mockS3GetObject = jest.fn().mockImplementation(async () => { + return { Body: 'template defined in s3' }; + }); + hotswapMockSdkProvider.stubS3({ getObject: mockS3GetObject }); + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + Kind: 'UNIT', + RequestMappingTemplateS3Location: 's3://test-bucket/old_location', + ResponseMappingTemplate: '## original response template', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncResolver', + 'AWS::AppSync::Resolver', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/types/Query/resolvers/myField', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + Kind: 'UNIT', + RequestMappingTemplateS3Location: 's3://test-bucket/path/to/key', + ResponseMappingTemplate: '## original response template', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateResolver).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + typeName: 'Query', + fieldName: 'myField', + kind: 'UNIT', + requestMappingTemplate: 'template defined in s3', + responseMappingTemplate: '## original response template', + }); + expect(mockS3GetObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'path/to/key', + }); + }); + + test('calls the updateResolver() API when it receives only a code s3 location in a Pipeline Resolver', async () => { + // GIVEN + mockS3GetObject = jest.fn().mockImplementation(async () => { + return { Body: 'code defined in s3' }; + }); + hotswapMockSdkProvider.stubS3({ getObject: mockS3GetObject }); + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + PipelineConfig: ['function1'], + CodeS3Location: 's3://test-bucket/old_location', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncResolver', + 'AWS::AppSync::Resolver', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/types/Query/resolvers/myField', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + PipelineConfig: ['function1'], + CodeS3Location: 's3://test-bucket/path/to/key', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateResolver).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + typeName: 'Query', + fieldName: 'myField', + pipelineConfig: ['function1'], + code: 'code defined in s3', + }); + expect(mockS3GetObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'path/to/key', + }); + }); + + test('calls the updateResolver() API when it receives only a code difference in a Pipeline Resolver', async () => { + // GIVEN + hotswapMockSdkProvider.stubS3({ getObject: mockS3GetObject }); + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + PipelineConfig: ['function1'], + Code: 'old code', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncResolver', + 'AWS::AppSync::Resolver', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/types/Query/resolvers/myField', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + PipelineConfig: ['function1'], + Code: 'new code', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateResolver).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + typeName: 'Query', + fieldName: 'myField', + pipelineConfig: ['function1'], + code: 'new code', + }); + }); + + test('calls the updateResolver() API when it receives only a mapping template difference in a Pipeline Resolver', async () => { // GIVEN setup.setCurrentCfnStackTemplate({ Resources: { @@ -137,6 +350,13 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }, }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncResolver', + 'AWS::AppSync::Resolver', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/types/Query/resolvers/myField', + ), + ); const cdkStackArtifact = setup.cdkStackArtifactOf({ template: { Resources: { @@ -160,24 +380,20 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).toBeUndefined(); - expect(mockUpdateFunction).not.toHaveBeenCalled(); - expect(mockUpdateResolver).not.toHaveBeenCalled(); - } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(deployStackResult?.noOp).toEqual(true); - expect(mockUpdateFunction).not.toHaveBeenCalled(); - expect(mockUpdateResolver).not.toHaveBeenCalled(); - } + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateResolver).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + typeName: 'Query', + fieldName: 'myField', + kind: 'PIPELINE', + pipelineConfig: ['function1'], + requestMappingTemplate: '## new request template', + responseMappingTemplate: '## original response template', + }); }); test(`when it receives a change that is not a mapping template difference in a Resolver, it does not call the updateResolver() API in CLASSIC mode @@ -225,19 +441,15 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { expect(deployStackResult).toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).toHaveBeenCalledWith({ @@ -280,19 +492,15 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { expect(deployStackResult).toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(deployStackResult?.noOp).toEqual(true); expect(mockUpdateFunction).not.toHaveBeenCalled(); @@ -360,6 +568,74 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); }); + test('calls the updateFunction() API when it receives only a mapping template s3 location difference in a Function', async () => { + // GIVEN + mockS3GetObject = jest.fn().mockImplementation(async () => { + return { Body: 'template defined in s3' }; + }); + hotswapMockSdkProvider.stubS3({ getObject: mockS3GetObject }); + const mockListFunctions = jest.fn().mockReturnValue({ functions: [{ name: 'my-function', functionId: 'functionId' }] }); + hotswapMockSdkProvider.stubAppSync({ listFunctions: mockListFunctions, updateFunction: mockUpdateFunction }); + + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::FunctionConfiguration', + Properties: { + Name: 'my-function', + ApiId: 'apiId', + DataSourceName: 'my-datasource', + FunctionVersion: '2018-05-29', + RequestMappingTemplate: '## original request template', + ResponseMappingTemplateS3Location: 's3://test-bucket/old_location', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::FunctionConfiguration', + Properties: { + Name: 'my-function', + ApiId: 'apiId', + DataSourceName: 'my-datasource', + FunctionVersion: '2018-05-29', + RequestMappingTemplate: '## original request template', + ResponseMappingTemplateS3Location: 's3://test-bucket/path/to/key', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateFunction).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + functionId: 'functionId', + functionVersion: '2018-05-29', + name: 'my-function', + requestMappingTemplate: '## original request template', + responseMappingTemplate: 'template defined in s3', + }); + expect(mockS3GetObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'path/to/key', + }); + }); + test(`when it receives a change that is not a mapping template difference in a Function, it does not call the updateFunction() API in CLASSIC mode but does in HOTSWAP_ONLY mode`, async () => { @@ -401,19 +677,15 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { expect(deployStackResult).toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(mockUpdateFunction).toHaveBeenCalledWith({ apiId: 'apiId', @@ -459,23 +731,305 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { expect(deployStackResult).toBeUndefined(); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(deployStackResult?.noOp).toEqual(true); expect(mockUpdateFunction).not.toHaveBeenCalled(); expect(mockUpdateResolver).not.toHaveBeenCalled(); } }); + + test('calls the startSchemaCreation() API when it receives only a definition difference in a graphql schema', async () => { + // GIVEN + mockStartSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'SUCCESS' }); + hotswapMockSdkProvider.stubAppSync({ startSchemaCreation: mockStartSchemaCreation }); + + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'original graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncGraphQLSchema', + 'AWS::AppSync::GraphQLSchema', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/schema/my-schema', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'new graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockStartSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + definition: 'new graphqlSchema', + }); + }); + + test('calls the startSchemaCreation() API when it receives only a definition s3 location difference in a graphql schema', async () => { + // GIVEN + mockS3GetObject = jest.fn().mockImplementation(async () => { + return { Body: 'schema defined in s3' }; + }); + hotswapMockSdkProvider.stubS3({ getObject: mockS3GetObject }); + mockStartSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'SUCCESS' }); + hotswapMockSdkProvider.stubAppSync({ startSchemaCreation: mockStartSchemaCreation }); + + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + DefinitionS3Location: 's3://test-bucket/old_location', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncGraphQLSchema', + 'AWS::AppSync::GraphQLSchema', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/schema/my-schema', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + DefinitionS3Location: 's3://test-bucket/path/to/key', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockStartSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + definition: 'schema defined in s3', + }); + + expect(mockS3GetObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'path/to/key', + }); + }); + + test('does not call startSchemaCreation() API when a resource with type that is not AWS::AppSync::GraphQLSchema but has the same properties is change', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::NotGraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'original graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncGraphQLSchema', + 'AWS::AppSync::GraphQLSchema', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/schema/my-schema', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::NotGraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'new graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + if (hotswapMode === HotswapMode.FALL_BACK) { + expect(deployStackResult).toBeUndefined(); + expect(mockStartSchemaCreation).not.toHaveBeenCalled(); + } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockStartSchemaCreation).not.toHaveBeenCalled(); + } + }); + + test('calls the startSchemaCreation() and waits for schema creation to stabilize before finishing', async () => { + // GIVEN + mockStartSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'PROCESSING' }); + const mockGetSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'SUCCESS' }); + hotswapMockSdkProvider.stubAppSync({ startSchemaCreation: mockStartSchemaCreation, getSchemaCreationStatus: mockGetSchemaCreation }); + + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'original graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncGraphQLSchema', + 'AWS::AppSync::GraphQLSchema', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/schema/my-schema', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'new graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockStartSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + definition: 'new graphqlSchema', + }); + expect(mockGetSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + }); + }); + + test('calls the startSchemaCreation() and throws if schema creation fails', async () => { + // GIVEN + mockStartSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'PROCESSING' }); + const mockGetSchemaCreation = jest.fn().mockReturnValueOnce({ status: 'FAILED', details: 'invalid schema' }); + hotswapMockSdkProvider.stubAppSync({ startSchemaCreation: mockStartSchemaCreation, getSchemaCreationStatus: mockGetSchemaCreation }); + + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'original graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncGraphQLSchema', + 'AWS::AppSync::GraphQLSchema', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/schema/my-schema', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncGraphQLSchema: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + ApiId: 'apiId', + Definition: 'new graphqlSchema', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + await expect(() => hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact)).rejects.toThrow('invalid schema'); + + // THEN + expect(mockStartSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + definition: 'new graphqlSchema', + }); + expect(mockGetSchemaCreation).toHaveBeenCalledWith({ + apiId: 'apiId', + }); + }); }); 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 3483ae67118dc..63bded36370f1 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -172,6 +172,10 @@ export class HotswapMockSdkProvider { this.mockSdkProvider.stubGetEndpointSuffix(stub); } + public stubS3(stubs: SyncHandlerSubsetOf) { + this.mockSdkProvider.stubS3(stubs); + } + public tryHotswapDeployment( hotswapMode: HotswapMode, stackArtifact: cxapi.CloudFormationStackArtifact,