🐤

AWS Application Composer と cdk-from-cfn で CDK はノーコード開発できるのか

2023/12/01に公開

忙しい人向け

AWS Application Composer (以下、Application Composer) と cdk-from-cfn を組み合わせる事でこんなことができます。

https://aws.amazon.com/jp/application-composer/
https://github.com/cdklabs/cdk-from-cfn

Pos

  • 構成図を作るかのような GUI 操作で誰でも簡単に AWS CDK のコードを作成できる

Cons

  • 全て L1 コンストラクトで定義されるため、そのままでは AWS CDK 特有の抽象化によるメリットを受けづらい

はじめに

...なにやら見覚えのある記事だと思ったそこのあなた、大正解です。AWS CDK Advent Calendar 2022 でも似た内容の記事を投稿しています。この記事は去年の構成を cdk-from-cfn でリベンジした記事になります。

https://zenn.dev/winteryukky/articles/fe5f02a50924a7

モチベーション

Application Composer はブラウザベースのキャンバスでドラッグアンドドロップ操作でインフラストラクチャを定義でき、AWS CloudFormation と AWS Serverless Application Model (SAM)のテンプレートとして出力することができるサービスです。一部のブラウザではファイルの同期を行ってくれるため、キャンバス上で操作した結果がリアルタイムにローカルのファイルに反映されます。

...ならば、ファイルの変更を監視して CloudFormation テンプレートから CDK のコードを出力すれば GUI 操作で CDK のコードができあがるのではないか!?という考えが本投稿のモチベーションです。
以下は登場人物の紹介になります。

AWS Application Composer とは

2023 年 3 月 7 日に一般提供を開始した、複数の AWS サービスからサーバーレスアプリケーションを構築するために使用できるビジュアルデザイナーです。Application Composer を使用すると、キャンバス上でサーバーレスリソースをドラッグして、それらを接続することができます。バックグラウンドでは、Application Composer がインフラストラクチャをコードとして AWS CloudFormation テンプレートを生成します。本投稿の主役です。
Application Composerのデモ
https://aws.amazon.com/jp/blogs/news/visualize-and-create-your-serverless-workloads-with-aws-application-composer/

https://aws.amazon.com/jp/about-aws/whats-new/2023/03/aws-application-composer-generally-available/

cdk-from-cfn とは

cdklabs のリポジトリで管理されているパッケージの 1 つで、AWS CloudFormation テンプレートを同等の AWS CDK コードに変換するためのツールです。実は cdk migrate コマンドの裏側で動くライブラリだったりします。今回は cdk-from-cfn を用いて CDK のコードを出力します。
https://github.com/cdklabs/cdk-from-cfn

nodemon

言わずと知れたファイルの変更を検知してくれるツールです。NPM で配布されていて導入が簡単なので利用します。
https://github.com/remy/nodemon

手順

今回は Application Composer の操作で CDK のコードを生成し、出力された CDK コードをデプロイするところまでをゴールとしてみます。

次の環境で実施しています。

  • Windows 11 Pro 22H2
  • Node.js v20.9.0
  • AWS CDK v2.110.1
  • cdk-from-cfn v0.79.0

CDK プロジェクト初期化

以下のコマンドで CDK プロジェクトを初期化します。

terminal
npx cdk init app --language typescript

CDK コードを生成する準備

以下のコマンドで nodemoncdk-from-cfn をインストールします。なお、cdk-from-cfn は Application Composer が出力する CloudFormation に含まれる SAM の Transform を解釈できないため、 SAM リポジトリから変換するスクリプトとそれに必要なライブラリもインストールしています。

terminal
npm install -D cdk-from-cfn nodemon
pip install aws-sam-translator pyyaml
curl -LO https://raw.githubusercontent.com/aws/serverless-application-model/develop/bin/sam-translate.py

package.jsonを編集します。template.yamlを監視し、変更の度にcdk-from-cfnを呼び出してlib/my-stack.tsへ書き込むコマンドをcodegen-modeとして登録します。ちなみに cdk-from-cfn はコマンドラインツールではないため JavaScript から呼び出して利用します。

