[AWS CDK] APIを呼び出すだけのカスタムリソースならLambda関数は不要な件

APIを呼び出すだけのカスタムリソースであれば、AWS CDKの場合、Lambda関数を使わずにAPIを呼び出してカスタムリソースを作成/更新/削除することができます。
2022.01.05

AWS CDKを使えばAPIを呼び出すだけのカスタムリソースならLambda関数は不要らしい

こんにちは、のんピ(@non____97)です。

皆さんはカスタムリソースを作成することは多いですか? 私はそこまで多くないです。

CloudFormationやAWS CDKでカスタムリソースを作成する場合、以下記事で紹介されている通りLambda関数を使って行うことが多いです。

ただ、APIを呼び出すだけなのにLambda関数を用意するのもめんどくさいですよね。

そんな時、AWS CDKのAPI Referenceを眺めていたら、ただAPIを呼び出すだけなのであればLambda関数をわざわざ用意する必要がないことが分かりました。

今回は、Lambda関数を使用せずにAWS CDKを使ってカスタムリソースを作成してみます。

いきなりまとめ

  • AWS CDKでデプロイする場合、APIを呼び出すだけのカスタムリソースであればLambda関数を作る必要はない
    • 内部的にはAWS SDKを実行するためのLambda関数が自動で作成される
  • APIを呼び出す以外の処理や複数のAPIを呼び出す場合は、カスタムリソース作成用のLambda関数を用意する必要がある

API Referenceの確認

AWS CDKのカスタムリソースのAPI Referenceは以下の通りです。

また、こちらのAPI Referenceで紹介されているコードは以下の通りです。

// The code below shows an example of how to instantiate this type.
// The values are placeholders you should change.
import * as cdk from 'aws-cdk-lib';
import { aws_iam as iam } from 'aws-cdk-lib';
import { aws_logs as logs } from 'aws-cdk-lib';
import { custom_resources } from 'aws-cdk-lib';

declare const awsCustomResourcePolicy: custom_resources.AwsCustomResourcePolicy;
declare const parameters: any;
declare const physicalResourceId: custom_resources.PhysicalResourceId;
declare const role: iam.Role;

const awsCustomResource = new custom_resources.AwsCustomResource(this, 'MyAwsCustomResource', {
  policy: awsCustomResourcePolicy,

  // the properties below are optional
  functionName: 'functionName',
  installLatestAwsSdk: false,
  logRetention: logs.RetentionDays.ONE_DAY,
  onCreate: {
    action: 'action',
    service: 'service',

    // the properties below are optional
    apiVersion: 'apiVersion',
    assumedRoleArn: 'assumedRoleArn',
    ignoreErrorCodesMatching: 'ignoreErrorCodesMatching',
    outputPaths: ['outputPaths'],
    parameters: parameters,
    physicalResourceId: physicalResourceId,
    region: 'region',
  },
  onDelete: {
    action: 'action',
    service: 'service',

    // the properties below are optional
    apiVersion: 'apiVersion',
    assumedRoleArn: 'assumedRoleArn',
    ignoreErrorCodesMatching: 'ignoreErrorCodesMatching',
    outputPaths: ['outputPaths'],
    parameters: parameters,
    physicalResourceId: physicalResourceId,
    region: 'region',
  },
  onUpdate: {
    action: 'action',
    service: 'service',

    // the properties below are optional
    apiVersion: 'apiVersion',
    assumedRoleArn: 'assumedRoleArn',
    ignoreErrorCodesMatching: 'ignoreErrorCodesMatching',
    outputPaths: ['outputPaths'],
    parameters: parameters,
    physicalResourceId: physicalResourceId,
    region: 'region',
  },
  resourceType: 'resourceType',
  role: role,
  timeout: cdk.Duration.minutes(30),
});

こちらのコードに出てくるonCreateonDeleteonUpdateのTypeを見てみるとAwsSdkCallとなっています。

AwsSdkCallはその名の通り、AWSのAPIを呼び出すInterfacesです。説明もThe AWS SDK call to make when the resource is created.とAPIを呼び出す内容です。

APIのactionserviceなどを指定するだけでよいので、単純なカスタムリソースであれば簡単に実装できそうですね。

検証の構成

CodeCommitの承認ルールテンプレート関連の操作はCloudFormationで実装されていません。

そこで、今回はCodeCommitに関する以下の操作をカスタムリソースを介して行います。

  • 承認ルールテンプレートの作成/更新/削除
  • 承認ルールテンプレートとCodeCommitリポジトリの関連付け/関連付け解除

AWS CDKの構成

AWS CDKのディレクトリ構成は以下の通りです。

AWS CDKのディレクトリ構成

