hclwriteを使ってtfファイルを編集する

こんにちは、SREグループの水戸 (@y_310)です。Terraformを継続的に使用していると、時々一括して大量のファイルの記法を修正したくなる時があります。 単純な置換であればsedで十分ですが、少し複雑な変換になるとsedでは難しいこともあります。そんな時はHCLの公式リポジトリに含まれているhclwriteパッケージを使うとHCLを構文解析した上で参照編集できるので便利なのですが、hclwriteはあくまでGo言語のライブラリなので使用にはGoでの実装が必要になります。この記事では気軽にtfファイルを編集できるようにhclwriteの簡単な使い方を紹介したいと思います。

tfファイルを開いて、formatして保存する

まずはこの後の実装の下準備として、基本構造となるファイルを開いて保存する、という処理を実装します。ついでにterraform fmt相当のformat処理もします。

こちらのtfファイルを使用します。(formatの効果を確認するためにあえて余計なスペースを入れています)

variable "bucket_name" {
  default         = "example-bucket"
}

resource "aws_s3_bucket" "example" {
  bucket   = var.bucket_name

  tags = {
    Name = "example"
  }
}

こちらが実装です。

package main

import (
    "io/ioutil"
    "log"
    "os"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/hclwrite"
)

func main() {
    path := "./main.tf"

    // ファイルを開く
    content, err := ioutil.ReadFile(path)
    if err != nil {
        log.Fatal(err)
    }

    file, diags := hclwrite.ParseConfig(content, path, hcl.InitialPos)
    if diags.HasErrors() {
        log.Fatal(diags.Error())
    }

    // 以後の例ではこの部分に関数呼び出し処理を追加します

    // format
    fileBytes := file.Bytes()
    formatted := hclwrite.Format(fileBytes)

    // 保存
    fo, err := os.Create(path)
    defer fo.Close()

    if err != nil {
        log.Fatal(err)
    }

    _, err = fo.Write(formatted)
    if err != nil {
        log.Fatal(err)
    }
}

実行するとtfファイルがformatされます。 これでファイルを開いて編集した結果を保存することができるようになったので、ここから先は hclwrite.ParseConfig で得られた file オブジェクトを元にパースした結果を使用していきます。

resourceやmoduleなどのブロック一覧を出力する

tfファイルは複数のresourceやmodule、variableなどのブロックで構成されているのが一般的かと思います。 それらの情報をパースして参照できることを確認するコードです。

こちらのtfファイルを使用します。

data "aws_s3_bucket" "example" {
  bucket = "example-bucket"
}

variable "bucket_name" {
  default = "example-bucket"
}

