Amplify CDK オーバーライドってどうなってるの?

December 20, 2021

この記事は AWS Amplify Advent Calendar 2021ESM 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 の方を見てみます。

やっていることは次の処理です。

  1. マイグレーションが必要かをチェックします。
  2. ファイルがなければユーザに問い合わせた後にマイグレーションを実行します。
  3. マイグレーションで生成したファイルをもとに CDK スタックから Cfn テンプレートを作成します。
  4. 最後にgenerateOverrideSkeleton関数を実行します。

マイグレーションの必要性のチェック、マイグレーションの実行はDynamoDBInputState2というクラスに実装されています。 マイグレーションが必要かのチェックですが、cliInputFileExists関数がその役割です。cli-input.json3ファイルの有無でマイグレーションが必要であるか判断しています。

amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts#L47-L49:

  public cliInputFileExists(): boolean {
    return fs.existsSync(this._cliInputsFilePath);
  }

マイグレーションはmigrate関数に実装されています。マイグレーションでは既存のparameters.json,Cfn テンプレートファイル,storage-params.jsonを読み込んでcli-input.jsonを作成します。 そして、既存のファイルを削除します。

amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts#L71-L161:

  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はまだ存在していないので何もオーバーライドされるものはありません。

amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-transform.ts#L60-L70:

  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.jsonoverride.tsファイルの生成してくれる関数です。 プラグインでオーバーライドを実装する場合も、この関数を適切な引数で呼び出せば良さそうです。

ここまで見てきた内容を踏まえると amplify overide <category> コマンドは実装できそうです。

amplify push の実装を見る

次に、amplify push を実行したときに CDK オーバーライド(override.ts)のコードがどのように反映されてデプロイされるのか見てみます。 amplify pushを実行すると amplify-cli/src/extensions/amplify-helpers/push-resources.tspushResource関数が呼び出されます。

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', {...})を呼び出すと以下に示す関数が順次呼び出されていきます。

  1. amplify-provider-awscloudformation/src/utility-functions.js#L66-L75:
  /**
   * 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関数を呼び出しています。

  1. amplify-provider-awscloudformation/src/override-manager/transform-resource.ts#L16-L66:
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関数を呼び出しています。

  1. amplify-category-storage/src/index.ts#L115-L124:
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-storagetransformCategoryStack関数は DynamoDB のリソースであればDDBStackTransform.transform()関数を呼び出しています。 このtransform関数はマイグレーションの処理でも呼び出されていましたね。この関数で呼びだされるapplyOverrides関数でoverride.tsの内容でスタックを上書きしています。次で詳細を見てみましょう。

  1. amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts#L173-L215:
  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関数を呼び出してユーザーが定義したオーバーライドの内容をスタックに反映します。

  1. amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts#L217-L239:
  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 側に提供できないということです。😢

実装に向けた活動

このままでは、サードパーティのプラグインで Amplify CLI が提供している CDK オーバーライドと同じ仕組みに乗っかって機能提供するのは難しそうなのでの GitHub の amplify-cli リポジトリで Issue を出しました。

今後は、サードパーティのプラグインで CDK オーバーライドが実装できるように、この Issue への Pull Request も出していきたいと思います。

脚注

  1. AWS Amplify Advent Calendar 2021 16 日目の記事で紹介いただいたプラグインです。

  2. このDynamoDBInputStateは何かのインタフェースを実装してはいませんが、/* Need to move this logic to a base class */ というコメントが書かれており、 authカテゴリのソースを見ると同じような役割のAuthInputStateクラスがCategoryInputStateクラスを継承しています。 マイグレーションを実装する場合は、CategoryInputStateクラスを継承して実装するのが良さそうです。

  3. cli-input.jsonについて公式ドキュメントには、まだ記載はないようです。このファイルにはユーザーから入力された値を保存しておき、CDK のコードをビルドする際に利用するためのものです。


Profile picture

Written by Masahiko MURAKAMI You should follow them on Twitter