> tree 
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── custom-resources-test.ts
├── cdk.json
├── jest.config.js
├── lib
│   ├── codecommit-stack.ts
│   └── custom-resources-test-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── custom-resources-test.test.ts
└── tsconfig.json

3 directories, 12 files

また、AWS CDKのバージョンは2.3.0です。

package.json

{
  "name": "custom-resources-test",
  "version": "0.1.0",
  "bin": {
    "custom-resources-test": "bin/custom-resources-test.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@types/jest": "^26.0.10",
    "@types/node": "10.17.27",
    "jest": "^26.4.2",
    "ts-jest": "^26.2.0",
    "aws-cdk": "2.3.0",
    "ts-node": "^9.0.0",
    "typescript": "~3.9.7"
  },
  "dependencies": {
    "aws-cdk-lib": "2.3.0",
    "constructs": "^10.0.0",
    "source-map-support": "^0.5.16"
  }
}

./lib/custom-resources-test-stack.tsでは、カスタムリソースで承認ルールテンプレートに関する処理を行います。

作成する承認ルールテンプレートは「mainブランチに対するプルリクエストがあった場合、指定したIAMロールで1人以上の承認が必要」というルールです。AwsCustomResourceのAPI Referenceと以下APIの仕様に従って入力します。

なお、actionで入力する文字列の先頭は大文字ではなく、小文字の場合で入力する必要がある場合がほとんどです。

例えば、CreateApprovalRuleTemplate APIを呼び出したい場合は、createApprovalRuleTemplateとなります。

理由は、内部的にAWS SDKを使用しており、APIを呼び出す際にactionで入力した文字列をそのままSDKで実行するためです。そのためactionで入力した文字列がSDKに存在する必要があります。(例: CreateApprovalRuleTemplate APIであればcreateApprovalRuleTemplate)

仮にactionで入力した文字列がSDKに存在しない場合は、npx cdk deploy時に以下のようにエラーが出力されます。

> npx cdk deploy CustomResourcesTestStack
MFA token for arn:aws:iam::<AWSアカウントID>:mfa/<IAMユーザー名>: 899788
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬──────────────────────────────┬────────┬──────────────────────────────┬──────────────────────────────┬────────────────────────────────┐
│   │ Resource                     │ Effect │ Action                       │ Principal                    │ Condition                      │
├───┼──────────────────────────────┼────────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────┤
│ + │ ${AWS679f53fac002430cb0da5b7 │ Allow  │ sts:AssumeRole               │ Service:lambda.amazonaws.com │                                │
│   │ 982bd2287/ServiceRole.Arn}   │        │                              │                              │                                │
├───┼──────────────────────────────┼────────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────┤
│ + │ ${IamRole.Arn}               │ Allow  │ sts:AssumeRole               │ AWS:arn:${AWS::Partition}:ia │ "Bool": {                      │
│   │                              │        │                              │ m::<AWSアカウントID>:root         │   "aws:MultiFactorAuthPresent" │
│   │                              │        │                              │                              │ : "true"                       │
│   │                              │        │                              │                              │ }                              │
├───┼──────────────────────────────┼────────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────┤
│ + │ ${LogRetentionaae0aa3c5b4d4f │ Allow  │ sts:AssumeRole               │ Service:lambda.amazonaws.com │                                │
│   │ 87b02d85b201efdd8a/ServiceRo │        │                              │                              │                                │
│   │ le.Arn}                      │        │                              │                              │                                │
├───┼──────────────────────────────┼────────┼──────────────────────────────┼──────────────────────────────┼────────────────────────────────┤
│ + │ *                            │ Allow  │ codecommit:CreateApprovalRul │ AWS:${AWS679f53fac002430cb0d │                                │
│   │                              │        │ eTemplate                    │ a5b7982bd2287/ServiceRole}   │                                │
│   │                              │        │ codecommit:DeleteApprovalRul │                              │                                │
│   │                              │        │ eTemplate                    │                              │                                │
│   │                              │        │ codecommit:UpdateApprovalRul │                              │                                │
│   │                              │        │ eTemplateContent             │                              │                                │
│ + │ *                            │ Allow  │ logs:DeleteRetentionPolicy   │ AWS:${LogRetentionaae0aa3c5b │                                │
│   │                              │        │ logs:PutRetentionPolicy      │ 4d4f87b02d85b201efdd8a/Servi │                                │
│   │                              │        │                              │ ceRole}                      │                                │
└───┴──────────────────────────────┴────────┴──────────────────────────────┴──────────────────────────────┴────────────────────────────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────┐
│   │ Resource                                                         │ Managed Policy ARN                                                │
├───┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ + │ ${AWS679f53fac002430cb0da5b7982bd2287/ServiceRole}               │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasic │
│   │                                                                  │ ExecutionRole                                                     │
├───┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ + │ ${IamRole}                                                       │ arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess         │
├───┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤
│ + │ ${LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole}      │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasic │
│   │                                                                  │ ExecutionRole                                                     │
└───┴──────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
CustomResourcesTestStack: deploying...
[0%] start: Publishing 70893b631249dc61260989e92e90d60ae94fbbec490a1e065680d77383084d8d:current_account-current_region
[33%] success: Published 70893b631249dc61260989e92e90d60ae94fbbec490a1e065680d77383084d8d:current_account-current_region
[33%] start: Publishing c13434f8f1aa2ea30fa577b2feb208a41368b11787b752e10bfc71fe8eb919d5:current_account-current_region
[66%] success: Published c13434f8f1aa2ea30fa577b2feb208a41368b11787b752e10bfc71fe8eb919d5:current_account-current_region
[66%] start: Publishing b92cd70aaa31140e1e82e379f035b09d0d6e662a3d7d4314c913f85a1a6fc959:current_account-current_region
[100%] success: Published b92cd70aaa31140e1e82e379f035b09d0d6e662a3d7d4314c913f85a1a6fc959:current_account-current_region
CustomResourcesTestStack: creating CloudFormation changeset...
21:24:31 | CREATE_FAILED        | Custom::AWS           | ApprovalRuleTemplate0ECDB14A
Received response status [FAILED] from custom resource. Message returned: awsService[call.action] is not a function (RequestId: 26675c2a-6a8
8-496b-a3a4-5a932829d9b4)










 ❌  CustomResourcesTestStack failed: Error: The stack named CustomResourcesTestStack failed creation, it may need to be manually deleted from the AWS console: ROLLBACK_COMPLETE
    at Object.waitForStackDeploy (/<ディレクトリパス>/custom-resources-test/node_modules/aws-cdk/lib/api/util/cloudformation.ts:307:11)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at prepareAndExecuteChangeSet (/<ディレクトリパス>/custom-resources-test/node_modules/aws-cdk/lib/api/deploy-stack.ts:351:26)
    at CdkToolkit.deploy (/<ディレクトリパス>/custom-resources-test/node_modules/aws-cdk/lib/cdk-toolkit.ts:194:24)
    at initCommandLine (/<ディレクトリパス>/custom-resources-test/node_modules/aws-cdk/bin/cdk.ts:267:9)
