既存のCognitoにTerraformからLambdaトリガーを設定する

Terraform でインフラの構成構築を行っている際に、既存の CognitoにLambdaトリガーを取り付けたい、というシーンがありました。

Cognito IDP の Data Sourcesdata ブロックで表現される、Terraformの外部で定義された情報を参照できるリソース)を頼りにトリガーとなる Lambda の設定ができないかと Terraform のリファレンスを眺めていたのですが、残念ながら既存のCognitoに対して変更を行えそうなリソースが見つかりませんでした。

そこで、Terraform から AWS CLI のコマンドを実行して Lambda の取り付けができないかと考えたのですが、いくつか注意点があったのでその紹介です。

前提

Terraform 自体やシェルスクリプトの説明は行いません。

Lambdaの作成する準備

まず、検証用の Lambda 関数をデプロイするためのインフラコードを作成します。

CognitoへのLambdaの取り付けが確認できれば良いので、以下の Terraform のインフラコードで空っぽのファイルをzip圧縮したダミーのコードを持つ Lambda関数を作成します。

※ local.region はお使いのリージョンに読み替えてください。

locals {
  region = "us-west-2"
}
 
provider "aws" {
  region                   = local.region
  shared_credentials_files = ["~/.aws/credentials"]
  profile                  = "dev"
}
 
data "archive_file" "dummy" {
  type        = "zip"
  output_path = "${path.module}/dummy.zip"
  source {
    content  = "dummy"
    filename = "bootstrap"
  }
}
 
resource "aws_iam_role" "iam_for_lambda" {
  name = "iam_for_lambda"
 
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}
 
resource "aws_lambda_function" "cognito_trigger_lambda" {
  function_name = "cognito-trigger-lambda"
  role          = aws_iam_role.iam_for_lambda.arn
 
  filename = data.archive_file.dummy.output_path
 
  runtime = "nodejs14.x"
  handler = "index.test"
}

update-user-pool コマンド

AWS CLI のコマンド cognito-idp update-user-pool でCognitoにLambdaトリガーを取り付けることができますが、注意が必要です。

https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cognito-idp/update-user-pool.html

If you don’t provide a value for an attribute, it will be set to the default value.

リファレンスには「属性の値を指定しない場合は、デフォルト値に設定される」とあります。

実際、単純に以下のようにコマンドを実行してしまうと、Lambdaトリガーの取り付けが行われるものの、既になされている他の設定がデフォルト値に戻ってしまいます。

aws cognito-idp update-user-pool --user-pool-id "<ユーザープールID>" --lambda-config PreSignUp="<Lambda関数のARN>"

このことは aws/aws-cli の issue としても挙がっています。

https://github.com/aws/aws-cli/issues/3302

解決策としては、JSONプロセッサ jq を駆使して以下のように現在の設定を —cli-input-json で指定しつつ、新規に追加したい内容(ここではLambdaトリガー)を --lambda-config で指定する方法が提示されています。

user_pool_id="<ユーザープールID>"
function_arn="<Lambda関数のARN>"
 
# 利用可能な設定のキーを取得する
config_keys=$(aws cognito-idp update-user-pool --generate-cli-skeleton | jq 'keys')
 
# 現在のユーザープールの設定値を取得し、更新用データを作成する
# note: we delete AdminCreateUserConfig.UnusedAccountValidityDays because it is not supported when using Policies.PasswordPolicy.TemporaryPasswordValidityDays
current_config=$(aws cognito-idp describe-user-pool --user-pool-id "${user_pool_id}" | jq --argjson whitelist "${config_keys}" '.UserPool | . + {UserPoolId:.Id} | with_entries(select(.key as $a | any($whitelist[]; . == $a))) | del(.AdminCreateUserConfig.UnusedAccountValidityDays)')
 
# 現在のユーザープールの設定にLambdaトリガーの設定をマージする
aws cognito-idp update-user-pool --lambda-config "PostConfirmation=${function_arn}" --cli-input-json "${current_config}"

この方法で良さそうに思えたのですが、このままでは、まだ少し課題があることに気が付きました。

上記で取り付けている関数以外に、事前に取り付けられているLambda関数の設定がある場合、事前の設定が消失してしまいます。

例えば、Cognitoにサインアップ前に動作するLambdaトリガー(PreSignUp)が取り付けられている状態で、上記のように PostConfirmation の Lambdaトリガーの設定を行うと、PreSignUp に取り付けられている Lambdaの設定は無くなってしまいます。

これを回避するために以下のように追加したい内容をJSONで表現するようにして、既存の設定のJSONにマージして設定を行うことで、他の設定に影響を出さずに新規で意図したLambdaトリガーの取り付けが行えるようになります。

