hcl v2 パッケージを使って HCL の var.* をリテラルに置き換えてみた

# はじめに
terraform でリソース管理している中で、「特定の HCL ファイル内の一部の variable だけ具体的なリテラルで置き換えたい…」と思うことがありました。
そこで HCL ファイル中の変数をリテラルで置き換えるツールを作ってみました。

https://github.com/heptaliane/tffreeze

ツールを作成するに当たっては、 hashicorp 公式のライブラリである、 `github.com/hashicorp/hcl/v2` を使ったのですが、

* HCL の構成要素の具体的な例や関係性がぱっとわからなかった
* HCL の変数の書き換えに関する記事が見つけられなかった

というつらみがあったため、備忘録も兼ねて記事にすることにしました。

本記事では、HCL の構成要素について、自分なりの解釈を具体例を交えた解説を行い、それを踏まえて HCL の変数をリテラルに書き換えた方法について説明します。

# 使用したライブラリ
ツールの作成に当たり、使用したライブラリは以下のとおりです。
* `github.com/hashicorp/hcl/v2`: v2.18.0
  * `github.com/hashicorp/hcl/v2/hclwrite`
  * `github.com/hashicorp/hcl/v2/hclsyntax`
* `github.com/zclconf/go-cty/cty`: v1.14.1

hclwrite は HCL の組み立てを行うライブラリで、hclsyntax は HCL の構成要素をプログラムで扱いやすい形で提供するライブラリです。
HCL の書き換えだけなら hclwrite だけで完結できるのですが、今回は既存の変数を加工したかったため、hclsyntax を使用しました。

cty は hcl のリテラル値を扱う際に使用されます。

# HCL の構成要素
まずは、ライブラリが HCL をどのような構成要素に分解しているのかについて説明します。

hclwrite, hclsyntax では、HCL の構成要素を同じ名前で定義しています。
ここでは、それぞれの構成要素について、以下の HCL を例に説明します。

``` hcl
resource "google_service_account" "default" {
  account_id   = "my-custom-sa"
  display_name = "Custom SA for VM Instance"
}

resource "google_compute_instance" "default" {
  name         = "my-instance"
  machine_type = "n2-standard-${var.cpus}"
  zone         = "us-central1-a"

  tags = ["foo", "bar"]

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
      labels = {
        my_label = "value"
      }
    }
  }

  metadata_startup_script = file(var.startup_script_path)

  service_account {
    # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles.
    email  = google_service_account.default.email
    scopes = ["cloud-platform"]
  }
}
```

## File
その名の通り、1つの HCL ファイルです。 
HCL の読み書きをする際に使用するため、HCL の操作を行う場合は、このインスタンスを起点として操作を行うことになります。

例えば 既存の HCL ファイルを読み込むには、次のようにして File インスタンスを生成します。
#### hclwrite
``` go
import (
    "os"
	"github.com/hashicorp/hcl/v2/hclwrite"
)

path := "hoge.hcl"
content, err := os.ReadFile(path)
writeFile := hclwrite.ParseConfig(content, path, hcl.Pos{})  // hclwrite.File
```
#### hclsyntax
``` go
import (
    "os"
	"github.com/hashicorp/hcl/v2/hclsyntax"
)

path := "hoge.hcl"
content, err := os.ReadFile(path)
syntaxFile := hclsyntax.ParseConfig(content, path, hcl.Pos{})  // hcl.File
```

同じように読み込むことができますが、hclsyntaxの場合は、`hclsyntax.File` ではなく、`hcl.File`として読み込むことになるため、`hclsyntax.Body`として扱うためにはキャストが必要な点には注意が必要です。

### 子要素
* Body
``` go
writeFileBody := writeFile.Body()  // hclwrite.Body
syntaxFileBody := syntaxFile.Body.(*syntax.Body)  // Cast: hcl.Body -> hclsyntax.Body
```

## Body
File や後述する Block 中の構成要素の塊です。
親要素が File であれば、Body は HCL ファイル全体の記述を指すことになりますし、親要素が上記の例における `boot_disk`の Block であれば Body は以下のとおりになります。

``` hcl
initialize_params {
  image = "debian-cloud/debian-11"
  labels = {
    my_label = "value"
  }
}
```
### 子要素
* Blocks (= []Block)
``` go
writeBlocks := writeBody.Blocks()  // []hclwrite.Block
syntaxBlocks := syntaxBody.Blocks  // []hclsyntax.Block
```
* Attributes (= []Attribute)
``` go
writeAttributes := writeBody.Attributes()  // []hclwrite.Attribute
syntaxAttributes := syntaxBody.Attributes  // []hclsyntax.Attribute
```