resource "aws_s3_bucket" "example" {
  bucket = var.bucket_name

  tags = {
    Name = "example"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "example" {
  bucket = aws_s3_bucket.example.id

  rule {
    id = "rule-1"

    filter {
      prefix = "logs/"
    }

    status = "Enabled"
  }
}

output "example" {
  value = data.aws_s3_bucket.example.bucket
}

こちらが実装です。前の例にprintBodyという関数を追加します。

func printBody(body *hclwrite.Body, nestLevel int) {
    for _, block := range body.Blocks() {
        indent := strings.Repeat("  ", nestLevel)
        fmt.Printf("%stype: %s, labels: %s\n", indent, block.Type(), strings.Join(block.Labels(), ", "))
        printBody(block.Body(), nestLevel+1)
    }
    for name, attr := range body.Attributes() {
        indent := strings.Repeat("  ", nestLevel)
        fmt.Printf("%sattribute: %s %s\n", indent, name, string(attr.Expr().BuildTokens(nil).Bytes()))
    }
}

func main() {
    // 省略

    // 以後の例ではこの部分に関数呼び出し処理を追加します
    body := file.Body()
    printBody(body)

    // 省略
}

実行すると以下の出力が得られます。

type: data, labels: aws_s3_bucket, example
  attribute: bucket  "example-bucket"
type: variable, labels: bucket_name
  attribute: default  "example-bucket"
type: resource, labels: aws_s3_bucket, example
  attribute: bucket  var.bucket_name
  attribute: tags  {
    Name = "example"
  }
type: resource, labels: aws_s3_bucket_lifecycle_configuration, example
  type: rule, labels:
    type: filter, labels:
      attribute: prefix  "logs/"
    attribute: status  "Enabled"
    attribute: id  "rule-1"
  attribute: bucket  aws_s3_bucket.example.id
type: output, labels: example
  attribute: value  data.aws_s3_bucket.example.bucket

HCLの構成要素の解説

コード上で、Body、Block、Label、Attributeなどの用語が出てきましたが、それぞれtfファイル上の以下の部分を意味しています。

まずファイル全体がFileというオブジェクトで、その内側全体をBody、Bodyの中の moduleresourcevariable などがBlockと呼ばれています。 Blockの中にはまたBodyがあり、Bodyの中に更にまたBlockが存在するといった形でネストした構造になっています。 リソースによってはこの例のようにruleやfilterなどネストしたブロックを記述できるものがありますが、こういったものがBlockになります。なお、= でmapを代入しているものは一見Blockに似ていますが後述のAttributeになるため注意が必要です。(例えばAWS関連リソースに存在するtagsは tags = { ... } と記述するためAttributeです)

block

次にBlock内は以下のように構成されています。resourcemoduleの名前の部分はLabels、Body内の代入形式で記述される部分はAttributes、Attributesの右辺がExpressionになります。Expressionは "rule-1" のようなリテラルaws_s3_bucket.example.id のような変数等様々な式が含まれます。(公式のExpressionsのドキュメント)

attributes

hclwriteを使う際はこれらの要素を順番にたどっていって目的の要素を発見し編集していくため、この基本構造を理解しておくと実装しやすくなると思います。

ブロックの名前や属性を変更する

ではブロックの値の参照ができたので次は変更です。

こちらのtfファイルを使用します。

resource "example" "test" {
  name   = var.name

  tags = {
    Name = "example"
  }
}

こちらが実装です。

func modifyBody(rootBody *hclwrite.Body) {
    // 条件にマッチするBlockを取得
    block := rootBody.FirstMatchingBlock("resource", []string{"example", "test"})

    // resource "aws_s3_bucket"をmoduleに変更し、リソース名も変更
    block.SetType("module")
    block.SetLabels([]string{"example-module", "example"})

    body := block.Body()
    // ブロックの属性を変更
    body.SetAttributeValue("name", cty.StringVal("example-name"))
    body.SetAttributeValue("boolean_attr", cty.BoolVal(true))

    // Object型の値を追加
    body.SetAttributeValue("tags", cty.ObjectVal(map[string]cty.Value{
        "Name":        cty.StringVal("example-name"),
        "Environment": cty.StringVal("development"),
    }))

    // List型の値を追加
    body.SetAttributeValue("list", cty.ListVal([]cty.Value{
        cty.StringVal("item1"),
        cty.StringVal("item2"),
        cty.StringVal("item2"),
    }))

    // ドット区切りの変数を追加
    body.SetAttributeTraversal("traversal_attr", hcl.Traversal{
        hcl.TraverseRoot{
            Name: "var",
        },
        hcl.TraverseAttr{
            Name: "example",
        },
        hcl.TraverseAttr{
            Name: "name",
        },
    })
}

実行すると以下のようにファイルが変更されます。

module "example-module" "example" {
  name = "example-name"

  tags = {
    Environment = "development"
    Name        = "example-name"
  }
  boolean_attr   = true
  list           = ["item1", "item2", "item2"]
  traversal_attr = var.example.name
}

編集したいBlockを探索して、Block内の色々な型のAttributeを編集したり追加したりしてみた例です。単純な文字列リテラルなどだけでなく、mapやlistといったデータ構造や変数のようなドット区切りの構造も設定できます。 なお値を設定する際はctyというライブラリを使う必要があります。

Tokenを使って任意の要素を追加する

1つ前の実行結果を見るとList型の値が改行無しで列挙されています。要素が多い場合など改行区切りで出力したいケースもあるかと思いますが、これはformatするだけでは実現できません(terraform fmtでもそういった変換はしてくれません)。 こういった場合、残念ながら手軽に出力形式を変更する手段は提供されていないため、Tokenを自分で組み立てて実現する必要があります。 他にも三項演算子や算術演算などSetAttributeValueでは設定できない式がたくさんありますが、そういったものもTokenを組み立てれば手間はかかりますが実装可能です。 では、実際にTokenを使って要素を組み立ててみる例です。

こちらのtfファイルを使用します。

resource "example" "test" {
}

こちらが実装です。

func modifyBody(rootBody *hclwrite.Body) {
    block := rootBody.FirstMatchingBlock("resource", []string{"example", "test"})

    items := []string{"item1", "item2", "item3"}

    tokens := []*hclwrite.Token{}

    // [\n
    // "item1",\n
    // "item2",\n
    // "item3",\n
    // ]
    tokens = append(
        tokens,
        &hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte("[")},
        &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")},
    )

    for _, item := range items {
        tokens = append(
            tokens,
            &hclwrite.Token{Type: hclsyntax.TokenOQuote, Bytes: []byte("\"")},
            &hclwrite.Token{Type: hclsyntax.TokenQuotedLit, Bytes: []byte(item)},
            &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte("\"")},
            &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte(",")},
            &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")},
        )
    }
    tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("]")})

    block.Body().SetAttributeRaw("list_formatted", tokens)

    // foo = var.bar ? "bar": "baz"
    tokens = []*hclwrite.Token{}
    tokens = append(
        tokens,
        &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte("foo")},
        &hclwrite.Token{Type: hclsyntax.TokenEqual, Bytes: []byte("=")},
        &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte("var")},
        &hclwrite.Token{Type: hclsyntax.TokenDot, Bytes: []byte(".")},
        &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte("bar")},
        &hclwrite.Token{Type: hclsyntax.TokenQuestion, Bytes: []byte("?")},
        &hclwrite.Token{Type: hclsyntax.TokenOQuote, Bytes: []byte("\"")},
        &hclwrite.Token{Type: hclsyntax.TokenQuotedLit, Bytes: []byte("bar")},
        &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte("\"")},
        &hclwrite.Token{Type: hclsyntax.TokenColon, Bytes: []byte(":")},
        &hclwrite.Token{Type: hclsyntax.TokenOQuote, Bytes: []byte("\"")},
        &hclwrite.Token{Type: hclsyntax.TokenQuotedLit, Bytes: []byte("baz")},
        &hclwrite.Token{Type: hclsyntax.TokenCQuote, Bytes: []byte("\"")},
        &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")},
    )
}

これで以下の出力が得られます。

resource "example" "test" {
  foo = var.bar ? "bar" : "baz"
  list_formatted = [
    "item1",
    "item2",
    "item3",
  ]
}

コードを見ると分かるように、愚直に目的の結果が得られるようにTokenを積み重ねていく形になります。最初はどういうTokenを追加すれば目的の結果が得られるか分かりづらいですが、その場合一度手でtfファイルを書いて対象の要素のBuildTokensメソッドを呼ぶとどういうトークンが使われているか分かるので、それを参考に組み立てていくと良いと思います。標準のメソッドでできないような変更についてはTokenを組み立てれば大概のことは実現できるのではないかと思います。

まとめ

hclwriteは標準で用意されているメソッドがそれほど網羅的ではなく、基本的な操作でも意外と実装されていないものが多くあるようです。そういった場合でもTokenを直接操作すれば目的の結果が得られる可能性が高いため、HCLのシンタックスを考慮したうえで複雑な変換をしたい場合等には十分使えるライブラリだと思いました。