Skip to content

Commit 0ea6313

Browse files
authored
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*
1 parent 48acd37 commit 0ea6313

6 files changed

+714
-78
lines changed

packages/aws-cdk/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@ Hotswapping is currently supported for the following changes
421421
- Container asset changes of AWS ECS Services.
422422
- Website asset changes of AWS S3 Bucket Deployments.
423423
- Source and Environment changes of AWS CodeBuild Projects.
424-
- VTL mapping template changes for AppSync Resolvers and Functions
424+
- VTL mapping template changes for AppSync Resolvers and Functions.
425+
- Schema changes for AppSync GraphQL Apis.
425426

426427
**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
427428
For this reason, only use it for development purposes.

packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts

+12
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,8 @@ const RESOURCE_TYPE_ATTRIBUTES_FORMATS: { [type: string]: { [attribute: string]:
413413
},
414414
'AWS::DynamoDB::Table': { Arn: stdSlashResourceArnFmt },
415415
'AWS::AppSync::GraphQLApi': { ApiId: appsyncGraphQlApiApiIdFmt },
416+
'AWS::AppSync::FunctionConfiguration': { FunctionId: appsyncGraphQlFunctionIDFmt },
417+
'AWS::AppSync::DataSource': { Name: appsyncGraphQlDataSourceNameFmt },
416418
};
417419

