AmplifyするならTerraformで

こんにちは。フロントエンド初学者です。
最近、Vue.js を勉強するために、Amplify、Appsyncを使って習作を試みています。その中で、AWSの環境構築は、amplify-cliを使ってCloudFormationで行うよりも、Terraformを使ったほうが管理しやすいし、柔軟に設定できるじゃないかと思ったため報告します。
Terraform、Vue.js、Amplifyとも初めて使ったので、おかしい点があるかもしれません。ご指摘いただけると嬉しいです。

## なぜTerraformか？

Amplifyを理解していて、CFnにも慣れている人は、断然amplify-cliを使ったほうが楽だと思います。あえてTerraformを使う理由は無いかもしれません。

今回、私がTerraformを使おうと思った動機は、S3のトリガでLambdaFunctionを作ったときに、ソースに環境変数を渡したいが設定方法が分からなかった、GraphQLをIAM認証にして特定のクエリだけゲストアクセスさせたいが設定方法が分からなかったというものです。Terraformではドキュメントさえ読めば、これらも柔軟に設定できました。
そして、下の3つがTerraformの方が使いやすいと感じている点です。特にあとの2つは重要だと思います。

#### 自分で書いたこと以上に設定されない

amplify-cliだと、用途によっては使わないリソースが作成されたり、自動生成された名前が使われます。
あたりまえですが、Terraformの場合は自分で名付けて、設定したリソース以上に作成されないので、何をやってるかイメージしやすいです。
これは初学者限定の問題かもしれません。

#### リソース変更をコード化するのサイクルが簡単

amplify-cliは、amplify add/update/push などのコマンドでリソース更新しますが、作成したリソースを継続的に変更/管理していくのに、どこをいじったら良いのかわかりにくく難しいです。
Terraformでは、AWSコンソールで変更したとしても、import -> plan -> apply の繰り返しで、継続的にコード化できるのが良いです。

#### データを設定と分離してコード化できる

amplify-cliは `amplify/backend/awscloudformation/nested-cloudformation-stack.yml` というスタック定義ファイルが出力されますが、
ARNやアカウントIDがところどころ埋め込まれていて、作ったものを共有しづらいです。その点Terraformは見せたくない情報はtfvarsやtfstateで秘匿でき、tfファイルで定義のみ共有できます。

## TerraformでAmplify環境を作成する