The stack named CustomResourcesTestStack failed creation, it may need to be manually deleted from the AWS console: ROLLBACK_COMPLETE

また、物理IDにはAPIを実行したレスポンスに含まれる承認ルールテンプレートIDであるapprovalRuleTemplate.approvalRuleTemplateIdを指定します。

実際のコードは以下の通りです。

./lib/custom-resources-test-stack.ts

import { Construct } from "constructs";
import {
  Stack,
  StackProps,
  aws_iam as iam,
  aws_logs as logs,
  custom_resources as cr,
} from "aws-cdk-lib";

export class CustomResourcesTestStack extends Stack {
  public readonly approvalRuleTemplate: cr.AwsCustomResource;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const jumpAccountId: string = this.node.tryGetContext("jumpAccountId");

    // Create Infra Team IAM role
    const iamRole = new iam.Role(this, "IamRole", {
      assumedBy: new iam.AccountPrincipal(jumpAccountId).withConditions({
        Bool: {
          "aws:MultiFactorAuthPresent": "true",
        },
      }),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
      ],
    });

    this.approvalRuleTemplate = new cr.AwsCustomResource(
      this,
      "ApprovalRuleTemplate",
      {
        logRetention: logs.RetentionDays.ONE_WEEK,
        onCreate: {
          action: "createApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 1,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateDescription:
              "Approval rule template for the main branch",
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onUpdate: {
          action: "updateApprovalRuleTemplateContent",
          parameters: {
            newRuleContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 1,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onDelete: {
          action: "deleteApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          service: "CodeCommit",
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
          new iam.PolicyStatement({
            actions: [
              "codecommit:CreateApprovalRuleTemplate",
              "codecommit:UpdateApprovalRuleTemplateContent",
              "codecommit:DeleteApprovalRuleTemplate",
            ],
            resources: ["*"],
          }),
        ]),
      }
    );
  }
}

./lib/codecommit-stack.tsでは、CodeCommitのリポジトリの作成と、作成したリポジトリと承認ルールテンプレートの関連付けを行います。

AwsCustomResourceのAPI Referenceと以下APIの仕様に従って入力します。

また、物理IDには承認ルールテンプレートIDとリポジトリ名を結合した文字列を指定します。

実際のコードは以下の通りです。

./lib/codecommit-stack.ts

