diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index f1c63975641ac..027dc574572f5 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -368,6 +368,7 @@ 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 **⚠ 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. @@ -549,8 +550,8 @@ Some of the interesting keys that can be used in the JSON configuration files: ``` If specified, the command in the `build` key will be executed immediately before synthesis. -This can be used to build Lambda Functions, CDK Application code, or other assets. -`build` cannot be specified on the command line or in the User configuration, +This can be used to build Lambda Functions, CDK Application code, or other assets. +`build` cannot be specified on the command line or in the User configuration, and must be specified in the Project configuration. The command specified in `build` will be executed by the "watch" process before deployment. diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index b805597cc010b..b703bb6130cc5 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -63,6 +63,7 @@ export interface ISDK { stepFunctions(): AWS.StepFunctions; codeBuild(): AWS.CodeBuild cloudWatchLogs(): AWS.CloudWatchLogs; + appsync(): AWS.AppSync; } /** @@ -190,6 +191,10 @@ export class SDK implements ISDK { return this.wrapServiceErrorHandling(new AWS.CloudWatchLogs(this.config)); } + public appsync(): AWS.AppSync { + return this.wrapServiceErrorHandling(new AWS.AppSync(this.config)); + } + public async currentAccount(): Promise { // Get/refresh if necessary before we can access `accessKeyId` await this.forceCredentialRetrieval(); diff --git a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts index f6d51969af5d5..61d6a750b4482 100644 --- a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts @@ -342,6 +342,7 @@ const RESOURCE_TYPE_ATTRIBUTES_FORMATS: { [type: string]: { [attribute: string]: // the name attribute of the EventBus is the same as the Ref Name: parts => parts.resourceName, }, + 'AWS::AppSync::GraphQLApi': { ApiId: appsyncGraphQlApiApiIdFmt }, }; function iamArnFmt(parts: ArnParts): string { @@ -364,6 +365,11 @@ function stdSlashResourceArnFmt(parts: ArnParts): string { return `arn:${parts.partition}:${parts.service}:${parts.region}:${parts.account}:${parts.resourceType}/${parts.resourceName}`; } +function appsyncGraphQlApiApiIdFmt(parts: ArnParts): string { + // arn:aws:appsync:us-east-1:111111111111:apis/ + return parts.resourceName.split('/')[1]; +} + 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 aebeae1058b34..441d4db9354b8 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -5,6 +5,7 @@ import { print } from '../logging'; import { ISDK, Mode, SdkProvider } from './aws-auth'; import { DeployStackResult } from './deploy-stack'; import { EvaluateCloudFormationTemplate, LazyListStackResources } from './evaluate-cloudformation-template'; +import { isHotswappableAppSyncChange } from './hotswap/appsync-mapping-templates'; import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects'; import { ICON, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate } from './hotswap/common'; import { isHotswappableEcsServiceChange } from './hotswap/ecs-services'; @@ -79,6 +80,7 @@ async function findAllHotswappableChanges( isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), + isHotswappableAppSyncChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), ]); } } diff --git a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts new file mode 100644 index 0000000000000..f7e4570a77b98 --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts @@ -0,0 +1,82 @@ +import * as AWS from 'aws-sdk'; +import { ISDK } from '../aws-auth'; +import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; +import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys } from './common'; + +export async function isHotswappableAppSyncChange( + logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver'; + const isFunction = change.newValue.Type === 'AWS::AppSync::FunctionConfiguration'; + + if (!isResolver && !isFunction) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + for (const updatedPropName in change.propertyUpdates) { + if (updatedPropName !== 'RequestMappingTemplate' && updatedPropName !== 'ResponseMappingTemplate') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } + + const resourceProperties = change.newValue.Properties; + if (isResolver && resourceProperties?.Kind === 'PIPELINE') { + // Pipeline resolvers can't be hotswapped as they reference + // the FunctionId of the underlying functions, which can't be resolved. + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const resourcePhysicalName = await evaluateCfnTemplate.establishResourcePhysicalName(logicalId, isFunction ? resourceProperties?.Name : undefined); + if (!resourcePhysicalName) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const evaluatedResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression(resourceProperties); + const sdkCompatibleResourceProperties = transformObjectKeys(evaluatedResourceProperties, lowerCaseFirstCharacter); + + if (isResolver) { + // Resolver physical name is the ARN in the format: + // arn:aws:appsync:us-east-1:111111111111:apis//types//resolvers/. + // We'll use `.` as the resolver name. + const arnParts = resourcePhysicalName.split('/'); + const resolverName = `${arnParts[3]}.${arnParts[5]}`; + return new ResolverHotswapOperation(resolverName, sdkCompatibleResourceProperties); + } else { + return new FunctionHotswapOperation(resourcePhysicalName, sdkCompatibleResourceProperties); + } +} + +class ResolverHotswapOperation implements HotswapOperation { + public readonly service = 'appsync' + public readonly resourceNames: string[]; + + constructor(resolverName: string, private readonly updateResolverRequest: AWS.AppSync.UpdateResolverRequest) { + this.resourceNames = [`AppSync resolver '${resolverName}'`]; + } + + public async apply(sdk: ISDK): Promise { + return sdk.appsync().updateResolver(this.updateResolverRequest).promise(); + } +} + +class FunctionHotswapOperation implements HotswapOperation { + public readonly service = 'appsync' + public readonly resourceNames: string[]; + + constructor( + private readonly functionName: string, + private readonly updateFunctionRequest: Omit, + ) { + this.resourceNames = [`AppSync function '${functionName}'`]; + } + + public async apply(sdk: ISDK): Promise { + const { functions } = await sdk.appsync().listFunctions({ apiId: this.updateFunctionRequest.apiId }).promise(); + const { functionId } = functions?.find(fn => fn.name === this.functionName) ?? {}; + const request = { + ...this.updateFunctionRequest, + functionId: functionId!, + }; + return sdk.appsync().updateFunction(request).promise(); + } +} 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 new file mode 100644 index 0000000000000..93b83de5cd96e --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/appsync-mapping-templates-hotswap-deployments.test.ts @@ -0,0 +1,367 @@ +import { AppSync } from 'aws-sdk'; +import * as setup from './hotswap-test-setup'; + +let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; +let mockUpdateResolver: (params: AppSync.UpdateResolverRequest) => AppSync.UpdateResolverResponse; +let mockUpdateFunction: (params: AppSync.UpdateFunctionRequest) => AppSync.UpdateFunctionResponse; + +beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + mockUpdateResolver = jest.fn(); + mockUpdateFunction = jest.fn(); + hotswapMockSdkProvider.stubAppSync({ updateResolver: mockUpdateResolver, updateFunction: mockUpdateFunction }); +}); + +test('returns undefined when a new Resolver is added to the Stack', async () => { + // GIVEN + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); +}); + +test('calls the updateResolver() API when it receives only a mapping template difference in a Unit Resolver', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + Kind: 'UNIT', + RequestMappingTemplate: '## original request template', + 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', + RequestMappingTemplate: '## new request template', + ResponseMappingTemplate: '## original response template', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateResolver).toHaveBeenCalledWith({ + apiId: 'apiId', + dataSourceName: 'my-datasource', + typeName: 'Query', + fieldName: 'myField', + kind: 'UNIT', + requestMappingTemplate: '## new request template', + responseMappingTemplate: '## original response template', + }); +}); + +test('does not call the updateResolver() API when it receives only a mapping template difference in a Pipeline Resolver', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + Kind: 'PIPELINE', + PipelineConfig: ['function1'], + RequestMappingTemplate: '## original request template', + ResponseMappingTemplate: '## original response template', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + ApiId: 'apiId', + FieldName: 'myField', + TypeName: 'Query', + DataSourceName: 'my-datasource', + Kind: 'PIPELINE', + PipelineConfig: ['function1'], + RequestMappingTemplate: '## new request template', + ResponseMappingTemplate: '## original response template', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateResolver).not.toHaveBeenCalled(); +}); + +test('does not call the updateResolver() API when it receives a change that is not a mapping template difference in a Resolver', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + RequestMappingTemplate: '## original template', + FieldName: 'oldField', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::Resolver', + Properties: { + RequestMappingTemplate: '## new template', + FieldName: 'newField', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateResolver).not.toHaveBeenCalled(); +}); + +test('does not call the updateResolver() API when a resource with type that is not AWS::AppSync::Resolver but has the same properties is changed', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::NotAResolver', + Properties: { + RequestMappingTemplate: '## original template', + FieldName: 'oldField', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncResolver: { + Type: 'AWS::AppSync::NotAResolver', + Properties: { + RequestMappingTemplate: '## new template', + FieldName: 'newField', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateResolver).not.toHaveBeenCalled(); +}); + +test('calls the updateFunction() API when it receives only a mapping template difference in a Function', async () => { + // GIVEN + 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', + ResponseMappingTemplate: '## original response template', + }, + 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', + ResponseMappingTemplate: '## new response template', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(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: '## new response template', + }); +}); + +test('does not call the updateFunction() API when it receives a change that is not a mapping template difference in a Function', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::FunctionConfiguration', + Properties: { + RequestMappingTemplate: '## original template', + Name: 'my-function', + DataSourceName: 'my-datasource', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::FunctionConfiguration', + Properties: { + RequestMappingTemplate: '## new template', + Name: 'my-function', + DataSourceName: 'new-datasource', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateFunction).not.toHaveBeenCalled(); +}); + +test('does not call the updateFunction() API when a resource with type that is not AWS::AppSync::FunctionConfiguration but has the same properties is changed', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::NotAFunctionConfiguration', + Properties: { + RequestMappingTemplate: '## original template', + Name: 'my-function', + DataSourceName: 'my-datasource', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncFunction: { + Type: 'AWS::AppSync::NotAFunctionConfiguration', + Properties: { + RequestMappingTemplate: '## new template', + Name: 'my-resolver', + DataSourceName: 'my-datasource', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateFunction).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 7b6aebb9a81ee..e1160e43cb781 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -114,6 +114,10 @@ export class HotswapMockSdkProvider { }); } + public stubAppSync(stubs: SyncHandlerSubsetOf) { + this.mockSdkProvider.stubAppSync(stubs); + } + public setInvokeLambdaMock(mockInvokeLambda: (input: lambda.InvocationRequest) => lambda.InvocationResponse) { this.mockSdkProvider.stubLambda({ invoke: mockInvokeLambda, diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index bb8eaae251c47..7b721cdf57a12 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -118,6 +118,10 @@ export class MockSdkProvider extends SdkProvider { (this.sdk as any).cloudWatchLogs = jest.fn().mockReturnValue(partialAwsService(stubs)); } + public stubAppSync(stubs: SyncHandlerSubsetOf) { + (this.sdk as any).appsync = jest.fn().mockReturnValue(partialAwsService(stubs)); + } + public stubGetEndpointSuffix(stub: () => string) { this.sdk.getEndpointSuffix = stub; } @@ -139,6 +143,7 @@ export class MockSdk implements ISDK { public readonly stepFunctions = jest.fn(); public readonly codeBuild = jest.fn(); public readonly cloudWatchLogs = jest.fn(); + public readonly appsync = jest.fn(); public readonly getEndpointSuffix = jest.fn(); public readonly appendCustomUserAgent = jest.fn(); public readonly removeCustomUserAgent = jest.fn(); @@ -161,6 +166,13 @@ export class MockSdk implements ISDK { this.cloudWatchLogs.mockReturnValue(partialAwsService(stubs)); } + /** + * Replace the AppSync client with the given object + */ + public stubAppSync(stubs: SyncHandlerSubsetOf) { + this.appsync.mockReturnValue(partialAwsService(stubs)); + } + /** * Replace the ECR client with the given object */