package.json
 {
   "name": "application-composer-cdk",
   "version": "0.1.0",
   "bin": {
     "application-composer-cdk": "bin/application-composer-cdk.js"
   },
   "scripts": {
     "build": "tsc",
     "watch": "tsc -w",
     "test": "jest",
-    "cdk": "cdk"
+    "cdk": "cdk",
+    "sam-translate": "AWS_DEFAULT_REGION=us-east-1 python sam-translate.py --template-file=sam-template.yaml --output-template=template.json",
+    "codegen": "node -e \"const [cdkfromcfn, fs] = [require('cdk-from-cfn'), require('fs')]; fs.writeFileSync(process.argv[2], cdkfromcfn.transmute(fs.readFileSync(process.argv[1]).toString(), 'typescript', 'MyStack'));\"",
+    "codegen-mode": "nodemon --ignore .aws-composer --ignore template.json --exec \"npm run sam-translate && npm run codegen ./template.json ./lib/application-composer-cdk-stack.ts\" ./sam-template.yaml"
   },
   "devDependencies": {
     "@types/jest": "^29.5.8",
     "@types/node": "20.9.0",
     "aws-cdk": "2.110.1",
     "cdk-from-cfn": "^0.79.0",
     "jest": "^29.7.0",
     "nodemon": "^3.0.1",
     "ts-jest": "^29.1.1",
     "ts-node": "^10.9.1",
     "typescript": "~5.2.2"
   },
   "dependencies": {
     "aws-cdk-lib": "2.110.1",
     "constructs": "^10.0.0",
     "source-map-support": "^0.5.21"
   }
 }

Applicaction Composer でプロジェクトを開く

yaml 形式のテンプレートを用意するためにResources:だけのファイルを作成します。

terminal
echo "Resources:" > sam-template.yaml

先ほどpackage.jsonに追加したコマンドを実行しておきましょう。

terminal
npm run codegen-mode

次のような CDK コードが作成されるはずです。

lib/my-stack.ts
import * as cdk from 'aws-cdk-lib';

export interface MyStackProps extends cdk.StackProps {
}

export class MyStack extends cdk.Stack {
  public constructor(scope: cdk.App, id: string, props: MyStackProps = {}) {
    super(scope, id, props);

    // Resources
  }
}

次に、Application Composer のサービスページへアクセスし、Create Project をクリックします。

[メニュー] > [開く] > [プロジェクトフォルダ] を選択します。

下表の通り設定します。

設定項目
フォルダを選択 先ほど作成した CDK プロジェクトのフォルダ
テンプレートファイル sam-template.yaml

作成 をクリックしたら準備完了です。操作するとリアルタイムで CDK のコードが出力されているのが分かります。

実際には次のようなコードが出力されています。

lib/my-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as stepfunctions from 'aws-cdk-lib/aws-stepfunctions';

export interface MyStackProps extends cdk.StackProps {
}

export class MyStack extends cdk.Stack {
  public constructor(scope: cdk.App, id: string, props: MyStackProps = {}) {
    super(scope, id, props);

    // Resources
    const eventRuleToStateMachineRole = new iam.CfnRole(this, 'EventRuleToStateMachineRole', {
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: {
          Effect: 'Allow',
          Principal: {
            Service: `events.${this.urlSuffix}`,
          },
          Action: 'sts:AssumeRole',
          Condition: {
            ArnLike: {
              'aws:SourceArn': `arn:${this.partition}:events:${this.region}:${this.account}:rule/${this.stackName}-EventRule-*`,
            },
          },
        },
      },
    });

    const stateMachineLogGroup = new logs.CfnLogGroup(this, 'StateMachineLogGroup', {
      logGroupName: `/aws/vendedlogs/states/${this.stackName}-StateMachine-Logs`,
    });

