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-xxxx
をdata.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の操作例を記述してみました。