Amplify CDK オーバーライドってどうなってるの?
December 20, 2021
この記事は AWS Amplify Advent Calendar 2021 と ESM Advent Calendar 2021 の 20 日目の記事です。
Amplify CLI v7.3.0 で CDK オーバーライドの機能が提供されました。かなり熱い機能です。 Amplify の CDK オーバーライドはどうやって実装されているのかを調べたので紹介します。
どうして CDK オーバーライドを調べたのか
amplify-category-console-notification1という amplfy プラグインを公開しているですが、
そのプラグインでもamplify console-notification override
のように利用者が設定を上書きできる機能を提供したいというのが動機です。
そのため、CDK オーバーライドを実装するにはどうすればいいのか?を把握することを目的に実装がどうなっているのかを紹介します。
なお、この記事ではamplify cli v7.6.3を対象に調べています。
オーバーライドとは
そもそも、「Amplify CDK オーバーライドとは何か」ですが、Amplify が生成するバックエンドリソース(の一部)を CDK でカスタマイズできる機能です。今までは生成された CloudFormation テンプレートの JSON ファイルを直接編集してカスタマイズしていたのを CDK を利用してカスタマイズできるようになったというものです。 詳しくはAmplify で生成されたバックエンドリソースを CDK でカスタマイズする新機能 「オーバーライド」のご紹介という Amazon Web Services ブログの記事を読んでいただくと一通り把握できると思います。
どうなってる?
次の 2 点がどうなっているかを調べるとサードパーティプラグインで CDK オーバーライドを提供(実装)できるのではないかと考えました。
amplify override
はどう実装されているかamplify push
でカテゴリープラグインのどの API が呼び出されているのか(カテゴリープラグインはどの API を実装すればいいのか)
そこで、この 2 点についてどう実装されているのか見ていきます。
amplify では overide はauth
, storage
, api
のカテゴリーで提供されていますが、ここではstorage
カテゴリーに絞って探っていきたいと思います。
amplify category override
の実装を見る
オーバーライドを利用するにはまず次のコマンドを実行します。
$ amplify storage override
このコマンドを実行すると、storage
カテゴリーのどのリソースをオーバーライドするのかユーザーに問い合わせてから、以下のコードが呼び出されます。
amplify-category-storage/src/commands/storage/override.ts#L58-L83:
// Make sure to migrate first
if (amplifyMeta[AmplifyCategories.STORAGE][selectedResourceName].service === AmplifySupportedService.DYNAMODB) {
const resourceInputState = new DynamoDBInputState(selectedResourceName);
if (!resourceInputState.cliInputFileExists()) {
if (await prompter.yesOrNo(getMigrateResourceMessageForOverride(AmplifyCategories.STORAGE, selectedResourceName, false), true)) {
resourceInputState.migrate();
const stackGenerator = new DDBStackTransform(selectedResourceName);
await stackGenerator.transform();
} else {
return;
}
}
} else if (amplifyMeta[AmplifyCategories.STORAGE][selectedResourceName].service === AmplifySupportedService.S3) {
const s3ResourceInputState = new S3InputState(selectedResourceName, undefined);
if (!s3ResourceInputState.cliInputFileExists()) {
if (await prompter.yesOrNo(getMigrateResourceMessageForOverride(AmplifyCategories.STORAGE, selectedResourceName, false), true)) {
await s3ResourceInputState.migrate(context); //migrate auth and storage config resources
const stackGenerator = new AmplifyS3ResourceStackTransform(selectedResourceName, context);
stackGenerator.transform(CLISubCommandType.MIGRATE);
} else {
return;
}
}
}
await generateOverrideSkeleton(context, srcPath, destPath);
何をしているのか確認してみます。 ストレージの種類が DynamoDB か S3 かで分岐していますが、どちらも同じような処理を行っているので、DynamoDB の方を見てみます。
やっていることは次の処理です。
- マイグレーションが必要かをチェックします。
- ファイルがなければユーザに問い合わせた後にマイグレーションを実行します。
- マイグレーションで生成したファイルをもとに CDK スタックから Cfn テンプレートを作成します。
- 最後に
generateOverrideSkeleton
関数を実行します。
マイグレーションの必要性のチェック、マイグレーションの実行はDynamoDBInputState
2というクラスに実装されています。
マイグレーションが必要かのチェックですが、cliInputFileExists
関数がその役割です。cli-input.json
3ファイルの有無でマイグレーションが必要であるか判断しています。
public cliInputFileExists(): boolean {
return fs.existsSync(this._cliInputsFilePath);
}
マイグレーションはmigrate
関数に実装されています。マイグレーションでは既存のparameters.json
,Cfn テンプレートファイル,storage-params.json
を読み込んでcli-input.json
を作成します。
そして、既存のファイルを削除します。
public migrate() {
let cliInputs: DynamoDBCLIInputs;
// migrate the resource to new directory structure if cli-inputs.json is not found for the resource
const backendDir = pathManager.getBackendDirPath();
const oldParametersFilepath = path.join(backendDir, 'storage', this._resourceName, 'parameters.json');
const oldCFNFilepath = path.join(backendDir, 'storage', this._resourceName, `${this._resourceName}-cloudformation-template.json`);
const oldStorageParamsFilepath = path.join(backendDir, 'storage', this._resourceName, `storage-params.json`);
const oldParameters = JSONUtilities.readJson<$TSAny>(oldParametersFilepath, { throwIfNotExist: true });
const oldCFN = JSONUtilities.readJson<$TSAny>(oldCFNFilepath, { throwIfNotExist: true });
const oldStorageParams = JSONUtilities.readJson<$TSAny>(oldStorageParamsFilepath, { throwIfNotExist: false }) || {};
const partitionKey = {
fieldName: oldParameters.partitionKeyName,
fieldType: getFieldType(oldParameters.partitionKeyType),
};
let sortKey;
if (oldParameters.sortKeyName) {
sortKey = {
fieldName: oldParameters.sortKeyName,
fieldType: getFieldType(oldParameters.sortKeyType),
};
}
let triggerFunctions = [];
if (oldStorageParams.triggerFunctions) {
triggerFunctions = oldStorageParams.triggerFunctions;
}
const getType = (attrList: $TSAny, attrName: string) => {
let attrType;
attrList.forEach((attr: $TSAny) => {
if (attr.AttributeName === attrName) {
attrType = getFieldType(attr.AttributeType);
}
});
return attrType;
};
let gsi: DynamoDBCLIInputsGSIType[] = [];
if (oldCFN?.Resources?.DynamoDBTable?.Properties?.GlobalSecondaryIndexes) {
oldCFN.Resources.DynamoDBTable.Properties.GlobalSecondaryIndexes.forEach((cfnGSIValue: $TSAny) => {
let gsiValue: $TSAny = {};
(gsiValue.name = cfnGSIValue.IndexName),
cfnGSIValue.KeySchema.forEach((keySchema: $TSObject) => {
if (keySchema.KeyType === 'HASH') {
gsiValue.partitionKey = {
fieldName: keySchema.AttributeName,
fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName),
};
} else {
gsiValue.sortKey = {
fieldName: keySchema.AttributeName,
fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName),
};
}
});
gsi.push(gsiValue);
});
}
cliInputs = {
resourceName: this._resourceName,
tableName: oldParameters.tableName,
partitionKey,
sortKey,
triggerFunctions,
gsi,
};
this.saveCliInputPayload(cliInputs);
// Remove old files
if (fs.existsSync(oldCFNFilepath)) {
fs.removeSync(oldCFNFilepath);
}
if (fs.existsSync(oldParametersFilepath)) {
fs.removeSync(oldParametersFilepath);
}
if (fs.existsSync(oldStorageParamsFilepath)) {
fs.removeSync(oldStorageParamsFilepath);
}
}
マイグレーションを実行すると次に、DDBStackTransform.transform()
関数を呼び出しています。
transform()
関数では、cli-input.json
から Cfn テンプレートファイル(cloudformation.json
)とパラメータファイル(parameters.json
)を生成しています。
applyOverrides
関数を呼び出してユーザーがオーバーライドした内容(override.ts
)をスタックに適用する処理も実行されます。
ただし、この時点ではoverride.ts
はまだ存在していないので何もオーバーライドされるものはありません。
async transform(commandType: CLISubCommandType) {
this.generateCfnInputParameters();
// Generate cloudformation stack from cli-inputs.json
await this.generateStack(this.context);
// Modify cloudformation files based on overrides
await this.applyOverrides();
// Save generated cloudformation.json and parameters.json files
this.saveBuildFiles(commandType);
}
最後に、generateOverrideSkeleton
関数の呼び出しです。generateOverrideSkeleton
関数はオーバーライドを利用する場合に必要なtsconfig.json
やoverride.ts
ファイルの生成してくれる関数です。
プラグインでオーバーライドを実装する場合も、この関数を適切な引数で呼び出せば良さそうです。
ここまで見てきた内容を踏まえると amplify overide <category>
コマンドは実装できそうです。
amplify push
の実装を見る
次に、amplify push
を実行したときに CDK オーバーライド(override.ts
)のコードがどのように反映されてデプロイされるのか見てみます。
amplify push
を実行すると amplify-cli/src/extensions/amplify-helpers/push-resources.ts
の pushResource
関数が呼び出されます。
override.ts
のビルドと Cfn テンプレートファイル生成
pushResource
関数の中に以下のコードがあり、context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', {...})
を呼び出しています。
ここで Cfn テンプレートが生成されているようです。
amplify-cli/src/extensions/amplify-helpers/push-resources.ts#L57-L63:
// building all CFN stacks here to get the resource Changes
await generateDependentResourcesType(context);
const resourcesToBuild: IAmplifyResource[] = await getResources(context);
await context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', {
resourcesToBuild,
forceCompile: true,
});
context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', {...})
を呼び出すと以下に示す関数が順次呼び出されていきます。
/**
* Utility function to build resource CFN with overrides
* Resources to build are passed with options
*/
buildOverrides: async (context, options) => {
for (const resource of options.resourcesToBuild) {
await transformResourceWithOverrides(context, resource);
}
await transformResourceWithOverrides(context);
},
ここでリソース毎にtransformResourceWithOverrides
関数を呼び出しています。
export async function transformResourceWithOverrides(context: $TSContext, resource?: IAmplifyResource) {
const flags = context.parameters.options;
let spinner: ora.Ora;
try {
if (resource) {
const { transformCategoryStack } = await import(`@aws-amplify/amplify-category-${resource.category}`);
if (transformCategoryStack) {
spinner = ora(`Building resource ${resource.category}/${resource.resourceName}`);
spinner.start();
await transformCategoryStack(context, resource);
FeatureFlags.ensureFeatureFlag('project', 'overrides');
spinner.stop();
return;
} else {
printer.info('Overrides functionality is not implemented for this category');
}
} else {
// old app -> migrate project must transform -> change detected
// new app -> just initialized project no transform -> no change detected
// new app -> just pushed project {
// overrides enabled : transform -> change detected
// override disabled : no transform -> No change detected
//}
// RootStack deployed to backend/awscloudformation/build
const projectRoot = pathManager.findProjectRoot();
const rootStackBackendBuildDir = pathManager.getRootStackBuildDirPath(projectRoot);
fs.ensureDirSync(rootStackBackendBuildDir);
const rootStackBackendFilePath = path.join(rootStackBackendBuildDir, rootStackFileName);
if (isMigrateProject()) {
//old App
const template = await transformRootStack(context);
await prePushCfnTemplateModifier(template);
JSONUtilities.writeJson(rootStackBackendFilePath, template);
} else {
if (isRootOverrideFileModifiedSinceLastPush()) {
// new App before push
const template = await transformRootStack(context);
await prePushCfnTemplateModifier(template);
JSONUtilities.writeJson(rootStackBackendFilePath, template);
}
}
}
} catch (err) {
if (spinner) {
spinner.stop();
}
return;
}
transformResourceWithOverrides
関数では指定されたリソースのカテゴリープラグインが実装するtransformCategoryStack
関数を呼び出しています。
export async function transformCategoryStack(context: $TSContext, resource: IAmplifyResource) {
if (resource.service === AmplifySupportedService.DYNAMODB) {
if (canResourceBeTransformed(resource.resourceName)) {
const stackGenerator = new DDBStackTransform(resource.resourceName);
await stackGenerator.transform();
}
} else if (resource.service === AmplifySupportedService.S3) {
await transformS3ResourceStack(context, resource);
}
}
amplify-category-storage
のtransformCategoryStack
関数は DynamoDB のリソースであればDDBStackTransform.transform()
関数を呼び出しています。
このtransform
関数はマイグレーションの処理でも呼び出されていましたね。この関数で呼びだされるapplyOverrides
関数でoverride.ts
の内容でスタックを上書きしています。次で詳細を見てみましょう。
async applyOverrides() {
const backendDir = pathManager.getBackendDirPath();
const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, 'storage', this._resourceName);
const overrideJSFilePath = path.resolve(path.join(resourceDirPath, 'build', 'override.js'));
const isBuild = await buildOverrideDir(backendDir, resourceDirPath).catch(error => {
printer.error(`Build error : ${error.message}`);
throw new Error(error);
});
// skip if packageManager or override.ts not found
if (isBuild) {
const { override } = await import(overrideJSFilePath).catch(error => {
formatter.list(['No override File Found', `To override ${this._resourceName} run amplify override auth ${this._resourceName} `]);
return undefined;
});
if (typeof override === 'function' && override) {
const overrideCode: string = await fs.readFile(overrideJSFilePath, 'utf-8').catch(() => {
formatter.list(['No override File Found', `To override ${this._resourceName} run amplify override auth`]);
return '';
});
const sandboxNode = new vm.NodeVM({
console: 'inherit',
timeout: 5000,
sandbox: {},
require: {
context: 'sandbox',
builtin: ['path'],
external: true,
},
});
try {
await sandboxNode.run(overrideCode, overrideJSFilePath).override(this._resourceTemplateObj as AmplifyDDBResourceTemplate);
} catch (err: $TSAny) {
const error = new Error(`Skipping override due to ${err}${os.EOL}`);
printer.error(`${error}`);
error.stack = undefined;
throw error;
}
}
}
}
buildOverrideDir
関数でoverride.ts
をビルドしてoverride.js
を出力します。ビルドされたらnew vm.NodeVM({...})
で作成した Node.js のサンドボックス環境内でoverride
関数を呼び出してユーザーが定義したオーバーライドの内容をスタックに反映します。
saveBuildFiles() {
if (this._resourceTemplateObj) {
this._cfn = JSON.parse(this._resourceTemplateObj.renderCloudFormationTemplate());
}
// store files in local-filesysten
fs.ensureDirSync(this._cliInputsState.buildFilePath);
const cfnFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, `${this._resourceName}-cloudformation-template.json`));
try {
JSONUtilities.writeJson(cfnFilePath, this._cfn);
} catch (e) {
throw new Error(e);
}
fs.ensureDirSync(this._cliInputsState.buildFilePath);
const cfnInputParamsFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, 'parameters.json'));
try {
JSONUtilities.writeJson(cfnInputParamsFilePath, this._cfnInputParams);
} catch (e) {
throw new Error(e);
}
}
DDBStackTransform.transform()
関数はapplyOverrides
関数の後にsaveBuildFiles
関数を呼び出して、オーバーライドの内容を反映したスタックから Cfn のテンプレートをファイル出力します。同時にparameters.json
も出力します。
ここまでで、amplify push
を実行したときにoverride.ts
がどうビルド・反映されて Cfn テンプレートが生成されるのか把握できました。
プラグインでこの仕組みを実装する場合、問題になる箇所があります。それは、以下のカテゴリープラグインが実装するtransformCategoryStack
関数を取得する部分です。
amplify-provider-awscloudformation/src/override-manager/transform-resource.ts#L22:
const { transformCategoryStack } = await import(`@aws-amplify/amplify-category-${resource.category}`);
インポートするプラグインが@aws-amplify/amplify-category-
で始まるものに限定されています。つまり、公式プラグインのみを対象にしています。
transformCategoryStack
実装に向けた活動
このままでは、サードパーティのプラグインで Amplify CLI が提供している CDK オーバーライドと同じ仕組みに乗っかって機能提供するのは難しそうなのでの GitHub の amplify-cli リポジトリで Issue を出しました。
今後は、サードパーティのプラグインで CDK オーバーライドが実装できるように、この Issue への Pull Request も出していきたいと思います。
脚注
-
AWS Amplify Advent Calendar 2021 16 日目の記事で紹介いただいたプラグインです。 ↩
-
この
DynamoDBInputState
は何かのインタフェースを実装してはいませんが、/* Need to move this logic to a base class */
というコメントが書かれており、auth
カテゴリのソースを見ると同じような役割のAuthInputState
クラスがCategoryInputState
クラスを継承しています。 マイグレーションを実装する場合は、CategoryInputState
クラスを継承して実装するのが良さそうです。 ↩ -
cli-input.json
について公式ドキュメントには、まだ記載はないようです。このファイルにはユーザーから入力された値を保存しておき、CDK のコードをビルドする際に利用するためのものです。 ↩