import { Construct } from "constructs";
import {
  Stack,
  StackProps,
  aws_logs as logs,
  aws_iam as iam,
  aws_codecommit as codecommit,
  custom_resources as cr,
} from "aws-cdk-lib";
interface CodeCommitStackProps extends StackProps {
  approvalRuleTemplate: cr.AwsCustomResource;
}

export class CodeCommitStack extends Stack {
  constructor(scope: Construct, id: string, props: CodeCommitStackProps) {
    super(scope, id, props);

    const repository = new codecommit.Repository(this, "Repository", {
      repositoryName: "CodeCommitTestRepository",
    });

    new cr.AwsCustomResource(
      this,
      "AssociateApprovalRuleTemplateWithRepository",
      {
        logRetention: logs.RetentionDays.ONE_WEEK,
        onCreate: {
          action: "associateApprovalRuleTemplateWithRepository",
          parameters: {
            approvalRuleTemplateName:
              props.approvalRuleTemplate.getResponseFieldReference(
                "approvalRuleTemplate.approvalRuleTemplateName"
              ),
            repositoryName: repository.repositoryName,
          },
          physicalResourceId: cr.PhysicalResourceId.of(
            `${props.approvalRuleTemplate.getResponseFieldReference(
              "approvalRuleTemplate.approvalRuleTemplateName"
            )}-${repository.repositoryName}`
          ),
          service: "CodeCommit",
        },
        onDelete: {
          action: "disassociateApprovalRuleTemplateFromRepository",
          parameters: {
            approvalRuleTemplateName:
              props.approvalRuleTemplate.getResponseFieldReference(
                "approvalRuleTemplate.approvalRuleTemplateName"
              ),
            repositoryName: repository.repositoryName,
          },
          service: "CodeCommit",
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
          new iam.PolicyStatement({
            actions: [
              "codecommit:AssociateApprovalRuleTemplateWithRepository",
              "codecommit:DisassociateApprovalRuleTemplateFromRepository",
            ],
            resources: ["*"],
          }),
        ]),
      }
    );
  }
}

./bin/custom-resources-test.tsでは、リポジトリ作成のスタックから承認ルールテンプレートを作成するスタックの値をクロススタック参照するように記述します。

実際のコードは以下の通りです。

./bin/custom-resources-test.ts

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { CustomResourcesTestStack } from "../lib/custom-resources-test-stack";
import { CodeCommitStack } from "../lib/codecommit-stack";

const app = new cdk.App();
const customResourcesTestStack = new CustomResourcesTestStack(
  app,
  "CustomResourcesTestStack"
);
new CodeCommitStack(app, "CodeCommitStack", {
  approvalRuleTemplate: customResourcesTestStack.approvalRuleTemplate,
});

やってみた

承認ルールテンプレートの作成

まず、承認ルールテンプレートの作成を行います。

承認ルールテンプレートを作成するため、npx cdk deploy CustomResourcesTestStackを実行しました。実行完了後にCodeCommitの承認ルールテンプレートを確認すると、確かに承認ルールテンプレートが作成されていました。

承認ルールテンプレートの作成

また、CloudFormationのコンソールからこちらのスタックのリソースを確認すると、物理IDが承認ルールテンプレートのID(b68e4a1c-cc70-4bdd-8d7a-884bbe077790)となっているリソースがありました。

承認ルールテンプレートの物理ID確認

物理IDがCustomResourcesTestStack-AWS679f53fac002430cb0da5b-INEth1zlk64DはAPIを呼び出すために自動で作成されたLambda関数です。

こちらのLambda関数のコードは以下の通りです。

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.handler = exports.forceSdkInstallation = exports.flatten = exports.PHYSICAL_RESOURCE_ID_REFERENCE = void 0;
/* eslint-disable no-console */
const child_process_1 = require("child_process");
const fs = require("fs");
const path_1 = require("path");
/**
 * Serialized form of the physical resource id for use in the operation parameters
 */
exports.PHYSICAL_RESOURCE_ID_REFERENCE = 'PHYSICAL:RESOURCEID:';
/**
 * Flattens a nested object
 *
 * @param object the object to be flattened
 * @returns a flat object with path as keys
 */
function flatten(object) {
    return Object.assign({}, ...function _flatten(child, path = []) {
        return [].concat(...Object.keys(child)
            .map(key => {
            const childKey = Buffer.isBuffer(child[key]) ? child[key].toString('utf8') : child[key];
            return typeof childKey === 'object' && childKey !== null
                ? _flatten(childKey, path.concat([key]))
                : ({ [path.concat([key]).join('.')]: childKey });
        }));
    }(object));
}
exports.flatten = flatten;
/**
 * Decodes encoded special values (physicalResourceId)
 */