user_pool_id="<ユーザープールID>"
function_arn="<Lambda関数のARN>"
 
# 利用可能な設定のキーを取得する
config_keys=$(aws cognito-idp update-user-pool --generate-cli-skeleton | jq 'keys')
 
# 現在のユーザープールの設定値を取得し、更新用データを作成する
# note: we delete AdminCreateUserConfig.UnusedAccountValidityDays because it is not supported when using Policies.PasswordPolicy.TemporaryPasswordValidityDays
current_config=$(aws cognito-idp describe-user-pool --user-pool-id "${user_pool_id}" | jq --argjson whitelist "${config_keys}" '.UserPool | . + {UserPoolId:.Id} | with_entries(select(.key as $a | any($whitelist[]; . == $a))) | del(.AdminCreateUserConfig.UnusedAccountValidityDays)')
 
# 現在のユーザープールの設定にLambdaトリガーの設定をマージ
input_json=$(echo ${current_config} | jq --arg func_arn ${function_arn} '. * {"LambdaConfig":{"PostConfirmation": $func_arn}}')
 
aws cognito-idp update-user-pool --cli-input-json "${input_json}"

provisioner "local-exec"

ようやくAWS CLIのコマンドからLambdaトリガーの取り付けが行えるようになりましたが、今度はこれをTerraformから実行できるようにしてみたいと思います。

結論から書くと、冒頭の Lambda をデプロイするための Terraform のインフラコードに以下のコードを追加します。

locals {
  # Cognito ユーザープールID
  user_pool_id="<ユーザープールID>"
 
    # 新規に追加したいLambdaトリガーの定義
  additional_cognito_settings = jsonencode({
    "LambdaConfig" : {
      "PreSignUp" : "${resource.aws_lambda_function.cognito_trigger_lambda.arn}"
    }
  })
}
 
resource "null_resource" "attach_cognito_lambda_trigger" {
  provisioner "local-exec" {
    # 実行したいコマンド
    command = <<-EOT
      config_keys=$(aws cognito-idp update-user-pool --generate-cli-skeleton | jq 'keys')
      current_config=$(aws cognito-idp describe-user-pool --user-pool-id ${local.user_pool_id} | jq --argjson whitelist "$config_keys" '.UserPool | . + {UserPoolId:.Id} | with_entries(select(.key as $a | any($whitelist[]; . == $a))) | del(.AdminCreateUserConfig.UnusedAccountValidityDays)')
      input_json=$(echo $current_config | jq '. * ${local.additional_cognito_settings}')
      aws cognito-idp update-user-pool --cli-input-json "$input_json"
    EOT
 
    # Windows環境ではデフォルトでcmd(コマンドプロンプト)でコマンドが実行されてしまうため
    # 明示的に bash を指定
    interpreter=["bash", "-c"]
    # 作業ディレクトリは本モジュール(このインフラコード)と同じディレクトリに設定
    working_dir=path.module
  }
}

インスタンスの存在しないリソースの定義 null_resource に provisioner を記述してTerraformを実行しているマシン上で任意のコマンドを実行するようにしています。

Provisioners

https://www.terraform.io/language/resources/provisioners/syntax

Provisioners can be used to model specific actions on the local machine or on a remote machine in order to prepare servers or other infrastructure objects for service.

私訳)プロビジョナーを使用すると、ローカルマシンまたはリモートマシンで特定のアクションをモデル化して、サービス用のサーバーまたはその他のインフラストラクチャオブジェクトを準備できます。

local-exec Provisioner

https://www.terraform.io/language/resources/provisioners/local-exec

The local-exec provisioner invokes a local executable after a resource is created. This invokes a process on the machine running Terraform, not on the resource.

私訳)local-exec Provisioner は、リソースの作成後にローカル実行可能ファイルを呼び出します。これはリソース上ではなく、Terraformを実行しているマシン上でプロセスを起動します。

以上で、TerraformからLambdaをデプロイしCognitoのLambdaトリガーとして取り付けるところまでをインフラコードにすることができました。

また、上記の例では記載していませんが、 null_resourcetriggers を使用することで、コマンドが実行されるタイミングを制御することもできます。

特定のスクリプトファイルが更新されたタイミングでコマンドを実行したい場合は以下のように記述することで実現できるようです。

https://discuss.hashicorp.com/t/re-triggering-shell-script-in-terraform/2988/2

resource "null_resource" "run_script" {
  triggers = {
    script_hash = "${sha256(var.bash_script)}"
  }
...
}