    const stateMachineRole = new iam.CfnRole(this, 'StateMachineRole', {
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: [
              'sts:AssumeRole',
            ],
            Effect: 'Allow',
            Principal: {
              Service: [
                'states.amazonaws.com',
              ],
            },
          },
        ],
      },
      managedPolicyArns: [
        'arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess',
      ],
      policies: [
        {
          policyName: 'StateMachineRolePolicy1',
          policyDocument: {
            Statement: [
              {
                Effect: 'Allow',
                Action: [
                  'logs:CreateLogDelivery',
                  'logs:GetLogDelivery',
                  'logs:UpdateLogDelivery',
                  'logs:DeleteLogDelivery',
                  'logs:ListLogDeliveries',
                  'logs:PutResourcePolicy',
                  'logs:DescribeResourcePolicies',
                  'logs:DescribeLogGroups',
                ],
                Resource: '*',
              },
            ],
          },
        },
      ],
      tags: [
        {
          key: 'stateMachine:createdBy',
          value: 'SAM',
        },
      ],
    });

    if (stateMachineLogGroup == null) { throw new Error(`A combination of conditions caused 'stateMachineLogGroup' to be undefined. Fixit.`); }
    if (stateMachineRole == null) { throw new Error(`A combination of conditions caused 'stateMachineRole' to be undefined. Fixit.`); }
    const stateMachine = new stepfunctions.CfnStateMachine(this, 'StateMachine', {
      definitionString: [
        '{',
        '    \"StartAt\": \"Start\",',
        '    \"States\": {',
        '        \"Done\": {',
        '            \"End\": true,',
        '            \"Type\": \"Pass\"',
        '        },',
        '        \"Start\": {',
        '            \"Next\": \"Done\",',
        '            \"Type\": \"Pass\"',
        '        }',
        '    }',
        '}',
      ].join('\n'),
      loggingConfiguration: {
        level: 'ALL',
        includeExecutionData: true,
        destinations: [
          {
            cloudWatchLogsLogGroup: {
              logGroupArn: stateMachineLogGroup.attrArn,
            },
          },
        ],
      },
      roleArn: stateMachineRole.attrArn,
      stateMachineType: 'STANDARD',
      tags: [
        {
          key: 'stateMachine:createdBy',
          value: 'SAM',
        },
      ],
      tracingConfiguration: {
        enabled: true,
      },
    });

    if (eventRuleToStateMachineRole == null) { throw new Error(`A combination of conditions caused 'eventRuleToStateMachineRole' to be undefined. Fixit.`); }
    if (stateMachine == null) { throw new Error(`A combination of conditions caused 'stateMachine' to be undefined. Fixit.`); }
    const eventRule = new events.CfnRule(this, 'EventRule', {
      eventPattern: {
        source: [
          'aws.health',
        ],
      },
      targets: [
        {
          id: stateMachine.attrName,
          arn: stateMachine.ref,
          roleArn: eventRuleToStateMachineRole.attrArn,
        },
      ],
    });

    if (eventRuleToStateMachineRole == null) { throw new Error(`A combination of conditions caused 'eventRuleToStateMachineRole' to be undefined. Fixit.`); }
    if (stateMachine == null) { throw new Error(`A combination of conditions caused 'stateMachine' to be undefined. Fixit.`); }
    const eventRuleToStateMachineConnector = new iam.CfnManagedPolicy(this, 'EventRuleToStateMachineConnector', {
      policyDocument: {
        Statement: [
          {
            Effect: 'Allow',
            Action: [
              'states:StartExecution',
            ],
            Resource: [
              stateMachine.ref,
            ],
          },
        ],
        Version: '2012-10-17',
      },
      roles: [
        eventRuleToStateMachineRole.ref,
      ],
    });
  }
}

変換された CDK アプリケーションをデプロイしてみます。

terminal
npx cdk deploy

無事デプロイすることができました。

おまけ

L1 コンストラクトだけでは AWS CDK の抽象化による恩恵を受けづらいので、簡単に L2 に変換できないか Amazon Bedrock の Claude v2.1 を利用して検証してみました。次のようなプロンプトで実行します。

次のコードが返ってきました。

import * as cdk from "aws-cdk-lib";
import * as events from "aws-cdk-lib/aws-events";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
import * as stepfunctions from "aws-cdk-lib/aws-stepfunctions";

export class MyStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Resources
    const eventRuleRole = new iam.Role(this, "EventRuleRole", {
      assumedBy: new iam.ServicePrincipal("events.amazonaws.com"),
    });

    const logGroup = new logs.LogGroup(this, "StateMachineLogs");

    const stateMachineRole = new iam.Role(this, "StateMachineRole", {
      assumedBy: new iam.ServicePrincipal("states.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AWSXrayWriteOnlyAccess"),
      ],
    });
    stateMachineRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ["logs:*"],
        resources: ["*"],
      })
    );

    const stateMachine = new stepfunctions.StateMachine(this, "StateMachine", {
      logging: {
        level: stepfunctions.LogLevel.ALL,
        includeExecutionData: true,
        destinations: [new stepfunctions.LogDestination(logGroup)],
      },
      role: stateMachineRole,
      tracingEnabled: true,
      definition: stepfunctions.Chain.start(
        new stepfunctions.Pass(this, "Start")
      ).next(new stepfunctions.Pass(this, "Done")),
    });

    stateMachine.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["states:StartExecution"],
        resources: [stateMachine.stateMachineArn],
      })
    );

    const eventRule = new events.Rule(this, "EventRule", {
      targets: [new targets.SfnStateMachine(stateMachine)],
      eventPattern: {
        source: ["aws.health"],
      },
    });

    eventRule.grantStartExecution(stateMachine);
  }
}

一部誤った設定があるようですが、ある程度は L2 に作り直してくれそうですね。

さいごに

去年使った cdk-dasm と違って CloudFormation 組み込み関数も処理してくれたり、リソース間の参照も文字列参照ではなく CDK LIKE な書き方になっていて実用的になったような気がします。cdk-from-cfn もまだまだ積極的に開発されているので今後に期待です。

Discussion