Skip to content

Commit

Permalink
fix(cdk): Add AppSync:Api_Key as hot swappable and fix a bug with App…
Browse files Browse the repository at this point in the history
…Sync.function (#27559)

Add AppSync:Api_Key as hots wappable and fix the bug where AppSync.function doesn't allow setting version and runtime

1. Allow `expires` property of resource `AWS::AppSync::Api_Key` to be hot-swappable
2. Read the Api_Key_id from the physical ARN if not available from resource properties. (It's optional in CFN but mandatory in SDK)
3. UpdateFunction doesn't [allow](https://docs.aws.amazon.com/appsync/latest/APIReference/API_UpdateFunction.html) setting both `functionVersion` and `runtime` in the SDK (allowed in CFN). Update to remove one based on if `code` is provided or `mappingTemplates`
4. Fix a bug where the file returned from S3 was not being decoded from buffer.
5. Increase the timeout and number of retries for concurrent modification of AppSync.Functions.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
Amplifiyer authored and mrgrain committed Nov 1, 2023
1 parent 605368c commit b19320e
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 7 deletions.
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 27 additions & 7 deletions packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}

Expand All @@ -26,6 +26,7 @@ export async function isHotswappableAppSyncChange(
'CodeS3Location',
'Definition',
'DefinitionS3Location',
'Expires',
]);
classifiedChanges.reportNonHotswappablePropertyChanges(ret);

Expand Down Expand Up @@ -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);
Expand All @@ -74,25 +76,34 @@ 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;
}

if (isResolver) {
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
Expand All @@ -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();
}
},
});
Expand All @@ -124,7 +144,7 @@ async function simpleRetry(fn: () => Promise<any>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ 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;

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,
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
});
});
});

0 comments on commit b19320e

Please sign in to comment.