function decodeSpecialValues(object, physicalResourceId) {
    return JSON.parse(JSON.stringify(object), (_k, v) => {
        switch (v) {
            case exports.PHYSICAL_RESOURCE_ID_REFERENCE:
                return physicalResourceId;
            default:
                return v;
        }
    });
}
/**
 * Filters the keys of an object.
 */
function filterKeys(object, pred) {
    return Object.entries(object)
        .reduce((acc, [k, v]) => pred(k)
        ? { ...acc, [k]: v }
        : acc, {});
}
let latestSdkInstalled = false;
function forceSdkInstallation() {
    latestSdkInstalled = false;
}
exports.forceSdkInstallation = forceSdkInstallation;
/**
 * Installs latest AWS SDK v2
 */
function installLatestSdk() {
    console.log('Installing latest AWS SDK v2');
    // Both HOME and --prefix are needed here because /tmp is the only writable location
    child_process_1.execSync('HOME=/tmp npm install aws-sdk@2 --production --no-package-lock --no-save --prefix /tmp');
    latestSdkInstalled = true;
}
const patchedServices = [
    { serviceName: 'OpenSearch', apiVersions: ['2021-01-01'] },
];
/**
 * Patches the AWS SDK by loading service models in the same manner as the actual SDK
 */
function patchSdk(awsSdk) {
    const apiLoader = awsSdk.apiLoader;
    patchedServices.forEach(({ serviceName, apiVersions }) => {
        const lowerServiceName = serviceName.toLowerCase();
        if (!awsSdk.Service.hasService(lowerServiceName)) {
            apiLoader.services[lowerServiceName] = {};
            awsSdk[serviceName] = awsSdk.Service.defineService(lowerServiceName, apiVersions);
        }
        else {
            awsSdk.Service.addVersions(awsSdk[serviceName], apiVersions);
        }
        apiVersions.forEach(apiVersion => {
            Object.defineProperty(apiLoader.services[lowerServiceName], apiVersion, {
                get: function get() {
                    const modelFilePrefix = `aws-sdk-patch/${lowerServiceName}-${apiVersion}`;
                    const model = JSON.parse(fs.readFileSync(path_1.join(__dirname, `${modelFilePrefix}.service.json`), 'utf-8'));
                    model.paginators = JSON.parse(fs.readFileSync(path_1.join(__dirname, `${modelFilePrefix}.paginators.json`), 'utf-8')).pagination;
                    return model;
                },
                enumerable: true,
                configurable: true,
            });
        });
    });
    return awsSdk;
}
/* eslint-disable @typescript-eslint/no-require-imports, import/no-extraneous-dependencies */
async function handler(event, context) {
    var _a, _b, _c, _d, _e, _f, _g, _h, _j, _l, _m, _o, _p;
    try {
        let AWS;
        if (!latestSdkInstalled && event.ResourceProperties.InstallLatestAwsSdk === 'true') {
            try {
                installLatestSdk();
                AWS = require('/tmp/node_modules/aws-sdk');
            }
            catch (e) {
                console.log(`Failed to install latest AWS SDK v2: ${e}`);
                AWS = require('aws-sdk'); // Fallback to pre-installed version
            }
        }
        else if (latestSdkInstalled) {
            AWS = require('/tmp/node_modules/aws-sdk');
        }
        else {
            AWS = require('aws-sdk');
        }
        try {
            AWS = patchSdk(AWS);
        }
        catch (e) {
            console.log(`Failed to patch AWS SDK: ${e}. Proceeding with the installed copy.`);
        }
        console.log(JSON.stringify(event));
        console.log('AWS SDK VERSION: ' + AWS.VERSION);
        event.ResourceProperties.Create = decodeCall(event.ResourceProperties.Create);
        event.ResourceProperties.Update = decodeCall(event.ResourceProperties.Update);
        event.ResourceProperties.Delete = decodeCall(event.ResourceProperties.Delete);
        // Default physical resource id
        let physicalResourceId;
        switch (event.RequestType) {
            case 'Create':
                physicalResourceId = (_j = (_f = (_c = (_b = (_a = event.ResourceProperties.Create) === null || _a === void 0 ? void 0 : _a.physicalResourceId) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : (_e = (_d = event.ResourceProperties.Update) === null || _d === void 0 ? void 0 : _d.physicalResourceId) === null || _e === void 0 ? void 0 : _e.id) !== null && _f !== void 0 ? _f : (_h = (_g = event.ResourceProperties.Delete) === null || _g === void 0 ? void 0 : _g.physicalResourceId) === null || _h === void 0 ? void 0 : _h.id) !== null && _j !== void 0 ? _j : event.LogicalResourceId;
                break;
            case 'Update':
            case 'Delete':
                physicalResourceId = (_o = (_m = (_l = event.ResourceProperties[event.RequestType]) === null || _l === void 0 ? void 0 : _l.physicalResourceId) === null || _m === void 0 ? void 0 : _m.id) !== null && _o !== void 0 ? _o : event.PhysicalResourceId;
                break;
        }
        let flatData = {};
        let data = {};
        const call = event.ResourceProperties[event.RequestType];
        if (call) {
            let credentials;
            if (call.assumedRoleArn) {
                const timestamp = (new Date()).getTime();
                const params = {
                    RoleArn: call.assumedRoleArn,
                    RoleSessionName: `${timestamp}-${physicalResourceId}`.substring(0, 64),
                };
                credentials = new AWS.ChainableTemporaryCredentials({
                    params: params,
                });
            }
            if (!Object.prototype.hasOwnProperty.call(AWS, call.service)) {
                throw Error(`Service ${call.service} does not exist in AWS SDK version ${AWS.VERSION}.`);
            }
            const awsService = new AWS[call.service]({
                apiVersion: call.apiVersion,
                credentials: credentials,
                region: call.region,
            });
            try {
                const response = await awsService[call.action](call.parameters && decodeSpecialValues(call.parameters, physicalResourceId)).promise();
                flatData = {
                    apiVersion: awsService.config.apiVersion,
                    region: awsService.config.region,
                    ...flatten(response),
                };
                let outputPaths;
                if (call.outputPath) {
                    outputPaths = [call.outputPath];
                }
                else if (call.outputPaths) {
                    outputPaths = call.outputPaths;
                }
                if (outputPaths) {
                    data = filterKeys(flatData, startsWithOneOf(outputPaths));
                }
                else {
                    data = flatData;
                }
            }
            catch (e) {
                if (!call.ignoreErrorCodesMatching || !new RegExp(call.ignoreErrorCodesMatching).test(e.code)) {
                    throw e;
                }
            }
            if ((_p = call.physicalResourceId) === null || _p === void 0 ? void 0 : _p.responsePath) {
                physicalResourceId = flatData[call.physicalResourceId.responsePath];
            }
        }
        await respond('SUCCESS', 'OK', physicalResourceId, data);
    }
    catch (e) {
        console.log(e);
        await respond('FAILED', e.message || 'Internal Error', context.logStreamName, {});
    }
    function respond(responseStatus, reason, physicalResourceId, data) {
        const responseBody = JSON.stringify({
            Status: responseStatus,
            Reason: reason,
            PhysicalResourceId: physicalResourceId,
            StackId: event.StackId,
            RequestId: event.RequestId,
            LogicalResourceId: event.LogicalResourceId,
            NoEcho: false,
            Data: data,
        });
        console.log('Responding', responseBody);
        // eslint-disable-next-line @typescript-eslint/no-require-imports
        const parsedUrl = require('url').parse(event.ResponseURL);
        const requestOptions = {
            hostname: parsedUrl.hostname,
            path: parsedUrl.path,
            method: 'PUT',
            headers: { 'content-type': '', 'content-length': responseBody.length },
        };
        return new Promise((resolve, reject) => {
            try {
                // eslint-disable-next-line @typescript-eslint/no-require-imports
                const request = require('https').request(requestOptions, resolve);
                request.on('error', reject);
                request.write(responseBody);
                request.end();
            }
            catch (e) {
                reject(e);
            }
        });
    }
}
exports.handler = handler;
function decodeCall(call) {
    if (!call) {
        return undefined;
    }
    return JSON.parse(call);
}
function startsWithOneOf(searchStrings) {
    return function (string) {
        for (const searchString of searchStrings) {
            if (string.startsWith(searchString)) {
                return true;
            }
        }
        return false;
    };

こちらのLambda関数のログは以下の通りです。AWS SDK VERSION: 2.1049.0となっているところから確かにSDKを介してAPIを呼び出しているようです。

START RequestId: 8e9c9ec6-6021-447f-9825-b2a2739751e3 Version: $LATEST
2022-01-04T08:19:54.410Z	8e9c9ec6-6021-447f-9825-b2a2739751e3	INFO	Installing latest AWS SDK v2
npm WARN deprecated uuid@3.3.2: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
npm WARN deprecated querystring@0.2.0: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
npm WARN enoent ENOENT: no such file or directory, open '/tmp/package.json'
npm WARN tmp No description
npm WARN tmp No repository field.
npm WARN tmp No README data
npm WARN tmp No license field.
2022-01-04T08:20:57.751Z	8e9c9ec6-6021-447f-9825-b2a2739751e3	INFO	
{
    "RequestType": "Create",
    "ServiceToken": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:CustomResourcesTestStack-AWS679f53fac002430cb0da5b-INEth1zlk64D",
    "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A<AWSアカウントID>%3Astack/CustomResourcesTestStack/f05226a0-6d36-11ec-833b-125c6536bdc3%7CApprovalRuleTemplate0ECDB14A%7C0c40c7a1-6c3c-4d64-808f-eb39bfc5ea20
    "StackId": "arn:aws:cloudformation:us-east-1:<AWSアカウントID>:stack/CustomResourcesTestStack/f05226a0-6d36-11ec-833b-125c6536bdc3",
    "RequestId": "0c40c7a1-6c3c-4d64-808f-eb39bfc5ea20",
    "LogicalResourceId": "ApprovalRuleTemplate0ECDB14A",
    "ResourceType": "Custom::AWS",
    "ResourceProperties": {
        "ServiceToken": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:CustomResourcesTestStack-AWS679f53fac002430cb0da5b-INEth1zlk64D",
        "Delete": "{\"action\":\"deleteApprovalRuleTemplate\",\"parameters\":{\"approvalRuleTemplateName\":\"approvalRuleTemplate\"},\"service\":\"CodeCommit\"}",
        "InstallLatestAwsSdk": "true",
        "Create": "{\"action\":\"createApprovalRuleTemplate\",\"parameters\":{\"approvalRuleTemplateContent\":\"{\\\"Version\\\":\\\"2018-11-08\\\",\\\"DestinationReferences\\\":[\\\"refs/heads/main\\\"],\\\"Statements\\\":[{\\\"Type\\\":\\\"Approvers\\\",\\\"NumberOfApprovalsNeeded\\\":1,\\\"ApprovalPoolMembers\\\":[\\\"arn:aws:sts::<AWSアカウントID>:assumed-role/CustomResourcesTestStack-IamRoleA750FF82-1TOUJ75WUY1O3/*\\\"]}]}\",\"approvalRuleTemplateDescription\":\"Approval rule template for the main branch\",\"approvalRuleTemplateName\":\"approvalRuleTemplate\"},\"physicalResourceId\":{\"responsePath\":\"approvalRuleTemplate.approvalRuleTemplateId\"},\"service\":\"CodeCommit\"}",
        "Update": "{\"action\":\"updateApprovalRuleTemplateContent\",\"parameters\":{\"newRuleContent\":\"{\\\"Version\\\":\\\"2018-11-08\\\",\\\"DestinationReferences\\\":[\\\"refs/heads/main\\\"],\\\"Statements\\\":[{\\\"Type\\\":\\\"Approvers\\\",\\\"NumberOfApprovalsNeeded\\\":1,\\\"ApprovalPoolMembers\\\":[\\\"arn:aws:sts::<AWSアカウントID>:assumed-role/CustomResourcesTestStack-IamRoleA750FF82-1TOUJ75WUY1O3/*\\\"]}]}\",\"approvalRuleTemplateName\":\"approvalRuleTemplate\"},\"physicalResourceId\":{\"responsePath\":\"approvalRuleTemplate.approvalRuleTemplateId\"},\"service\":\"CodeCommit\"}"
    }
}

2022-01-04T08:20:57.770Z	8e9c9ec6-6021-447f-9825-b2a2739751e3	INFO	AWS SDK VERSION: 2.1049.0
2022-01-04T08:20:58.430Z	8e9c9ec6-6021-447f-9825-b2a2739751e3	INFO	Responding 
{
    "Status": "SUCCESS",
    "Reason": "OK",
    "PhysicalResourceId": "b68e4a1c-cc70-4bdd-8d7a-884bbe077790",
    "StackId": "arn:aws:cloudformation:us-east-1:<AWSアカウントID>:stack/CustomResourcesTestStack/f05226a0-6d36-11ec-833b-125c6536bdc3",
    "RequestId": "0c40c7a1-6c3c-4d64-808f-eb39bfc5ea20",
    "LogicalResourceId": "ApprovalRuleTemplate0ECDB14A",
    "NoEcho": false,
    "Data": {
        "apiVersion": null,
        "region": "us-east-1",
        "approvalRuleTemplate.approvalRuleTemplateId": "b68e4a1c-cc70-4bdd-8d7a-884bbe077790",
        "approvalRuleTemplate.approvalRuleTemplateName": "approvalRuleTemplate",
        "approvalRuleTemplate.approvalRuleTemplateDescription": "Approval rule template for the main branch",
        "approvalRuleTemplate.approvalRuleTemplateContent": "{\"Version\":\"2018-11-08\",\"DestinationReferences\":[\"refs/heads/main\"],\"Statements\":[{\"Type\":\"Approvers\",\"NumberOfApprovalsNeeded\":1,\"ApprovalPoolMembers\":[\"arn:aws:sts::<AWSアカウントID>:assumed-role/CustomResourcesTestStack-IamRoleA750FF82-1TOUJ75WUY1O3/*\"]}]}",
        "approvalRuleTemplate.ruleContentSha256": "057f160448ab42b19d27b302f13901a4c7d47838b43e9f998d1a7e9fa80edaaf",
        "approvalRuleTemplate.lastModifiedUser": "arn:aws:sts::<AWSアカウントID>:assumed-role/CustomResourcesTestStack-AWS679f53fac002430cb0da5b-1PJREJVDLUR3O/CustomResourcesTestStack-AWS679f53fac002430cb0da5b-INEth1zlk64D"
    }
}

END RequestId: 8e9c9ec6-6021-447f-9825-b2a2739751e3
REPORT RequestId: 8e9c9ec6-6021-447f-9825-b2a2739751e3	Duration: 64156.55 ms	Billed Duration: 64157 ms	Memory Size: 128 MB	Max Memory Used: 128 MB	Init Duration: 172.06 ms

承認ルールテンプレートの更新

それでは次に承認ルールテンプレートの更新を行います。

具体的には、onUpdate内のNumberOfApprovalsNeededを1から2に変更して、承認が必要な数を1から2に変更してみます。

./lib/custom-resources-test-stack.ts

    this.approvalRuleTemplate = new cr.AwsCustomResource(
      this,
      "ApprovalRuleTemplate",
      {
        logRetention: logs.RetentionDays.ONE_WEEK,
        onCreate: {
          action: "createApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 1,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateDescription:
              "Approval rule template for the main branch",
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onUpdate: {
          action: "updateApprovalRuleTemplateContent",
          parameters: {
            newRuleContent: JSON.stringify({
              Version: "2018-11-08",
              DestinationReferences: ["refs/heads/main"],
              Statements: [
                {
                  Type: "Approvers",
                  NumberOfApprovalsNeeded: 2,
                  ApprovalPoolMembers: [
                    `arn:aws:sts::${this.account}:assumed-role/${iamRole.roleName}/*`,
                  ],
                },
              ],
            }),
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          physicalResourceId: cr.PhysicalResourceId.fromResponse(
            "approvalRuleTemplate.approvalRuleTemplateId"
          ),
          service: "CodeCommit",
        },
        onDelete: {
          action: "deleteApprovalRuleTemplate",
          parameters: {
            approvalRuleTemplateName: "approvalRuleTemplate",
          },
          service: "CodeCommit",
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
          new iam.PolicyStatement({
            actions: [
              "codecommit:CreateApprovalRuleTemplate",
              "codecommit:UpdateApprovalRuleTemplateContent",
              "codecommit:DeleteApprovalRuleTemplate",
            ],
            resources: ["*"],
          }),
        ]),
      }
    );

承認ルールテンプレートを更新するため、再度npx cdk deploy CustomResourcesTestStackを実行しました。実行完了後CodeCommitの承認ルールテンプレートを確認すると、確かに必要な承認の数が2に変更されていました。

承認ルールテンプレートの更新確認

承認ルールテンプレートとCodeCommitリポジトリの関連付け

続いて承認ルールテンプレートとCodeCommitリポジトリの関連付けを行います。

承認ルールテンプレートとCodeCommitリポジトリの関連付けを行うため、npx cdk deploy CodeCommitStackを実行しました。実行完了後にCodeCommitの承認ルールテンプレートを確認すると、確かに承認ルールテンプレートとCodeCommitリポジトリの関連付けがされていました。

承認ルールテンプレートとCodeCommitリポジトリの関連付け

承認ルールテンプレートとCodeCommitリポジトリの関連付け解除

続いて承認ルールテンプレートとCodeCommitリポジトリの関連付け解除を行います。

承認ルールテンプレートとCodeCommitリポジトリの関連付け解除を行うため、npx cdk destroy CodeCommitStackを実行しました。実行完了後にCodeCommitの承認ルールテンプレートを確認すると、確かに承認ルールテンプレートとCodeCommitリポジトリの関連付けが解除されていました。

承認ルールテンプレートとCodeCommitリポジトリの関連付け解除

承認ルールテンプレートの削除

最後に承認ルールテンプレートの削除を行います。

承認ルールテンプレートとCodeCommitリポジトリの関連付け解除を行うため、npx cdk destroy CustomResourcesTestStackを実行しました。実行完了後にCodeCommitの承認ルールテンプレートを確認すると、確かに承認ルールテンプレーが削除されていました。

承認ルールテンプレートの削除

単純なカスタムリソースならLambda関数レスでできちゃうかも

Lambda関数を使わずにAPIを呼び出してカスタムリソースを作成してみました。

これで「わざわざLambda関数作るのめんどくさいな」からちょっと脱出することができます。なお、APIを呼び出す前に処理を入れたり、複数のAPIを呼び出す場合はLambda関数を介してカスタムリソースを作成する必要があります。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!