Terraform で（無理やり！）独自の関数を定義する

Infrastructure as Code に欠かせない存在である Terraform。宣言的な記法、安心の plan 出力、豊富な対応プロバイダなど嬉しいポイントはいっぱいです。

でも、独自の設定記述言語 [HCL](https://github.com/hashicorp/hcl)/[HIL](https://github.com/hashicorp/hil) や Terraform 自体の制約上「こういう処理が書きたいのに書けない！」「無理やり書くとカオスになる！」と嘆くこともしばしば（それでも、昔よりはマシですが……）。

今回はそんな悩みをちょっぴり和らげてくれる「独自の関数を定義する」 hack を紹介します。

*※ お断り: 本記事はあくまで「こうやったらできるよ」と方法を紹介するもので、それが good practice であるかどうかは大いに議論の余地があります*

# Terraform バージョン

下記バージョンの Terraform で実行を確認しました。

```bash
$ terraform -v
Terraform v0.11.7
```

とはいえ、それほど新しい機能は使っていないので、もう少し古いバージョンでも動くはずです（ただし [local value は v0.10.3 で導入された](https://github.com/hashicorp/terraform/blob/v0.10.3/CHANGELOG.md) ので、それ以前の方は若干の書き換えが必要です）。

# どんなやり方があるのか

現在の Terraform で関数を定義する方法としては、以下の3つの選択肢が思いつきます（他にもありそうだったら教えて下さい）。

1. External data source から外部スクリプトを呼ぶ
2. Module 内に interpolation function をまとめる
3. 独自 interpolation function をコンパイルする

それぞれ、下表のような特徴があります:

| | External data source から外部スクリプトを呼ぶ | Module 内に interpolation function をまとめる | 独自 interpolation function をコンパイルする |
|:-------|:-------|:-------|:-------|
| 依存関係 | :broken_heart: スクリプト実行環境も必要 | :white_check_mark: Terraform のみ | :white_check_mark: （独自の）Terraform のみ |
| できること | :white_check_mark: 何でも | :broken_heart: interpolation でできることだけ | :white_check_mark: 何でも |
| ブロックの記述 | :broken_heart: 要 | :broken_heart: 要 | :white_check_mark: 不要（インラインですぐ使える） |
| ソースビルド | :white_check_mark: 不要 | :white_check_mark: 不要 | :broken_heart: 要 |

3つ目の方法は Terraform 自体のコンパイルが必要で話が大きくなってしまうため、今回は深入りしません。興味がある方向けに、ソースコードの場所だけご案内しておきます:

:octocat: https://github.com/hashicorp/terraform/blob/v0.11.7/config/interpolate_funcs.go

もし自分が欲しい関数が汎用的なものであれば、実装して本家に pull request を送ってみるのも良いですね :muscle:

# やってみよう

今回実装したものは、Github にも上げてありますので、必要に応じてクローンしてご活用ください。

:octocat: https://github.com/tmshn/terraform-how-to/define-custom-functions

### 実装してみる処理内容

今回はサンプルとして、引数として URL を受け取り、それをスキーム・ホスト・ポート番号・パス・クエリ文字列・ハッシュにバラして返す関数を実装してみます。

下記のようなイメージです。

```python
>>> parse_url('https://blog.example.com/articles?category=bigdata')
{'scheme': 'https',
 'host':   'blog.example.com',
 'port':   '',
 'path':   '/articles',
 'query':  'category=bigdata',
 'hash':   ''}
```

### 準備

Terraform の設定ファイルに、先にパース対象の URL を変数で宣言しておきましょう。

```hcl:main.tf
variable "url" {
  type        = "string"
  description = "URL to parse."
  default     = "https://blog.example.com/articles?category=bigdata"
}
```

### 方法 1. External data source から外部スクリプトを呼ぶ

#### 使い方

実行対象のプログラムを実装し、それを `external` data source の `program` に指定して呼び出します。入力データは、 `query` に map で指定します。

これが無事評価できれば、プログラムの出力結果が `data.external.url_parts.result` で参照できるようになります。

```hcl:main.tf
data "external" "url_parts" {
  program = ["python", "parse_url.py"]

  # input to the program
  query = {
    url = "${var.url}"
  }
  
  # result = (...result of the program...)
}
```

#### 解説

External data source とは、ざっくり言うと「external プロバイダが提供する」「data source」です。それぞれを説明すると……

- External プロバイダ……Terraform と外部のプログラムの橋渡しをするためのプロバイダ
    - 今回紹介する data source のみが定義されている
    - :book: https://www.terraform.io/docs/providers/external/index.html
- Data source ……読み取り専用のリソースのこと
    - 別のところで作ったリソースの情報を現在の Terraform 内で使いたい場合などに用いる（たとえば AWS Server certificate を手動でアップロードし、その情報を使うなど）
    - :book: https://www.terraform.io/docs/configuration/data-sources.html

この external data source を使うと、任意のプログラムを実行してデータを取得することができます。

通常は `aws` コマンドや `gcloud` コマンドを実行して、Terraform に未実装のリソースの情報を取得するなどの用途で使われますが、純粋な関数として利用することも可能ですね。

Terraform と外部プログラム間では、下記のようなプロトコルでやりとりが行われます:

- `query`ブロックで文字列 [^1] のキーバリューを渡すことができ、それは外部プログラムの標準入力に JSON 形式で渡される
- 外部プログラムの戻り値は JSON 形式で標準出力に出力することができ、それは Terraform 側 data source の `result` attribute に設定される
- 成功時はゼロ、エラー時は非ゼロの実行ステータスを返すこと

[^1]: Terraform で「文字列型」と呼ばれるのは、数値型や bool 型を含むスカラー型のことです

（入力としてはコマンドライン引数も渡せますが、既存のコマンドを呼ぶとき以外は入力はすべて標準入力に集約した方が混乱が少なくなると思います）

#### 実装例

今回は、前述の処理をするためのスクリプトを Python で書いてみました。

```python:parse_url.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Small script to parse URL (intended to use from Terraform)."""

import json
import re
import sys


URL_REGEX = re.compile('^(?:(?P<scheme>\\w+):\\/\\/)?(?P<host>[^:\\/]+)(?::(?P<port>\\d+))?(?P<path>[^\\?#]*)(?:\\?(?P<query>[^#]*))?(?:#(?P<hash>.*))?$')


def parse_url(url):
    """Parse URL and returns its parts as dict."""
    try:
        return URL_REGEX.search(url).groupdict(default='')
    except AttributeError:  # If regex did not match
        raise ValueError('invalid url')


def main():
    """Read URL from stdin, parse it, and write result into stdout."""
    input_data = json.load(sys.stdin)

    url = input_data['url']
    result = parse_url(url)

    json.dump(result, sys.stdout)


if __name__ == '__main__':
    main()
```

### 方法 2. Module 内に interpolation function をまとめる

#### 使い方

呼び出したい module を定義してそのパスを `source` に指定し、他の入力データ（今回は URL）も引数として指定すれば OK です。

今回は（方法 1 に合わせて） `result` という単一の `output` を定義するので、`module.url_parts.result` で値が取得できます。

```hcl:main.tf
module "url_parts" {
  source = "./parse_url"
  url    = "${var.url}"
}
```

#### 解説

- Module ……リソース類をカプセル化するもの。繰り返し現れる共通処理をまとめるために使われる
    - AWS で [Launch Configration と Auto Scaling Group を組み合わせてオートスケーリンググループを作ったり](https://registry.terraform.io/modules/terraform-aws-modules/autoscaling/aws/2.3.0) GCP で [いろいろ組み合わせてロードバランサを作ったり](https://registry.terraform.io/modules/GoogleCloudPlatform/lb-http/google/1.0.5) など、複数のリソースを1つの論理的な単位にまとめるのが主な目的
    - :book: https://www.terraform.io/docs/configuration/modules.html
    - Terraform registory にコミュニティが作った様々な module が集まっている
          - :link: https://registry.terraform.io/

「関数」というものを「一連の処理をまとめたもの」だと捉えれば、それを module で実現するのは理にかなっていますね。ただし「1つもリソースを定義しない」という、本来の目的とは少しだけずれた形ではありますが。

さて、module はそれぞれの名前空間が完全に分離されており、module 同士のデータのやり取りが制限されています。仕様として下記のことを理解しておく必要があります（ここでは、module を呼び出す側を「親」、呼び出される側を「子」と表現しています）:

- 子 module で `variable` として定義したものだけが、親 module から値を指定できる
- 子 module で `output` として定義したものだけが、親 module から値を取得できる
- それ以外は、ある module から 別の module の情報を見ることは一切できない（親子 module 間であっても）

ちなみに、通常の設定が書かれているのは実は root module という最上位の module です。Terraform 外から `variable` が指定できたり `output`が取得できたりするのは、この仕組によるものなのです。

#### 実装例

では、これを使って「関数」を実現してみましょう。

前掲の Python スクリプトと同じ処理をすればいいだけですね。Terraform では [`replace` 関数](https://www.terraform.io/docs/configuration/interpolation.html#replace-string-search-replace-) で正規表現が使えるので、Python で用いたものと全く同じ正規表現をそのまま使って実装してみます。

```hcl:parse_url/variables.tf
variable "url" {
  type        = "string"
  description = "[Required] URL to parse."
}
```

```hcl:parse_url/main.tf
locals {
  url_regex = "/^(?:(?P<scheme>\\w+):\\/\\/)?(?P<host>[^:\\/]+)(?::(?P<port>\\d+))?(?P<path>[^\\?#]*)(?:\\?(?P<query>[^#]*))?(?:#(?P<hash>.*))?$/"
}

locals {
  scheme = "${replace(var.url, local.url_regex, "$scheme")}"
  host   = "${replace(var.url, local.url_regex, "$host")}"
  port   = "${replace(var.url, local.url_regex, "$port")}"
  path   = "${replace(var.url, local.url_regex, "$path")}"
  query  = "${replace(var.url, local.url_regex, "$query")}"
  hash   = "${replace(var.url, local.url_regex, "$hash")}"
}
```

```hcl:parse_url/outputs.tf
output "result" {
  value = {
    scheme = "${local.scheme}"
    host   = "${local.host}"
    port   = "${local.port}"
    path   = "${local.path}"
    query  = "${local.query}"
    hash   = "${local.hash}"
  }
}
```

Module を定義する時は `variable` は `variables.tf` に、`output` は `outputs.tf` にまとめることが多いので、今回もそれに則りました。ただ使う時はディレクトリ名を指定するだけなので、どのようにファイルを分割するかはお好みで調整して下さい。

### 実行してみる

いよいよこれらを実行してみます。

```bash
$ terraform init  # 初期化処理
$ terraform apply # 実行！
```

"Apply complete!" と表示されれば実行は完了です。結果を確認してみましょう。

```bash
$ echo 'data.external.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "blog.example.com"
  "path" = "/articles"
  "port" = ""
  "query" = "category=bigdata"
  "scheme" = "https"
}
$ echo 'module.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "blog.example.com"
  "path" = "/articles"
  "port" = ""
  "query" = "category=bigdata"
  "scheme" = "https"
}
```

いずれも思ったとおりの結果が出ていますね。

他の URL でもやってみます。

```bash
$ terraform apply -var url='tcp://redis.example.com:6379'
$ echo 'data.external.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "redis.example.com"
  "path" = ""
  "port" = "6379"
  "query" = ""
  "scheme" = "tcp"
}
$ echo 'module.url_parts.result' | terraform console
{
  "hash" = ""
  "host" = "redis.example.com"
  "path" = ""
  "port" = "6379"
  "query" = ""
  "scheme" = "tcp"
}
```

うまくいっていますね！

# おわりに

本記事では、Terraform で独自の関数を定義する方法として

- External data source から外部スクリプトを呼ぶ
- Module 内に interpolation function をまとめる

という2つを紹介しました。

:octocat: https://github.com/tmshn/terraform-how-to/define-custom-functions

記事内ではそれを `terraform console` コマンドで確認するにとどまりましたが、実際にはこれをリソースの argument に指定したりすることになるでしょう。

:coffee:    :cake:    :coffee:    :cake:

今回紹介したようなやり方を「イケてる practice」と言えるかは微妙なところです。どちらかというと、無理のある黒魔術がかったやり方ですよね。

正直、個人的には Terraform 自体のイケてなさに「これが現代の構成管理ツールのあるべき姿なのか」と辟易することすらあります。

でも決して悪いツールではないし、スピード感のある開発チームと活発なコミュニティに恵まれているのも事実です。そのコミュニティの一員として、Terraform がより良いツールになるよう、これからも積極的に貢献していきたいと思います :muscle:

それでは、皆さんも Happy terraforming! :wave: