CloudFormationで未対応なリソースを作成する方法

# 概要

- CloudFormationではすべてのリソースを作成できない場合がある。
- この場合カスタムリソースを利用して作成することが可能。
- 今回は、Cognitoでサーバリソースを作成出来なったため、カスタムリソースで作成することを試みました。

# 対象

- CloudFormationで構築できないリソースがあるが、カスタムリソースってどんなものなんだろう？という方。

# カスタムリソースの作成

## リクエスト

- CloudFormationのサブコマンドに **create-stack**、**update-stack**、**delete-stack** があるように、対象リソースに対して作成、更新、削除のリクエストが発生します。
- カスタムリソースでも、それぞれのリクエストタイプごとに、Lambdaには event が渡されるので、それに応じた処理を実装が必要になります。

## レスポンス

- レスポンスはCloudFormationで解釈できるように、特定のフォーマットで返す必要があります。
- このレスポンス不備な場合、CloudFormationではカスタムリソースが処理中のまま、CloudFormationのタイムアウトになるまで待つしかないという悲惨な状態になった気がします。。。
- 今回はPythonでカスタムリソースを実装しまが、[cfnresponse](https://docs.aws.amazon.com/en_pv/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html) モジュールを利用することで、このレスポンスは容易に作成させることが可能です。

## 実装

- Pythonで実装しています。
- 最低限動くレベルでの実装になります。

```py
import json
import cfnresponse
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
import boto3
client = boto3.client('cognito-idp')

def handler(event, context):
  response = {}
  try:
    logger.info(json.dumps(event))
    client = boto3.client('cognito-idp')
    resource_properties = event.get('ResourceProperties', {})
    old_resource_properties = event.get('OldResourceProperties', {})
    if event.get('RequestType', '') == 'Create':
      response = create(resource_properties)
    elif event.get('RequestType', '') == 'Update':
      if exists(resource_properties) is False:
        delete(old_resource_properties)
        response = create(resource_properties)
      else:
        response = update(resource_properties)
    elif event.get('RequestType', '') == 'Delete':
      response = delete(resource_properties)
    else:
      response = {}
    logger.info(json.dumps(response))
    user_pool_id = response.get('ResourceServer', {}).get('UserPoolId', '')
    identifier = response.get('ResourceServer', {}).get('Identifier', '')
    physical_resource_id = '%s/%s' % (user_pool_id, identifier)
    cfnresponse.send(event, context, cfnresponse.SUCCESS, response, physical_resource_id)
  except Exception as e:
    logger.error(e)
    cfnresponse.send(event, context, cfnresponse.FAILED, {}, '/')
# 作成
def create(properties):
  return client.create_resource_server(
    UserPoolId = properties.get('UserPoolId', ''),
    Identifier = properties.get('Identifier', ''),
    Name = properties.get('Name', ''),
    Scopes = properties.get('Scopes', [])
  )
# 作成チェック
def exists(properties):
  try:
    res = client.describe_resource_server(
      UserPoolId = properties.get('UserPoolId', ''),
      Identifier = properties.get('Identifier', '')
    )
    return True
  except Exception as e:
    return False
# 更新
def update(properties):
  return client.update_resource_server(
    UserPoolId = properties.get('UserPoolId', ''),
    Identifier = properties.get('Identifier', ''),
    Name = properties.get('Name', ''),
    Scopes = properties.get('Scopes', [])
  )
# 削除
def delete(properties):
  return client.delete_resource_server(
    UserPoolId = properties.get('UserPoolId', ''),
    Identifier = properties.get('Identifier', '')
  )
```

- 作成リクエスト（Create)
    - **event['ResourceProperties']** を元にリソースサーバを作成（create_resource_server）します。
    - ResourcePropertiesの *UserPoolId*、*Identifier*、*Name*、*Scope* はCloudFormationで定義したものになります。CloudFormationの使い方で説明します。
- 更新リクエスト（Update）
    - **event['ResourceProperties']** のリソースサーバが作成済みであるかチェックします。（作成済みかのチェックは *UserPoolId* と *Identifier* の組み合わせのリソースサーバがあるかどうか）
    - 作成済みの場合は、作成済みのサーバリソースを更新（update_resource_server）します。
    - 未作成の場合は、**event['OldResourceProperties']** から、既存のリソースサーバを削除（delete_resource_server）し、**event['ResourceProperties']**を元にリソースサーバを作成（create_resource_server）します。
- 削除リクエスト（Delete）
    - **event['ResourceProperties']** のリソースサーバを削除します。
- レスポンス
    - リクエストタイプごとに、それぞれレスポンスのフォーマットが決まっています。
    - 詳細については、[作成](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/crpg-ref-requesttypes-create.html)、[更新](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/crpg-ref-requesttypes-update.html)、[削除](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/crpg-ref-requesttypes-delete.html) のそれぞれのページで確認できます。
    - ただ、今回は [cfnresponse](https://docs.aws.amazon.com/en_pv/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html) モジュールを利用しています。
    - **cfnresponse.send** では、第1、第2引数では、リクエストの event、context をそのまま指定し、第3引数にリソースに対する処理の成否、第4引数には、CloudFormationに渡す値を設定できます。（第5引数は、リソースの物理IDを指定します。）
    - 第4引数に指定した値は、CloudFormationのテンプレート側で GetAtt で参照することが出来る値になります。（今回は、sdkのレスポンスを設定していますが。。。）

## 権限

- Cognitoのカスタムリソースへの操作が必要になります。上記実装の場合には、以下のPolicyに対してAllow権限を、LambdaのRoleに追加してください。
    - cognito-idp:CreateResourceServer
    - cognito-idp:DescribeResourceServer
    - cognito-idp:UpdateResourceServer
    - cognito-idp:DeleteResourceServer
- これらの権限は、それぞれ作成に必要なPolicyを指定してください。（上記のPolicyはあくまで、Cognitoのリソースサーバを操作する場合です）

# 使い方

- CloudFormationでは以下のように指定し使います。

```yaml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  FunctionArn:
    Type: String

Resources:
  UserPool:
    Type: AWS::Cognito::UserPool

  ResourceServer:
    Type: Custom::CognitoResourceServer
    Properties:
      ServiceToken: !Ref FunctionArn
      UserPoolId: !Ref UserPool
      Identifier: example-user-pool-client
      Name: example-user-pool-client
      Scopes:
      - ScopeName: scope-name
        ScopeDescription: scope-description
```

- ServiceTokenは必須です。作成したカスタムリソースのARNを指定します。
- また、ServiceTokenを含め、それぞれの設定した任意の値は、Lambda側に **event['ResourceProperties']** として渡されます。
- リソースサーバの操作に限らず、他のリソースについても、作成に必要な値はここで指定し、カスタムリソース（Lambda）で拾うようにします。

# さいごに

- このカスタムリソースを利用する方法を用いれば、CloudFormationでは対応していないが、aws-sdkを使って作成出来るものであれば、対応できます！
- サンプルは [リポジトリ](https://github.com/ot-nemoto/cognito-create-resource-server) を参照。