418420
function iamArnFmt(parts: ArnParts): string {
@@ -440,6 +442,16 @@ function appsyncGraphQlApiApiIdFmt(parts: ArnParts): string {
440442
return parts.resourceName.split('/')[1];
441443
}
442444

445+
function appsyncGraphQlFunctionIDFmt(parts: ArnParts): string {
446+
// arn:aws:appsync:us-east-1:111111111111:apis/<apiId>/functions/<functionId>
447+
return parts.resourceName.split('/')[3];
448+
}
449+
450+
function appsyncGraphQlDataSourceNameFmt(parts: ArnParts): string {
451+
// arn:aws:appsync:us-east-1:111111111111:apis/<apiId>/datasources/<name>
452+
return parts.resourceName.split('/')[3];
453+
}
454+
443455
interface Intrinsic {
444456
readonly name: string;
445457
readonly args: any;

packages/aws-cdk/lib/api/hotswap-deployments.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const RESOURCE_DETECTORS: { [key:string]: HotswapDetector } = {
2828
// AppSync
2929
'AWS::AppSync::Resolver': isHotswappableAppSyncChange,
3030
'AWS::AppSync::FunctionConfiguration': isHotswappableAppSyncChange,
31+
'AWS::AppSync::GraphQLSchema': isHotswappableAppSyncChange,
3132

3233
'AWS::ECS::TaskDefinition': isHotswappableEcsServiceChange,
3334
'AWS::CodeBuild::Project': isHotswappableCodeBuildProjectChange,

packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts

+81-17
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common';
1+
import { GetSchemaCreationStatusRequest, GetSchemaCreationStatusResponse } from 'aws-sdk/clients/appsync';
2+
import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys } from './common';
23
import { ISDK } from '../aws-auth';
4+
35
import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template';
46

57
export async function isHotswappableAppSyncChange(
68
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
79
): Promise<ChangeHotswapResult> {
810
const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver';
911
const isFunction = change.newValue.Type === 'AWS::AppSync::FunctionConfiguration';
12+
const isGraphQLSchema = change.newValue.Type === 'AWS::AppSync::GraphQLSchema';
1013

11-
if (!isResolver && !isFunction) {
14+
if (!isResolver && !isFunction && !isGraphQLSchema) {
1215
return [];
1316
}
1417

1518
const ret: ChangeHotswapResult = [];
16-
if (isResolver && change.newValue.Properties?.Kind === 'PIPELINE') {
17-
reportNonHotswappableChange(
18-
ret,
19-
change,
20-
undefined,
21-
'Pipeline resolvers cannot be hotswapped since they reference the FunctionId of the underlying functions, which cannot be resolved',
22-
);
23-
return ret;
24-
}
2519

26-
const classifiedChanges = classifyChanges(change, ['RequestMappingTemplate', 'ResponseMappingTemplate']);
20+
const classifiedChanges = classifyChanges(change, [
21+
'RequestMappingTemplate',
22+
'RequestMappingTemplateS3Location',
23+
'ResponseMappingTemplate',
24+
'ResponseMappingTemplateS3Location',
25+
'Code',
26+
'CodeS3Location',
27+
'Definition',
28+
'DefinitionS3Location',
29+
]);
2730
classifiedChanges.reportNonHotswappablePropertyChanges(ret);
2831

2932
const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps);
@@ -49,25 +52,86 @@ export async function isHotswappableAppSyncChange(
4952

5053
const sdkProperties: { [name: string]: any } = {
5154
...change.oldValue.Properties,
55+
Definition: change.newValue.Properties?.Definition,
56+
DefinitionS3Location: change.newValue.Properties?.DefinitionS3Location,
5257
requestMappingTemplate: change.newValue.Properties?.RequestMappingTemplate,
58+
requestMappingTemplateS3Location: change.newValue.Properties?.RequestMappingTemplateS3Location,
5359
responseMappingTemplate: change.newValue.Properties?.ResponseMappingTemplate,
60+
responseMappingTemplateS3Location: change.newValue.Properties?.ResponseMappingTemplateS3Location,
61+
code: change.newValue.Properties?.Code,
62+
codeS3Location: change.newValue.Properties?.CodeS3Location,
5463
};
5564
const evaluatedResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression(sdkProperties);
5665
const sdkRequestObject = transformObjectKeys(evaluatedResourceProperties, lowerCaseFirstCharacter);
5766

67+
// resolve s3 location files as SDK doesn't take in s3 location but inline code
68+
if (sdkRequestObject.requestMappingTemplateS3Location) {
69+
sdkRequestObject.requestMappingTemplate = (await fetchFileFromS3(sdkRequestObject.requestMappingTemplateS3Location, sdk))?.toString('utf8');
70+
delete sdkRequestObject.requestMappingTemplateS3Location;
71+
}
72+
if (sdkRequestObject.responseMappingTemplateS3Location) {
73+
sdkRequestObject.responseMappingTemplate = (await fetchFileFromS3(sdkRequestObject.responseMappingTemplateS3Location, sdk))?.toString('utf8');
74+
delete sdkRequestObject.responseMappingTemplateS3Location;
75+
}
76+
if (sdkRequestObject.definitionS3Location) {
77+
sdkRequestObject.definition = await fetchFileFromS3(sdkRequestObject.definitionS3Location, sdk);
78+
delete sdkRequestObject.definitionS3Location;
79+
}
80+
if (sdkRequestObject.codeS3Location) {
81+
sdkRequestObject.code = await fetchFileFromS3(sdkRequestObject.codeS3Location, sdk);
82+
delete sdkRequestObject.codeS3Location;
83+
}
84+
5885
if (isResolver) {
5986
await sdk.appsync().updateResolver(sdkRequestObject).promise();
60-
} else {
87+
} else if (isFunction) {
88+
6189
const { functions } = await sdk.appsync().listFunctions({ apiId: sdkRequestObject.apiId }).promise();
6290
const { functionId } = functions?.find(fn => fn.name === physicalName) ?? {};
63-
await sdk.appsync().updateFunction({
64-
...sdkRequestObject,
65-
functionId: functionId!,
66-
}).promise();
91+
await simpleRetry(
92+
() => sdk.appsync().updateFunction({ ...sdkRequestObject, functionId: functionId! }).promise(),
93+
3,
94+
'ConcurrentModificationException');
95+
} else {
96+
let schemaCreationResponse: GetSchemaCreationStatusResponse = await sdk.appsync().startSchemaCreation(sdkRequestObject).promise();
97+
while (schemaCreationResponse.status && ['PROCESSING', 'DELETING'].some(status => status === schemaCreationResponse.status)) {
98+
await sleep(1000); // poll every second
99+
const getSchemaCreationStatusRequest: GetSchemaCreationStatusRequest = {
100+
apiId: sdkRequestObject.apiId,
101+
};
102+
schemaCreationResponse = await sdk.appsync().getSchemaCreationStatus(getSchemaCreationStatusRequest).promise();
103+
}
104+
if (schemaCreationResponse.status === 'FAILED') {
105+
throw new Error(schemaCreationResponse.details);
106+
}
67107
}
68108
},
69109
});
70110
}
71111

72112
return ret;
73113
}
114+
115+
async function fetchFileFromS3(s3Url: string, sdk: ISDK) {
116+
const s3PathParts = s3Url.split('/');
117+
const s3Bucket = s3PathParts[2]; // first two are "s3:" and "" due to s3://
118+
const s3Key = s3PathParts.splice(3).join('/'); // after removing first three we reconstruct the key
119+
return (await sdk.s3().getObject({ Bucket: s3Bucket, Key: s3Key }).promise()).Body;
120+
}
121+
122+
async function simpleRetry(fn: () => Promise<any>, numOfRetries: number, errorCodeToRetry: string) {
123+
try {
124+
await fn();
125+
} catch (error: any) {
126+
if (error && error.code === errorCodeToRetry && numOfRetries > 0) {
127+
await sleep(500); // wait half a second
128+
await simpleRetry(fn, numOfRetries - 1, errorCodeToRetry);
129+
} else {
130+
throw error;
131+
}
132+
}
133+
}
134+
135+
async function sleep(ms: number) {
136+
return new Promise(ok => setTimeout(ok, ms));
137+
}

0 commit comments

Comments
 (0)