hcl2パッケージでhclを書き換えてみた

こんにちは、完璧にblogを書くことをサボってました。 今回はgolangのhcl2パッケージを用いてaws security group id, subnet-idを直接記述されている部分をdata sourceへ書き直すツールをgolangで実装してみました。

背景

自分が所属している会社ではterraformを用いてaws infra resourceを管理しています。またマイクロサービスを展開しており、モノレポでterraform projectを管理しています。 そのリポジトリは歴史も古く、ところどころsecurity goup idやsubnet idが直接記述されているとこがありました。 これでは実態に依存している状態で良くないと考えています。 例えば、マルチアカウント戦略を取るときににはそのソースコードは元アカウントのリソースに依存している為、汎用性がないと言えるでしょう。 また自分はgolangを触ったことがなく、自己学習を兼ねてリファクタリングツールを作ってみようと思い書いてみました。(完全にはまだ実装できていない)

HCLについて

hclのデータ構造は以下のように分類ができます。 file,body, block, attribute,

file

hclファイルをparseするとfileというdata型に変換される。
fileの内部にはbodyというdataを保持しており、bodyの中にはblock, attributeを持つ。
イメージとしては下記のような感じになる。
マトリョーシカぽい感じだ...

File
└── body
    ├── attribute
    └── block
        └── body
            ├── attribute
            └── block

body

file, blockの中身に相当する。

reource "aws_vpc" "main" {
  // body
  vpc_id = ""
  tags {
     // body
  }
}

block

resouce {} など hoge {}で表す構造体。内部にbodyを持つ
実際のblockは下記のようになるかと思います。

resource "aws_vpc" "main" {} // block

locals  {} // block

variable "hoge" {} //block

reource "aws_vpc" "main" {
  // body
  tags {} // block
}

module  "hoge" {
  hoge = {} // これはblockではない
}

attribute

attributeは端的に言えば特定の名前が付けられた値である key = value形式のものをいう 右辺に当たる部分をexpressionと呼びます

resource "aws_vpc" "main" {
    vpc_id = "" // attribute
}

// tfvars
hoge = "hoge" //attribute

module "hoge" {
    hoge = {} // これもattribute
}

ここまでざっくりではありますがhclのデータ構造の説明でした。 hclはfileをrootに持ち、body -> block or attributeというふうにマトリョーシカのようなデータ構造になっている。

HCL2パッケージについて

golangではhclを扱うためにHCL2パッケージを提供しています。用途は主に読み込みと、書き込みであり。読み込みであれば, hclsyntax, gohclといったパッケージを使用することになり、書き込みを行う場合はhclwriteというパッケージを用いることになると思います。
今回はhclファイルのparse, 対象のresource idにマッチしたものを書き換え、書き換えたblock path(data.subnet_groups.hogeといったもの)にマッチしたdata source blockを生成するためhclwriteを用いました。

hclのファイル, 各種dataの読み込み

hclwriteでhclファイルをparseするには下記のように記述します。

file, err := os.ReadFile(path)
if err != nil {
    log.Fatal(err)
}

hclFile, diags := hclwrite.ParseConfig(file, path, hcl.Pos{})
if diags != nil {
    log.Fatal(diags)
}

上記でhclファイルをparseできたので、fileの中のblockを取得するには下記のようになります

for _, block := range hclFile.Body().Blocks() {}

さらにblockで定義されたattributeの一覧を取得するには下記のようになります。

for _, block := range hclFile.Body().Blocks() {
       attributes := block.Body().Attributes()
}

次にattibutesのexpressionを取得するには下記のようになります

for _, block := range hclFile.Body().Blocks() {
       attributes := block.Body().Attributes()
       for _, attribute := range attributes {
               attibute..Expr()
       }
}

愚直に各データにアクセスするには上記で対応できるかと思います。
特定のattributeにアクセスする、blockにアクセスするなどはhclwriteパッケージには用意されているのでdocを拝見していただければと思います。

subnet, sg idを書き換える

本題のsubnet-id, sg-idを書き換えなんですが, 一覧の流れとしてはresource idを定義したyamlを準備し、それをparseしaws-sdk用いてidに紐付いたreosurce情報を取得、必要dataをトリミングする。トリミング後そのデータを用いてsg-xxxxdata.aws_security_group.xxx.ids[0]に書き換えを行い、それに相当するhcl構文を生成するという流れになります。

 yaml parse後aws-sdkを用いてresource情報を取得

func fetchAwsResource(subnetIds SubnetIds, sgIds SgIds) map[string]string {
    ctx := context.TODO()
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        panic(err)
    }

    // clientの生成, aws profile情報を読み込む
    client := ec2.NewFromConfig(cfg)
    // sgの取得
    groupInputs := ec2.DescribeSecurityGroupsInput{GroupIds: sgIds}

    //security groupの取得
    sgOutput, err := client.DescribeSecurityGroups(ctx, &groupInputs)
    if err != nil {
        panic(err)
    }
    sgMaps := createSecurityGroups(sgOutput, client, ctx, sgIds)

    // subnetの取得
    subnetOutput, err := client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{
        SubnetIds: subnetIds,
    })
    if err != nil {
        panic(err)
    }
    subnetMaps := createSubnetGroups(subnetOutput, client, ctx, subnetIds)

    resourceMaps := mergeMap(sgMaps, subnetMaps)

    return resourceMaps
}

変更対象を検出

変更対象をserchする。
変更対象が見つかったら、下記の戻り値をもとに、その変更対象を構造体として保持する。