それでは、[aws-samples/aws-amplify-vue](https://github.com/aws-samples/aws-amplify-vue) の環境をTerraformで構築して、動くようにしていきたいと思います。
amplify-cliでは生成されるけれども、今回使用しないリソースについては、追加しないようにしています。
作ったものは [github](https://github.com/sabmeua/aws-amplify-vue) に置いています。
git clone -> terraform init & apply -> npm install & start したら使えるようになっていると思いますので、こちらも参考にしてください。

### 準備

https://github.com/sabmeua/aws-amplify-vue/commit/674e50c9539bfbfee48e97e30ee8937b26fb28a7

まず、必要なファイルを作成して、Terraformを使えるようにします。 `tfvars` と `ftstate` は .gitignore しておきます。

```shell

mkdir terraform
touch terraform/main.tf terraform/aws-exports.tf terraform/terraform.tfvars
cat <<EOF >> .gitignore
terraform/.terraform
terraform/terraform.tfvars
terraform/terraform.tfstate*
EOF
terraform init
```

作成したファイルは、以下のように編集します。


```HCL:terraform/main.tf

variable "app_name" {
  type    = "string"
  default = "amplify_terraform_example"
}

variable "app_env" {
  type    = "string"
  default = "dev"
}

variable "aws_profile" {}
variable "aws_default_region" {}

provider "aws" {
  profile = "${var.aws_profile}"
  region  = "${var.aws_default_region}"
}

data "aws_caller_identity" "current" {}
```

main.tf はアプリ名、環境名、AWSプロバイダ初期化を行います。
AWSアカウントはプロファイルで指定するようにしたので、aws-cliで予めプロファイルを作っておいて、terraform.tfvarsにリージョンとあわせて設定します。

```HCL:terraform/aws-exports.tf
resource "local_file" "aws_exports_js" {
  filename = "../src/aws-exports.js"
  content  = <<EOF
const awsmobile = {
    "aws_project_region": "${var.aws_default_region}",
};
export default awsmobile;
EOF
}
```

aws-exports.tfは、amplifyのアプリ用の設定ファイルを生成するファイルです。この時点ではリージョンのみ書き出すようにしています。

できたら、一旦 `terraform plan` で間違いが無いか確認しておきましょう。

### 初期化

https://github.com/sabmeua/aws-amplify-vue/commit/c92d148b2e52b9eae10b9dc1b177d7d902ab95a9

`amplify init` に相当する設定をします。
ここでは、認証済みと未認証のIAMロールを作成します。 amplify-cliでは"deployment"というCFn用のS3バケットが作成されますが、使わないので作成しません。

```HCL:terraform/roles.tf

resource "aws_iam_role" "authenticated_user" {
  name               = "${var.app_name}_${var.app_env}_authed_user_role"
  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "cognito-identity.amazonaws.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "ForAnyValue:StringLike": {
                    "cognito-identity.amazonaws.com:amr": "authenticated"
                }
            }
        }
    ]
}
EOF
}

resource "aws_iam_role" "unauthenticated_user" {
  name = "${var.app_name}_${var.app_env}_unauthed_user_role"
  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "cognito-identity.amazonaws.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "ForAnyValue:StringLike": {
                    "cognito-identity.amazonaws.com:amr": "unauthenticated"
                }
            }
        }
    ]
}
EOF
}
```

### 認証

https://github.com/sabmeua/aws-amplify-vue/commit/6eb95e39234b7f56647306f99b8a4f4722266682

`amplify add auth` に相当する設定を行います。
認証に関して追加するリソースはcognito user poolとidentity poolです。identity pool には認証済みユーザ、未認証ユーザそれぞれに上で設定したIAMロールのidを発行するよう設定します。
ここでもamplify-cliではSNSトピックやLambda関数などが追加されますが、今回使いませんので下記には記述していません。

aws-exports.jsには、cognitoの設定が書き出されるよう変更しましょう。

```HCL:terraform/auth.tf

variable "aws_cognito_user_pool_name" {
  type    = "string"
  default = "user_pool"
}

variable "aws_cognito_user_pool_client_web_name" {
  type    = "string"
  default = "user_pool_client_web"
}

variable "aws_cognito_user_pool_client_app_name" {
  type    = "string"
  default = "user_pool_client_app"
}

variable "aws_cognito_identity_pool_name" {
  type    = "string"
  default = "identity_pool"
}

resource "aws_cognito_user_pool" "userpool" {
  name = "${var.app_name}_${var.app_env}_${var.aws_cognito_user_pool_name}"
  auto_verified_attributes = [
    "email"
  ]

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = false
    require_uppercase = false
  }
}

resource "aws_cognito_user_pool_client" "webclient" {
  name            = "${var.app_name}_${var.app_env}_${var.aws_cognito_user_pool_client_web_name}"
  user_pool_id    = "${aws_cognito_user_pool.userpool.id}"
  generate_secret = false
}

resource "aws_cognito_user_pool_client" "appclient" {
  name            = "${var.app_name}_${var.app_env}_${var.aws_cognito_user_pool_client_app_name}"
  user_pool_id    = "${aws_cognito_user_pool.userpool.id}"
  generate_secret = false
}

resource "aws_cognito_identity_pool" "idpool" {
  identity_pool_name               = "${var.app_name}_${var.app_env}_${var.aws_cognito_identity_pool_name}"
  allow_unauthenticated_identities = true
  cognito_identity_providers {
    client_id               = "${aws_cognito_user_pool_client.webclient.id}"
    provider_name           = "${aws_cognito_user_pool.userpool.endpoint}"
    server_side_token_check = false
  }
  cognito_identity_providers {
    client_id               = "${aws_cognito_user_pool_client.appclient.id}"
    provider_name           = "${aws_cognito_user_pool.userpool.endpoint}"
    server_side_token_check = false
  }
}

resource "aws_cognito_identity_pool_roles_attachment" "user_role_attachment" {
  identity_pool_id = "${aws_cognito_identity_pool.idpool.id}"
  roles = {
    "authenticated"   = "${aws_iam_role.authenticated_user.arn}"
    "unauthenticated" = "${aws_iam_role.unauthenticated_user.arn}"
  }
}
```

できたら一度 `terraform apply` します。 `npm start` してアプリにアクセスすると、
作成したuser poolでサインアップ、サインインができるようになっていると思います。

### ストレージ

https://github.com/sabmeua/aws-amplify-vue/commit/d526da7a652c76062ddf6129b5dfa289d78d4581

次に `amplify add storage` 相当の設定を行います。まずストレージ用のバケットを作成します。

```HCL:terraform/storage.tf

variable "aws_s3_bucket_storage_name" {
  type = "string"
  default = "storage-bucket"
}

resource "aws_s3_bucket" "storage_bucket" {
  bucket = "${replace(var.app_name, "_", "-")}-${var.app_env}-${var.aws_s3_bucket_storage_name}"
  cors_rule {
    allowed_headers = ["*"]
    allowed_methods = ["GET", "HEAD", "PUT", "POST", "DELETE"]
    allowed_origins = ["*"]
    expose_headers = [
      "x-amz-server-side-encryption",
      "x-amz-request-id",
      "x-amz-id-2",
      "ETag"
    ]
    max_age_seconds = 3000
  }
}
```

そして、このバケットへの権限をIAMロールに与えていきます。今回はサンプルアプリの実装のとおり、認証済みユーザのみアクセスできるようにします。ゲストアクセスに対して権限をつける場合は、ここでunauthenticated_userにもポリシーをアタッチすれば良いわけです。

これはamplify-cliでは簡単にできるのに対して、Terraformだとかなり手間ですが、このIAMの設定はAmplifyのキモの部分だとおもうので、自分で設定すると理解が深まります。

```HCL:terraform/roles.tf

resource "aws_iam_role" "authenticated_user" {
  name               = "${var.app_name}_${var.app_env}_authed_user_role"
  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "cognito-identity.amazonaws.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "ForAnyValue:StringLike": {
                    "cognito-identity.amazonaws.com:amr": "authenticated"
                }
            }
        }
    ]
}
EOF
}

resource "aws_iam_role" "unauthenticated_user" {
  name               = "${var.app_name}_${var.app_env}_unauthed_user_role"
  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "cognito-identity.amazonaws.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "ForAnyValue:StringLike": {
                    "cognito-identity.amazonaws.com:amr": "unauthenticated"
                }
            }
        }
    ]
}
EOF
}

resource "aws_iam_policy" "storage_upload" {
  name   = "${var.app_name}_${var.app_env}_storage_upload_policy"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}/uploads/*"
            ],
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_policy_attachment" "attach_storage_upload" {
  name       = "attach_storage_upload"
  roles      = ["${aws_iam_role.authenticated_user.name}"]
  policy_arn = "${aws_iam_policy.storage_upload.arn}"
}

resource "aws_iam_policy" "storage_read" {
  name   = "${var.app_name}_${var.app_env}_storage_read_policy"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}/protected/*"
            ],
            "Effect": "Allow"
        },
        {
            "Condition": {
                "StringLike": {
                    "s3:prefix": [
                        "public/",
                        "public/*",
                        "protected/",
                        "protected/*",
                        "private/$${cognito-identity.amazonaws.com:sub}/",
                        "private/$${cognito-identity.amazonaws.com:sub}/*"
                    ]
                }
            },
            "Action": [
                "s3:ListBucket"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}"
            ],
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_policy_attachment" "attach_storage_read" {
  name       = "attach_storage_read"
  roles      = ["${aws_iam_role.authenticated_user.name}"]
  policy_arn = "${aws_iam_policy.storage_read.arn}"
}

resource "aws_iam_policy" "storage_write_public" {
  name   = "${var.app_name}_${var.app_env}_storage_write_public_policy"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}/public/*"
            ],
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_policy_attachment" "attach_storage_write_public" {
  name       = "attach_storage_write_public"
  roles      = ["${aws_iam_role.authenticated_user.name}"]
  policy_arn = "${aws_iam_policy.storage_write_public.arn}"
}

resource "aws_iam_policy" "storage_write_protected" {
  name   = "${var.app_name}_${var.app_env}_storage_write_protected_policy"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}/protected/$${cognito-identity.amazonaws.com:sub}/*"
            ],
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_policy_attachment" "attach_storage_write_protected" {
  name       = "attach_storage_write_protected"
  roles      = ["${aws_iam_role.authenticated_user.name}"]
  policy_arn = "${aws_iam_policy.storage_write_protected.arn}"
}

resource "aws_iam_policy" "storage_write_private" {
  name   = "${var.app_name}_${var.app_env}_storage_write_private_policy"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "${aws_s3_bucket.storage_bucket.arn}/private/$${cognito-identity.amazonaws.com:sub}/*"
            ],
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_policy_attachment" "attach_storage_write_private" {
  name       = "attach_storage_write_private"
  roles      = ["${aws_iam_role.authenticated_user.name}"]
  policy_arn = "${aws_iam_policy.storage_write_private.arn}"
}
```
aws-exports.js にはバケット名などが書き出されるよう設定しておきます。

ここまでくると、ログイン後のプロファイル画面で自分のアバターをアップロードできるようになっていると思います。アバターはpublicにアップロードされるので、他のアカウントのアバターにアクセスもできるはずです。

### Appsync GraphQL API

https://github.com/sabmeua/aws-amplify-vue/commit/0c2c231c6726ca2f42745ecedfca3ad9807dc916

最後のフィーチャーとして Appsync を追加します。これは `amplify add api` に相当します。
DynamoDBのテーブルを作成して、それをデータソースとしたGraphQL APIをAppsyncに設定します。

GraphQLのスキーマ、リゾルバもここで設定します。内容については説明しませんが、スキーマ、リゾルバは `terraform/appsync/graphql` の下にファイルで配置しているので、自由にカスタマイズできます。amplify-cliではSubscriptionなどのスキーマも自動生成されますが、今は要らないので追加していません。

```HCL:terraform/api.tf

variable "aws_dynamodb_table_todos_name" {
  type    = "string"
  default = "TodoTable"
}

variable "aws_iam_role_appsync_todos_api_name" {
  type        = "string"
  default     = "appsync_todos_api_role"
  description = "Appsync execution role"
}

variable "aws_iam_role_policy_appsync_todos_api_name" {
  type        = "string"
  default     = "appsync_todos_api_role_policy"
  description = "Appsync execution role policy"
}

variable "aws_appsync_datasource_todos_name" {
  type    = "string"
  default = "TodoDatasource"
}

variable "aws_appsync_graphql_api_todos_name" {
  type    = "string"
  default = "todos_api"
}

data "local_file" "graphql_todos_api_schema" {
  filename = "appsync/graphql/schema.graphql"
}

data "local_file" "graphql_resolver_create_todo" {
  filename = "appsync/graphql/resolvers/createTodo.vm"
}

data "local_file" "graphql_resolver_update_todo" {
  filename = "appsync/graphql/resolvers/updateTodo.vm"
}

data "local_file" "graphql_resolver_delete_todo" {
  filename = "appsync/graphql/resolvers/deleteTodo.vm"
}

data "local_file" "graphql_resolver_get_todo" {
  filename = "appsync/graphql/resolvers/getTodo.vm"
}

data "local_file" "graphql_resolver_list_todos" {
  filename = "appsync/graphql/resolvers/listTodos.vm"
}

data "local_file" "graphql_resolver_response" {
  filename = "appsync/graphql/resolvers/response.vm"
}

resource "aws_dynamodb_table" "todos_table" {
  name         = "${var.app_name}_${var.app_env}_${var.aws_dynamodb_table_todos_name}"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

resource "aws_iam_role" "appsync_todos_api_role" {
  name               = "${var.aws_iam_role_appsync_todos_api_name}"
  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "sts:AssumeRole",
            "Principal": {
                "Service": "appsync.amazonaws.com"
            },
            "Effect": "Allow"
        }
    ]
}
EOF
}

resource "aws_iam_role_policy" "appsync_todos_api_role_policy" {
  name = "${var.aws_iam_role_policy_appsync_todos_api_name}"
  role = "${aws_iam_role.appsync_todos_api_role.id}"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "dynamodb:*"
            ],
            "Effect": "Allow",
            "Resource": [
                "${aws_dynamodb_table.todos_table.arn}",
                "${aws_dynamodb_table.todos_table.arn}/*"
            ]
        }
    ]
}
EOF
}

resource "aws_appsync_graphql_api" "todos_api" {
  authentication_type = "AMAZON_COGNITO_USER_POOLS"
  name                = "${var.app_name}_${var.app_env}_${var.aws_appsync_graphql_api_todos_name}"
  schema              = "${data.local_file.graphql_todos_api_schema.content}"
  user_pool_config {
    aws_region     = "${var.aws_default_region}"
    default_action = "ALLOW"
    user_pool_id   = "${aws_cognito_user_pool.userpool.id}"
  }
}

resource "aws_appsync_datasource" "todos_datasource" {
    api_id           = "${aws_appsync_graphql_api.todos_api.id}"
    name             = "${var.aws_appsync_datasource_todos_name}"
    service_role_arn = "${aws_iam_role.appsync_todos_api_role.arn}"
    type             = "AMAZON_DYNAMODB"
    dynamodb_config {
        table_name = "${aws_dynamodb_table.todos_table.name}"
    }
}

resource "aws_appsync_resolver" "create_todo" {
  api_id            = "${aws_appsync_graphql_api.todos_api.id}"
  field             = "createTodo"
  type              = "Mutation"
  data_source       = "${aws_appsync_datasource.todos_datasource.name}"
  request_template  = "${data.local_file.graphql_resolver_create_todo.content}"
  response_template = "${data.local_file.graphql_resolver_response.content}"
}

resource "aws_appsync_resolver" "update_todo" {
  api_id            = "${aws_appsync_graphql_api.todos_api.id}"
  field             = "updateTodo"
  type              = "Mutation"
  data_source       = "${aws_appsync_datasource.todos_datasource.name}"
  request_template  = "${data.local_file.graphql_resolver_update_todo.content}"
  response_template = "${data.local_file.graphql_resolver_response.content}"
}

resource "aws_appsync_resolver" "delete_todo" {
  api_id            = "${aws_appsync_graphql_api.todos_api.id}"
  field             = "deleteTodo"
  type              = "Mutation"
  data_source       = "${aws_appsync_datasource.todos_datasource.name}"
  request_template  = "${data.local_file.graphql_resolver_delete_todo.content}"
  response_template = "${data.local_file.graphql_resolver_response.content}"
}

resource "aws_appsync_resolver" "get_todo" {
  api_id            = "${aws_appsync_graphql_api.todos_api.id}"
  field             = "getTodo"
  type              = "Query"
  data_source       = "${aws_appsync_datasource.todos_datasource.name}"
  request_template  = "${data.local_file.graphql_resolver_get_todo.content}"
  response_template = "${data.local_file.graphql_resolver_response.content}"
}

resource "aws_appsync_resolver" "list_todos" {
  api_id            = "${aws_appsync_graphql_api.todos_api.id}"
  field             = "listTodos"
  type              = "Query"
  data_source       = "${aws_appsync_datasource.todos_datasource.name}"
  request_template  = "${data.local_file.graphql_resolver_list_todos.content}"
  response_template = "${data.local_file.graphql_resolver_response.content}"
}
```

aws-exports.js にはGraphQLのエンドポイントなどを設定するようにしておきます。
以上で `terraform apply` するとTodoのAdd/Done/Delete操作ができるようになりました。


## まとめ

サンプル実装の環境をTerraformで作成することができました。あとは Hosting の設定などがありますが、ここまで設定できれば、それも大してむずかしく無いと思うので割愛します。
Terraform で一つ一つのリソースを設定していくとAmplifyの理解が捗りました。理解を深めて、逆にamplify-cliに足りないものをコントリビュートできるまでいけると良いです。