## Block
[公式の syntax で説明されている Block](https://developer.hashicorp.com/terraform/language/syntax/configuration#blocks) に相当します。
上記の例では、
``` hcl
resource "google_service_account" "default" {
  account_id   = "my-custom-sa"
  display_name = "Custom SA for VM Instance"
}
```
や
``` hcl
service_account {
  # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles.
  email  = google_service_account.default.email
  scopes = ["cloud-platform"]
}
```
が Block です。
### 子要素
* Body
``` go
for _, writeBlock := range writeBlocks {
    writeBlockBody := writeBlock.Body()  // hclwrite.Body
}
for _, syntaxBlock := range syntaxBlocks {
    syntaxBlockBody := syntaxBlock.Body  // hclsyntax.Body
}
```

## Attribute
リソースの1つのパラメータを指定する記述が Attribute です。
上記のhcl中の例としては、
``` hcl
name = "my-instance"
```
``` hcl
tags = ["foo", "bar"]
```
``` hcl
email = google_service_account.default.email
```
などが Attribute に当たります。
### 子要素
* Expression
``` go
for _, writeAttribute := range writeAttributes {
    writeExpression := writeAttribute.Expr()  // hclwrite.Expression
}
for _, syntaxAttribute := range syntaxAttributes {
    syntaxExpression := syntaxAttribute.Expr  // hclsyntax.Expression
}
```

## Expression
Expression は Attribute の右辺値の構成要素です。
単純な例としては、
``` hcl
name = "my-instance"
```
における、`"my-instance"`や、
``` hcl
email  = google_service_account.default.email
```
における、`google_service_account.default.email`が該当します。

より複雑なケースとして、Expression が Expression の構成要素となるものもあります。

例えば、
``` hcl
machine_type = "n2-standard-${var.cpus}"
```
において、`"n2-standard-${var.cpus}"`が Attribute 直下の Expression であるのに加え、 `"n2-standard-"` と `var.cpus` はそれぞれ `"n2-standard-${var.cpus}"` を構成する Expression に当たります。

別な例としては、
``` hcl
metadata_startup_script = file(var.startup_script_path)
```
において、Attribute 直下の Expression が `file(var.startup_script_path)`であり、子要素に Expression `var.startup_script_path` を持ちます。 

### 子要素 (hclsyntax only)
* Traversal
``` go
// hclwrite.Expression can't access Traversal

syntaxTraversals := syntaxExpression.Variables  // []hcl.Traversal
```
* cty.Value
``` go
// hclwrite.Expression can't access cty.Value

syntaxValues := syntaxExpression.Values(nil)  // []cty.Value
```

* Expression

hclsyntax には様々な Expression の実装が定義されており、[LiteralValueExpression](https://pkg.go.dev/github.com/hashicorp/hcl2/hcl/hclsyntax#LiteralValueExpr)のように Expression を子に持たないものもあれば、[TemplateExpr](https://pkg.go.dev/github.com/hashicorp/hcl2/hcl/hclsyntax#TemplateExpr) のように子に持つものもおり、そのフィールド名も実装によってまちまちである。

## Traversal
[リファレンス](https://pkg.go.dev/github.com/hashicorp/hcl/v2#Traversal) には
> A Traversal is a description of traversing through a value through a series of operations such as attribute lookup, index lookup, etc.

とあるが、扱う上ではとりあえず変数だと思っておけばよさそう。

例としては、`google_service_account.default.email`や、`var.cpus`など。

## cty.Value
`"my-instance"`などの何らかのリテラル。

# HCL の書き換え
続いて本題となる HCL ファイル中の変数をリテラルで置換する方法について説明します。

変数の置換は、単純には次の手順で実現することになります。

1. 置き換えたい変数を定義した `map[string]cty.Value` を使って、 `hcl.EvalContext`インスタンスを作成する。
1. 作成した `hcl.EvalContext`を使って、`hclsyntax.Expression.Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics)` で、変数の値を展開したあとのリテラルを生成する
1. `hclwrite.Body.SetAttributeValue(name string, val cty.Value) *Attribute` で、生成したリテラルで Attribute を置換する

具体的な実装例を以下に示します。
``` go
import (
    "os"
	"github.com/hashicorp/hcl/v2/hcl"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/hashicorp/hcl/v2/hclwrite"
    "github.com/zclconf/go-cty/cty"
)

...  // Read HCL file to convert as hcl.File and hclwrite.File

// Read .tfvars file as HCL file
path := "fuga.tfvars"
content, err := os.ReadFile(path)
tfvarsFile := hclsyntax.ParseConfig(content, path, hcl.Pos{})

// Parse variables
tfvarsAttrs, _ := tfvarsFile.Body.JustAttributes()
tfvars := make(map[string]cty.Value)
for key, attr := range tfvarsAttrs {
    value, _ := attr.Expr.Value(nil)
    tfvars[name] = key
}

// Use tfvars as var.* variables
ctx := hcl.EvalContext {
    Variables: map[string]cty.Value {
        "var": cty.ObjectVal(tfvars)
    }
}

...

// var syntaxAttrs []hclsyntax.Attribute
// var writeBody hclwrite.Body

for name := range syntaxAttrs {
    value, _ := syntaxAttrs[name].Expr.Value(&ctx)
    writeBody.setAttributeValue(name, value)
}

...

// var writeFile hclwrite.File

// Output file
os.WriteFile("converted.hcl", writeFile.Bytes(), 0644)
```

# 終わりに
hcl/v2 ライブラリにおける HCL ファイルの構成要素と、hclライブラリを使った変数の書き換え方法について説明しました。
みなさんもぜひ hcl/v2 ライブラリを使って terraform の定義ファイルをいじって遊んでみてください ^^

# 参考資料
* ライブラリのドキュメント
  * https://pkg.go.dev/github.com/hashicorp/hcl/v2
  * https://pkg.go.dev/github.com/hashicorp/hcl/v2/hclwrite
  * https://pkg.go.dev/github.com/hashicorp/hcl/v2/hclsyntax