// tokenとreplaceAwsResourcesのkeyを比較して一致したらmapに格納し返却する
func appendMatchResourceId(attribute *hclwrite.Attribute, replaceAwsResources map[string]string) map[string]string {
    awsResourceIds := map[string]string{}
    tokens := attribute.Expr().BuildTokens(nil)
    for _, token := range tokens {
        str := string(token.Bytes)
        for key, resourceId := range replaceAwsResources {
            //aws resource idとattributeのtokenが一致するか
            if !strings.Contains(str, resourceId) {
                continue
            }
            awsResourceIds[key] = resourceId
        }
    }

    return awsResourceIds
}

変更対象を書き換え

上記で変更対象を割り出して、変更対象のattribute.expressionを書き換えていく。

func (r *Rewrite) writeToken(attributes map[string]*hclwrite.Attribute) {
    //bodyのattributeを取得しloopで回す
    for attributeKey, attr := range attributes {
        // addressにかくのされているattribute名と一致するを確認
        if attributeKey == r.Address.GetAttribute() {
            // attributeのtokenを取得しloopで回す
            tokens := attr.Expr().BuildTokens(nil)
            for tokenIndex, token := range tokens {
                // tokenの文字列を取得
                hclToken := string(token.Bytes)

                // address("data.hoge": "sg-hogehoge" )のlistを取得しloopで回す
                for key, awsId := range r.Address.GetAwsIds() {
                    // tokenの文字列にaddressの文字列が含まれているか確認
                    if hclToken == awsId {
                        newLabel := createDataSourceSyntax(hclToken, key, awsId)

                        //対象のaddressと一致するtokenを書き換える
                        frontNodeString := string(tokens[tokenIndex-1].Bytes)
                        backNodeString := string(tokens[tokenIndex+1].Bytes)
                        tokens[tokenIndex].Bytes = []byte(strings.ReplaceAll(hclToken, awsId, newLabel))
                        tokens[tokenIndex-1].Bytes = []byte(strings.ReplaceAll(frontNodeString, frontNodeString, ""))
                        tokens[tokenIndex+1].Bytes = []byte(strings.ReplaceAll(backNodeString, backNodeString, ""))
                    }
                }
            }
        }
    }
}

書き換え後、それに相当するblockを生成する

書き換えて、書き換えた内容に相当したdatasourceブロックを生成する
地味にhclTokens{}で定義される右辺の内容を理解するのに手こずってしまった。
hclはbuildTokenというdata型に変換でき、tokenに変換することで fileの中身をbyte列で取得することができる。
特定のattributeをtokenに変換できるし、expressionも変換可能である。
もちろん、下記のコード以外にhclパッケージにはattribute, expressionを生成してくれる関数も存在するので、詳しくはdocを参照してみてください。

// datasource blockを作成する
func createBlock(hclBody *hclwrite.Body, newLabel, datasource, resourceTagName, vpcString string) {
    block := hclBody.AppendNewBlock("data", []string{datasource, newLabel})
    body := block.Body()
    vpcFilterBlock := body.AppendNewBlock("filter", []string{})
    vpcFilterBody := vpcFilterBlock.Body()
    vpcFilterBody.SetAttributeValue("name", cty.StringVal("vpc-id"))
    vpcTokens := hclwrite.Tokens{
        {
            Type:  hclsyntax.TokenOBrack,
            Bytes: []byte{'['},
        },
        {
            Type:  hclsyntax.TokenIdent,
            Bytes: []byte(vpcString),
        },
        {
            Type:  hclsyntax.TokenCBrack,
            Bytes: []byte{']'},
        },
    }

    vpcFilterBody.SetAttributeRaw("values", vpcTokens)
    body.AppendNewline()

    filterResourceNameBlock := body.AppendNewBlock("filter", []string{})
    filterResourceNameBody := filterResourceNameBlock.Body()
    var filterResourceName hclwrite.Tokens
    if datasource == aws_subnets {
        filterResourceNameBody.SetAttributeValue("name", cty.StringVal("tag:Name"))
        filterResourceName = hclwrite.Tokens{
            {
                Type:  hclsyntax.TokenOBrack,
                Bytes: []byte{'['},
            },
            {
                Type:  hclsyntax.TokenOQuote,
                Bytes: []byte(`"`),
            },
            {
                Type:  hclsyntax.TokenIdent,
                Bytes: []byte(resourceTagName),
            },
            {
                Type:  hclsyntax.TokenCQuote,
                Bytes: []byte(`"`),
            },
            {
                Type:  hclsyntax.TokenCBrack,
                Bytes: []byte{']'},
            },
        }
    }

    if datasource == aws_security_groups {
        filterResourceNameBody.SetAttributeValue("name", cty.StringVal("group-name"))
        filterResourceName = hclwrite.Tokens{
            {
                Type:  hclsyntax.TokenOBrack,
                Bytes: []byte{'['},
            },
            {
                Type:  hclsyntax.TokenOQuote,
                Bytes: []byte(`"`),
            },
            {
                Type:  hclsyntax.TokenIdent,
                Bytes: []byte(resourceTagName),
            },
            {
                Type:  hclsyntax.TokenCQuote,
                Bytes: []byte(`"`),
            },
            {
                Type:  hclsyntax.TokenCBrack,
                Bytes: []byte{']'},
            },
        }
    }
    filterResourceNameBody.SetAttributeRaw("values", filterResourceName)
}

断片的ではありますが、以上でhclの特定のexpressionの書き換え、それに相当したdatasourceの生成を行いました。
もちろん上記コードは完全なものではありません。
実際は環境を判別し、それに相当したvpcのdatasouceを生成するコードも存在します。repositoryも社内codeに依存する部分があり公開することができないです。
不完全な内容で簡易的ではありますが、hclの操作例を記述してみました。