diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index 0766c82573e6d..86fc0d43aa3c6 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -29,6 +29,7 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { 'AWS::AppSync::Resolver': isHotswappableAppSyncChange, 'AWS::AppSync::FunctionConfiguration': isHotswappableAppSyncChange, 'AWS::AppSync::GraphQLSchema': isHotswappableAppSyncChange, + 'AWS::AppSync::ApiKey': 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 1240490b0a8ad..a5a2156409458 100644 --- a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts +++ b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts @@ -10,8 +10,8 @@ export async function isHotswappableAppSyncChange( 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 && !isGraphQLSchema) { + const isAPIKey = change.newValue.Type === 'AWS::AppSync::ApiKey'; + if (!isResolver && !isFunction && !isGraphQLSchema && !isAPIKey) { return []; } @@ -26,6 +26,7 @@ export async function isHotswappableAppSyncChange( 'CodeS3Location', 'Definition', 'DefinitionS3Location', + 'Expires', ]); classifiedChanges.reportNonHotswappablePropertyChanges(ret); @@ -60,6 +61,7 @@ export async function isHotswappableAppSyncChange( responseMappingTemplateS3Location: change.newValue.Properties?.ResponseMappingTemplateS3Location, code: change.newValue.Properties?.Code, codeS3Location: change.newValue.Properties?.CodeS3Location, + expires: change.newValue.Properties?.Expires, }; const evaluatedResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression(sdkProperties); const sdkRequestObject = transformObjectKeys(evaluatedResourceProperties, lowerCaseFirstCharacter); @@ -74,11 +76,11 @@ export async function isHotswappableAppSyncChange( delete sdkRequestObject.responseMappingTemplateS3Location; } if (sdkRequestObject.definitionS3Location) { - sdkRequestObject.definition = await fetchFileFromS3(sdkRequestObject.definitionS3Location, sdk); + sdkRequestObject.definition = (await fetchFileFromS3(sdkRequestObject.definitionS3Location, sdk))?.toString('utf8'); delete sdkRequestObject.definitionS3Location; } if (sdkRequestObject.codeS3Location) { - sdkRequestObject.code = await fetchFileFromS3(sdkRequestObject.codeS3Location, sdk); + sdkRequestObject.code = (await fetchFileFromS3(sdkRequestObject.codeS3Location, sdk))?.toString('utf8'); delete sdkRequestObject.codeS3Location; } @@ -86,13 +88,22 @@ export async function isHotswappableAppSyncChange( await sdk.appsync().updateResolver(sdkRequestObject).promise(); } else if (isFunction) { + // Function version is only applicable when using VTL and mapping templates + // Runtime only applicable when using code (JS mapping templates) + if (sdkRequestObject.code) { + delete sdkRequestObject.functionVersion; + } else { + delete sdkRequestObject.runtime; + } + const { functions } = await sdk.appsync().listFunctions({ apiId: sdkRequestObject.apiId }).promise(); const { functionId } = functions?.find(fn => fn.name === physicalName) ?? {}; + // Updating multiple functions at the same time or along with graphql schema results in `ConcurrentModificationException` await simpleRetry( () => sdk.appsync().updateFunction({ ...sdkRequestObject, functionId: functionId! }).promise(), - 3, + 5, 'ConcurrentModificationException'); - } else { + } else if (isGraphQLSchema) { 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 @@ -104,6 +115,15 @@ export async function isHotswappableAppSyncChange( if (schemaCreationResponse.status === 'FAILED') { throw new Error(schemaCreationResponse.details); } + } else { //isApiKey + if (!sdkRequestObject.id) { + // ApiKeyId is optional in CFN but required in SDK. Grab the KeyId from physicalArn if not available as part of CFN template + const arnParts = physicalName?.split('/'); + if (arnParts && arnParts.length === 4) { + sdkRequestObject.id = arnParts[3]; + } + } + await sdk.appsync().updateApiKey(sdkRequestObject).promise(); } }, }); @@ -124,7 +144,7 @@ async function simpleRetry(fn: () => Promise, numOfRetries: number, errorCo await fn(); } catch (error: any) { if (error && error.code === errorCodeToRetry && numOfRetries > 0) { - await sleep(500); // wait half a second + await sleep(1000); // wait a whole second await simpleRetry(fn, numOfRetries - 1, errorCodeToRetry); } else { throw error; 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 4882859656a47..9bc8e6a51f84e 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 @@ -6,6 +6,7 @@ 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 mockUpdateApiKey: (params: AppSync.UpdateApiKeyRequest) => AppSync.UpdateApiKeyResponse; let mockStartSchemaCreation: (params: AppSync.StartSchemaCreationRequest) => AppSync.StartSchemaCreationResponse; let mockS3GetObject: (params: S3.GetObjectRequest) => S3.GetObjectOutput; @@ -13,10 +14,12 @@ beforeEach(() => { hotswapMockSdkProvider = setup.setupHotswapTests(); mockUpdateResolver = jest.fn(); mockUpdateFunction = jest.fn(); + mockUpdateApiKey = jest.fn(); mockStartSchemaCreation = jest.fn(); hotswapMockSdkProvider.stubAppSync({ updateResolver: mockUpdateResolver, updateFunction: mockUpdateFunction, + updateApiKey: mockUpdateApiKey, startSchemaCreation: mockStartSchemaCreation, }); @@ -568,6 +571,127 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); }); + test('calls the updateFunction() API with function version when it receives both function version and runtime with a mapping template 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', + Runtime: 'APPSYNC_JS', + 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', + Runtime: 'APPSYNC_JS', + RequestMappingTemplate: '## original request template', + ResponseMappingTemplate: '## new response template', + }, + 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: '## new response template', + }); + }); + + test('calls the updateFunction() API with runtime when it receives both function version and runtime with code 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', + Runtime: 'APPSYNC_JS', + Code: 'old test code', + }, + 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', + Runtime: 'APPSYNC_JS', + Code: 'new test code', + }, + 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', + runtime: 'APPSYNC_JS', + name: 'my-function', + code: 'new test code', + }); + }); + 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 () => { @@ -1032,4 +1156,110 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot apiId: 'apiId', }); }); + + test('calls the updateApiKey() API when it receives only a expires property difference in an AppSync ApiKey', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncApiKey: { + Type: 'AWS::AppSync::ApiKey', + Properties: { + ApiId: 'apiId', + Expires: 1000, + Id: 'key-id', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncApiKey', + 'AWS::AppSync::ApiKey', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/apikeys/api-key-id', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncApiKey: { + Type: 'AWS::AppSync::ApiKey', + Properties: { + ApiId: 'apiId', + Expires: 1001, + Id: 'key-id', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateApiKey).toHaveBeenCalledWith({ + apiId: 'apiId', + expires: 1001, + id: 'key-id', + }); + }); + + test('calls the updateApiKey() API when it receives only a expires property difference and no api-key-id in an AppSync ApiKey', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + AppSyncApiKey: { + Type: 'AWS::AppSync::ApiKey', + Properties: { + ApiId: 'apiId', + Expires: 1000, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf( + 'AppSyncApiKey', + 'AWS::AppSync::ApiKey', + 'arn:aws:appsync:us-east-1:111111111111:apis/apiId/apikeys/api-key-id', + ), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + AppSyncApiKey: { + Type: 'AWS::AppSync::ApiKey', + Properties: { + ApiId: 'apiId', + Expires: 1001, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateApiKey).toHaveBeenCalledWith({ + apiId: 'apiId', + expires: 1001, + id: 'api-key-id', + }); + }); });