HCLをコマンドで編集するhcleditというツールを書いた

# はじめに
以前、[Terraform本体/プロバイダ/モジュールのバージョンアップを自動化するtfupdateというツールを書いた](https://qiita.com/minamijoyo/items/1350fb28ae82a3dbb354)のですが 、Terraformのバージョン制約だけじゃなくて、もうちょっと汎用的にHCLがコマンドで編集できたら便利では？と思って、hcleditというツールを書いてみました。

https://github.com/minamijoyo/hcledit

まだできることはそれほど多くはないのですが、設計方針というか特徴はこんなかんじです。

- 標準入出力でHCLを読み書きするので、簡単に他のコマンドとパイプして組み合わせることが可能です。
- HCLのまま直接トークンを書き換えるので、可能な限りソースコード内のコメントを維持します。これは既存のHCLファイルをスクリプトなどで一括処理するのに便利です。
- Terraformには依存せず、汎用的なHCLを対象にしているのでスキーマレスです。
- HCL2対応(というかHCL1はサポートするつもりはありません）

特にソースコードのコメントを維持する方針は、このhcleditの存在意義みたいなものです。というのも、単にHCLを編集したいということであれば、HCLを一旦JSONなど他のデータ構造に変換してしまえば、jqなど既存のツールが活用できるのですが、コメントが失われてしまいます。それだと大量にある既存ファイルをスクリプトなどで一括で書き換えたりするのには困るのです。HCLをHCLのままでピンポイントで書き換えたいのです。というわけで作ってみたよ。

※本稿執筆時点のhcleditはv0.1.0です。最新の状況についてはREADMEを参照して下さい。

# インストール

いくつかの方法でインストールできます。

macOSの場合は、簡単にHomebrew経由でインストールできるようにしてあります。

```
$ brew install minamijoyo/hcledit/hcledit
```

Linuxで動かしたい場合は、ここからビルド済みのバイナリをダウンロードするか

https://github.com/minamijoyo/hcledit/releases

それ以外のOSの場合は、ソースコードからビルドして下さい。（Go 1.14以上が必要）

```
$ git clone https://github.com/minamijoyo/hcledit
$ cd hcledit/
$ make install
$ hcledit version
```

# 使い方

READMEほぼそのままですが、簡単な使い方を説明しておきます。

とりあえず --help を付けるとヘルプが出ます。

```
$ hcledit --help
A command line editor for HCL

Usage:
  hcledit [command]

Available Commands:
  attribute   Edit attribute
  block       Edit block
  help        Help about any command
  version     Print version

Flags:
  -h, --help   help for hcledit

Use "hcledit [command] --help" for more information about a command.
```

attributeとblockのサブコマンドに分かれてるので、それぞれ説明していきます。

## attribute

```
$ hcledit attribute --help
Edit attribute

Usage:
  hcledit attribute [flags]
  hcledit attribute [command]

Available Commands:
  get         Get attribute
  rm          Remove attribute
  set         Set attribute

Flags:
  -h, --help   help for attribute

Use "hcledit attribute [command] --help" for more information about a command.
```

例えば以下のようなHCLファイルがあった場合、

```attr.hcl
resource "foo" "bar" {
  attr1 = "val1"
  nested {
    attr2 = "val2"
  }
}
```

たとえば `attr2` の部分の右辺の値を取得したい場合は、こんなかんじで `hcledit attribute get` にアドレス `resource.foo.bar.nested.attr2` を指定して値を取得できます。

```
$ cat tmp/attr.hcl | hcledit attribute get resource.foo.bar.nested.attr2
"val2"
```

もちろん、値を書き換えることも可能です。値を書き換えるには `hcledit attribute set` を使います。第1引数にアドレス、第2引数に変更後の値を指定します。

```
$ cat tmp/attr.hcl | hcledit attribute set resource.foo.bar.nested.attr2 '"val3"'
resource "foo" "bar" {
  attr1 = "val1"
  nested {
    attr2 = "val3"
  }
}
```

変更後の文字列をセットするときに、文字列を表すダブルクオート `"` も必要で、 `'"val3"'` という風にさらにシェルに解釈されないように、シングルクオート `'` でエスケープする必要があることに注意してください。これは単に `val3` と書いた場合、 HCLの仕様的には `val3` という変数の参照の意味になってしまうからです。

属性値を削除することもできます。 `hcledit attribute rm` で `attr1` の部分を削除してみましょう。

```
$ cat tmp/attr.hcl | hcledit attribute rm resource.foo.bar.attr1
resource "foo" "bar" {
  nested {
    attr2 = "val2"
  }
}
```

## block

```
$ hcledit block --help
Edit block

Usage:
  hcledit block [flags]
  hcledit block [command]

Available Commands:
  get         Get block
  list        List block
  mv          Move block (Rename block type and labels)
  rm          Remove block

Flags:
  -h, --help   help for block

Use "hcledit block [command] --help" for more information about a command.
```

ブロックも操作できます。例えば以下のようなファイルがあったとして、

```block.hcl
resource "foo" "bar" {
  attr1 = "val1"
}

resource "foo" "baz" {
  attr1 = "val2"
}
```

`hcledit block list` を使うとブロックの一覧が取得できます。これはスクリプトでブロックをループしながら処理したりするのに便利です。

```
$ cat tmp/block.hcl | hcledit block list
resource.foo.bar
resource.foo.baz
```

`hcledit block get` で指定したブロックだけ抜き出すことが可能です。

```
$ cat tmp/block.hcl | hcledit block get resource.foo.bar
resource "foo" "bar" {
  attr1 = "val1"
}
```

簡単そうですが、これをsedで正確にやるのはなかなか難しいです。ブロックがネストしていたり、コメントアウトされていたりする可能性などのいろいろなバリエーションを考慮すると、HCLをパースして構文を正しく理解してないと難しいんじゃなかろうか。

`hcledit block mv` でブロックをリネームすることも可能です。 `resource.foo.bar` を `resource.foo.qux` にリネームしてみましょう。

```
$ cat tmp/block.hcl | hcledit block mv resource.foo.bar resource.foo.qux
resource "foo" "qux" {
  attr1 = "val1"
}

resource "foo" "baz" {
  attr1 = "val2"
}
```

Terraformのリファクタリングでリソース名をリネームするのに便利そうですね。

`hcledit block rm` でブロックを削除することも可能です。

```
$ cat tmp/block.hcl | hcledit block rm resource.foo.baz
resource "foo" "bar" {
  attr1 = "val1"
}
```

# 補足
## アドレス記法

ところで、さきほどの `attribute get` の例で、ラベルもネストしたブロックもドット `.` 区切りになっていることに気づいたでしょうか？

```attr.hcl
resource "foo" "bar" {
  attr1 = "val1"
  nested {
    attr2 = "val2"
  }
}
```

```
$ cat tmp/attr.hcl | hcledit attribute get resource.foo.bar.nested.attr2
"val2"
```

これは意図的で、HCL仕様にはそもそもアドレスという概念はありません。Terraformのリソース参照を意識してこのようなアドレス形式になっています。ラベルとネストしたブロックを区別した区切り文字の方が実装はしやすいんですが、あまり新しい記法を発明したくなかったので、できるだけ直感的で驚きが少ないようにこのようにしました。
たとえば `resource.foo` を読み込んだとき `foo` はラベルなのかネストしたブロックなのかどうやって探索範囲を判断しているのでしょうか？
答えは `foo` というラベルがあればそちらを優先し、なければ `foo` というネストしたブロックを探すという実装になっています。

普段Terraformを使ってる人には、TerraformとHCLの境界は曖昧かもしれませんが、HCL部分の仕様はかなり汎用的で、アプリケーション（Terraform本体や依存するプロバイダ）のスキーマ（＝型定義）がないと、ブロックタイプがラベルをいくつ受け取るかは分かりません。厳密にやろうとすると型定義が必要なのですが、仮にTerraform本体はいいとしてもさすがにプロバイダの型定義を内包することはできません。AWSプロバイダみたいなメジャーなのは頻繁にバージョンが上がりますし、原理的にはカスタムの野良プロバイダも可能です。かといって実行時に読み込もうとすると依存の管理が煩雑です。厳密に実装して複雑化するよりも、9割のユースケースをシンプルに上手くやれたらそれでええやんという割り切った思想です。というわけで、多少の厳密性は犠牲にしてスキーマレスで可能な範囲で、現実的な利便性を優先する方針としています。

この例でいうと、 特定のブロックタイプ（`resource`）を指定したとき、型定義がないと `resource` がいくつのラベルを受け取るかは不明だとしても普通は増減することはありません。もし仮にラベル名とネストブロック名が衝突した場合、  `resource` がラベル `foo` と ネストブロック `foo` を同時に持っていると仮定すると、`resource.foo.bar.foo.nested.attr2` のように `foo` を2回書くはずです。 `hcledit attribute get resource.foo.bar.nested.attr2` と書いた人は、 `foo` がラベルなのかネストしたブロックのどちらかであることを知っています。どちらかわかりませんが、両方ではなくどちらかにマッチすることを期待します。
では、なぜラベルを優先的するのかというと、ネストブロックの名前は型定義の一部なのでHCLアプリケーションの開発者が指定するのもであるのに対し、ラベルはHCLアプリケーションの利用者が指定するものであるので、もしぶつかった場合に利用者側に回避の余地を残したかったからです。Terraformのresource例だと `foo` の部分はリソースタイプが来るので任意の値は設定できませんが、これがネストブロックにそのまま埋め込まれることはありえませんし、`bar` というラベルがネストブロックにぶつかる場合は、 `bar` というラベルは利用者が任意の文字列に変更可能です。というわけで、この曖昧さが現実的にそれほど大きな問題になることはあまりないだろうという割り切りです。

ちなみにREADMEに書き忘れてたのを今思い出したんですが（おい）、ブロックのアドレスは `resource.foo.*` で部分一致させることも可能です。

```block2.hcl
resource "foo" "bar" {
  attr1 = "val1"
}

resource "foo" "baz" {
  attr1 = "val2"
}

resource "qux" "baz" {
  attr1 = "val3"
}
```

```
$ cat tmp/block2.hcl | hcledit block get 'resource.foo.*'
resource "foo" "bar" {
  attr1 = "val1"
}

resource "foo" "baz" {
  attr1 = "val2"
}
```

これは特定のリソースタイプなどを一括で処理したいみたいなときに便利です。

## コメント

コメントを維持する挙動についても若干補足しておきます。例えばこんなコメントを含むファイルがあったとして、

```main.tf
resource "foo_bar" "bar" {
  attr1 = "val1"
}

# ブロックの手前に空行があると独立したコメントとみなされる

# このコメントはブロックに付随する
# このbazは一見不要なように見えますが、実は非常に重要です。
# 絶対に削除しないでください！！絶対にだよ？
resource "foo_bar" "baz" {
  # なんとなくval2を設定している深い意味はない
  attr1 = "val2"
}
```

`block get resource.foo_bar.baz` するとコメントがくっついてきます。

```
$ cat tmp/main.tf | hcledit block get resource.foo_bar.baz
# このコメントはブロックに付随する
# このbazは一見不要なように見えますが、実は非常に重要です。
# 絶対に削除しないでください！！絶対にだよ？
resource "foo_bar" "baz" {
  # なんとなくval2を設定している深い意味はない
  attr1 = "val2"
}
```

たとえばリファクタリングでtfstateを分割するのに、特定のリソースを別のファイルに切り出したりするときに、コメントを失わずにすみます。うれしい。すごく大事。

ただコメント内に記載したとおり、ブロックの手前に空行があると独立したコメントとみなされるので注意してください。

# 応用

hcleditそのものは単機能なツールなので、これ単体で何かをするというよりも、スクリプトを書いたりして他のコマンドと組み合わせて使うことを想定しています。

というわけで、もうちょっと複雑な例として、ここでは[tflint](https://github.com/terraform-linters/tflint)と組み合わせて未使用のlocal/variable/data定義を検出して一括削除するスクリプトを書いてみましょう。tflintとはTerraform用のLinterです。tflintにはデフォルトで有効ではないのであんまり知られてない気がしますが、v0.16.0から未使用のlocal/variable/data定義を検出するterraform_unused_declarationsルールがあります。

たとえば以下のようなモジュールがあったとして、

```tflint-sample/main.tf
provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_security_group" "foo" {
  name = local.foo_name
}

resource "aws_security_group" "bar" {
  name        = local.bar_name
  description = "account_id = ${data.aws_caller_identity.current.account_id}"
}

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

```tflint-sample/local.tf
locals {
  foo_name = var.foo_name
  bar_name = var.bar_name
}
```

```tflint-sample/variable.tf
variable "foo_name" {
  default = "foo"
}

variable "bar_name" {
  default = "bar"
}
```

当初fooとbarのresourceがあったけど、いろいろあって、結果的にbarの方はいらなくなったとしましょう。本来は関連するlocal/variable/dataも削除しないといけないのですが、うっかり忘れてresourceだけ消しちゃいましたみたいな。

```diff
$ git diff
diff --git a/tflint-sample/main.tf b/tflint-sample/main.tf
index b673dde..22d9fb2 100644
--- a/tflint-sample/main.tf
+++ b/tflint-sample/main.tf
@@ -6,9 +6,4 @@ resource "aws_security_group" "foo" {
   name = local.foo_name
 }

-resource "aws_security_group" "bar" {
-  name        = local.bar_name
-  description = "account_id = ${data.aws_caller_identity.current.account_id}"
-}
-
```

この例は説明用に簡略化しているのでわざとらしいかんじですが、Terraformのモジュールをごにょごにょして試行錯誤しているうちに、variable定義したけど結局使ってないやん、みたいなのあるあるですよね。あとで残ったゴミだけ見つけてなんだっけこれみたいな。

これをtflintで検出してみます。tflintがインストールされてなければ各自インストールしてください。

```
$ brew install tflint
$ tflint -v
TFLint version 0.20.1
```

`terraform_unused_declarations` を有効化すると検出できます。

```
$ tflint --enable-rule=terraform_unused_declarations tflint-sample
2 issue(s) found:

Warning: local.bar_name is declared but not used (terraform_unused_declarations)

  on tflint-sample/local.tf line 3:
   3:   bar_name = var.bar_name

Reference: https://github.com/terraform-linters/tflint/blob/v0.20.1/docs/rules/terraform_unused_declarations.md

Warning: data "aws_caller_identity" "current" is declared but not used (terraform_unused_declarations)

  on tflint-sample/main.tf line 9:
   9: data "aws_caller_identity" "current" {}

Reference: https://github.com/terraform-linters/tflint/blob/v0.20.1/docs/rules/terraform_unused_declarations.md
```

`--format=json` にして `jq` でごにょごにょして切り出すと、未使用のlocalとdataの名前が取れそうです。

```
$ tflint --format=json --enable-rule=terraform_unused_declarations tflint-sample |
    jq -r '.issues[] | select(.rule.name == "terraform_unused_declarations") | .message'
local.bar_name is declared but not used
data "aws_caller_identity" "current" is declared but not used
```

若干フォーマットが揺れてますが、これをsedなどでパースしてやれば変数名が組み立てられそうです。
一点注意点として、この時点ではlocalとdataしか検出されていませんが、これは `var.bar_name` はまだ `local.bar_name` から参照されているからです。 `local.bar_name` を削除すると、追加で `var.bar_name` も未使用であることが検出されます。

というわけで 何を削除すればよいか対象がわかったので、これをhcleditを使って削除してみましょう。
`tfrm_unused.sh` というスクリプトを書いてみました。補足はインラインのコメントに書いたのでそれを参照。稼働確認は以下の環境で行いました。

- macOS: Catalina (10.15)
- hcledit: 0.1.0
- tflint: 0.20.1
- jq: 1.5
- gsed: 4.8

```bin/tfrm_unused.sh
#!/bin/bash
set -e

#
# tfrm_unused.sh
#
# 未使用のlocal/variable/data定義を検出し削除する作業スクリプトです。
# 大雑把な仕組みとしてはtflintで未使用定義を検出し、hcleditで該当の定義を削除しています。
#
# インストール
# tflint, hcledit, jq, gsedコマンドに依存しています。なければインストールして下さい。
# ※tflintはv0.16.0以上が必要です。
# $ brew install tflint
# $ brew install minamijoyo/hcledit/hcledit
# $ brew install jq
# $ brew install gnu-sed
#
# 使い方
# 引数で処理対象のディレクトリを指定します。
# $ bin/tfrm_unused.sh services/foo/prod

usage()
{
  echo "tfrm_unused.sh <TARGET_DIR>"
}

# tflintで未使用定義の検出
# tflintの出力結果をjqで加工してterraform_unused_declarationsに一致したものだけ抜き出す
detect_unused_declarations()
{
  local target_dir=$1
  tflint --format=json --enable-rule=terraform_unused_declarations "$target_dir" | jq -r '.issues[] | select(.rule.name == "terraform_unused_declarations") | .message'
}

# ディレクトリ内のファイルをまとめて更新するヘルパー関数
update_dir()
{
  local target_dir=$1
  local command=$2
  files=$(git ls-files "$target_dir/*.tf")
  for file in $files
  do
    update_file "$file" "$command"
  done
}

# ファイルを更新するヘルパー関数
update_file()
{
  local file=$1
  local command=$2
  local contents
  contents=$(cat "$file")
  echo "$contents" | $command > "$file"
}

# 未使用データソースを削除
rm_unused_data()
{
  local target_dir=$1
  unused_declarations=$(detect_unused_declarations "$target_dir")

  # 以下のメッセージを正規表現でキャプチャして
  # data "foo" "bar" is declared but not used
  # data.foo.barに加工
  unused_data=$(echo "$unused_declarations" | gsed -rn 's/^data \"(\w+)\" \"(\w+)\" is declared but not used$/data.\1.\2/p')

  for unused in $unused_data
  do
    echo "Remove: $unused"
    command="hcledit block rm $unused"
    update_dir "$target_dir" "$command"
    REMOVED=$((++REMOVED))
  done
}

# 未使用ローカル変数を削除
rm_unused_locals()
{
  local target_dir=$1
  unused_declarations=$(detect_unused_declarations "$target_dir")

  # 以下のメッセージを正規表現でキャプチャして
  # local.foo is declared but not used
  # locals.fooに加工
  unused_locals=$(echo "$unused_declarations" | gsed -rn 's/^local\.(\w+) is declared but not used$/locals.\1/p')

  for unused in $unused_locals
  do
    echo "Remove: $unused"
    command="hcledit attribute rm $unused"
    update_dir "$target_dir" "$command"
    REMOVED=$((++REMOVED))
  done
}

# 未使用入力変数を削除
rm_unused_variable()
{
  local target_dir=$1
  unused_declarations=$(detect_unused_declarations "$target_dir")

  # 以下のメッセージを正規表現でキャプチャして
  # variable "foo" is declared but not used
  # variable.fooに加工
  unused_variable=$(echo "$unused_declarations" | gsed -rn 's/^variable \"(\w+)\" is declared but not used$/variable.\1/p')

  for unused in $unused_variable
  do
    echo "Remove: $unused"
    command="hcledit block rm $unused"
    update_dir "$target_dir" "$command"
    REMOVED=$((++REMOVED))
  done
}

# 未使用定義を削除
rm_unused()
{
  local target_dir=$1
  # 未使用定義間の参照の依存がある可能性があるので、
  # data => locals => variable の順に削除
  rm_unused_data "$target_dir"
  rm_unused_locals "$target_dir"
  rm_unused_variable "$target_dir"
}

# main

# 必須引数の数チェック
if [[ $# -ne 1 ]]; then
    usage
    exit 1
fi

# 処理対象のディレクトリ
TARGET_DIR=$1
# トレイリングスラッシュの削除して正規化
TARGET_DIR=${TARGET_DIR%/}
echo "TARGET_DIR: $TARGET_DIR"

# 未使用定義を削除した結果、新しい未使用定義が見つかることがあるので
# REMOVED(削除した数)が0に収束するまで実行する。
while true; do
  # 削除した数をbash関数の戻り値で返すと標準出力がデバッグに使えなくて不便なので
  # カウンタはグローバル変数にしてある。ループのたびにリセットされる。
  REMOVED=0
  rm_unused "$TARGET_DIR"
  echo "REMOVED: $REMOVED"
  [[ $REMOVED -ne 0 ]] || break
done
```

実行してみます。

```
$ bin/tfrm_unused.sh tflint-sample
TARGET_DIR: tflint-sample
Remove: data.aws_caller_identity.current
Remove: locals.bar_name
Remove: variable.bar_name
REMOVED: 3
REMOVED: 0
```

実行結果に削除した変数名も出ていますが、diffを見ると、

```diff
[hcledit@tflint-sample|✚3…]$ git diff
diff --git a/tflint-sample/local.tf b/tflint-sample/local.tf
index 7ce9d7c..9b222a3 100644
--- a/tflint-sample/local.tf
+++ b/tflint-sample/local.tf
@@ -1,4 +1,3 @@
 locals {
   foo_name = var.foo_name
-  bar_name = var.bar_name
 }
diff --git a/tflint-sample/main.tf b/tflint-sample/main.tf
index 22d9fb2..8bcace7 100644
--- a/tflint-sample/main.tf
+++ b/tflint-sample/main.tf
@@ -5,5 +5,3 @@ provider "aws" {
 resource "aws_security_group" "foo" {
   name = local.foo_name
 }
-
-data "aws_caller_identity" "current" {}
diff --git a/tflint-sample/variable.tf b/tflint-sample/variable.tf
index 0946923..912c090 100644
--- a/tflint-sample/variable.tf
+++ b/tflint-sample/variable.tf
@@ -2,6 +2,3 @@ variable "foo_name" {
   default = "foo"
 }

-variable "bar_name" {
-  default = "bar"
-}
```

たしかに未使用のlocal/variable/dataの定義が削除されていることが確認できます。よさそう感。

一点上記スクリプトの現状の制限として、削除した結果localsブロックが空になった場合、空のローカルブロックを削除していません。特にtflintやterraform validateなどでもエラーにならないですが、空のlocalsブロックはゴミです。がんばれば検出できる気もしますが、やっつけで書いたのでそこまで作り込んでません。読者の宿題としておきましょうw

# まとめ

HCLをコマンドで編集するhcleditというツールを書いてみました。
まだまだできることは少ないですが、アイデア次第で夢がひろがりんぐ。
Terraform使っててHCL書くの飽きたーという人は、HCLをコードで書き換えたりして遊んでみてね。

# 余談
`hcledit block mv` のところで、Terraformのリファクタリングでリソース名をリネームするのに便利そうですね〜とか簡単そうに言いいましたが、当初これをやりたかったけどHCLのパーサに必要なAPIが足りなくて絶望しました。どうしても欲しかったのでhclのライブラリを改造してPullRequestを送ったのですが、なんやかんやあってマージしてもらうまで半年ぐらいかかりました↓
https://github.com/hashicorp/hcl/pull/340

そもそもHCLは設定ファイルのための言語なので、ライブラリも読み込みを主目的としてて、書き換え機能はおまけみたいな扱いなので、いろいろ全然APIが足りないです。俺達の戦いはまだ始まったばかりだ。

ちなみにアドレスがブロックの配列にまだ対応してないのは単に実装が間になってないというか、自分がまだ困ってないだけなのですが、属性がmapの場合に、mapの中のキーなどをアドレスに指定する方法が現状ないのは、依存しているhclライブラリ側の問題で、ちょっと今のところどうしたものかと悩んでます。ポジティブに言えば改善の余地しかない。

もし気に入ったらスター☆してくれると、今後の開発の励みになります |ω・`）ﾁﾗｯ
https://github.com/minamijoyo/hcledit