查看: 107|回复: 0

使用 hclwrite 包改写 Terraform 代码的例子

[复制链接]

2

主题

8

帖子

12

积分

新手上路

Rank: 1

积分
12
发表于 2023-4-19 16:47:07 | 显示全部楼层 |阅读模式
最近做了一个小工具,感觉还可以,拿出来分享一下思路给读者。
BridgeCrew Yor 是一个开源工具,帮助在基础设施即代码 (IaC) 框架中添加信息丰富且一致的标签。目前,Yor 可以自动为 Terraform、CloudFormation 和 Serverless Frameworks 添加标签。该工具有助于确保基础设施资源被正确地标记,从而提高可视性、成本管理和合规性。使用 Yor,用户可以轻松地向 IaC 文件中添加标签,确保所有资源的标签都是一致且准确的。
一个例子

resource "azurerm_storage_account" "security_storage_account" {
  name                      = "securitystorageaccount-${var.environment}${random_integer.rnd_int.result}"
  resource_group_name       = azurerm_resource_group.example.name
  location                  = azurerm_resource_group.example.location
  account_tier              = "Standard"
  account_replication_type  = "LRS"
  enable_https_traffic_only = true
  tags                      = var.tags
}
比如一个项目里有这样一个 Resource 块,它的 tags 属性赋值为 var.tags。对这个文件使用 Yor 处理后的效果大概是这样的:
resource "azurerm_storage_account" "security_storage_account" {
  name                      = "securitystorageaccount-${var.environment}${random_integer.rnd_int.result}"
  resource_group_name       = azurerm_resource_group.example.name
  location                  = azurerm_resource_group.example.location
  account_tier              = "Standard"
  account_replication_type  = "LRS"
  enable_https_traffic_only = true
  tags                      = merge(var.tags, {
    git_commit           = "a1d1c1ce31a1bde6dafa188846d90eca82abe5fd"
    git_file             = "terraform/azure/mssql.tf"
    git_last_modified_at = "2022-01-20 05:32:41"
    git_last_modified_by = "28880387+tsmithv11@users.noreply.github.com"
    git_modifiers        = "28880387+tsmithv11"
    git_org              = "bridgecrewio"
    git_repo             = "terragoat"
    yor_trace            = "4b504d4d-608c-45fe-ae56-807bde6d969f"
  })
}
Yor 会对当前代码目录读取 git blame 信息,分析当前 Resource 块是否可以应用 tags,如果可以,则会把最近一次的 git 提交信息以硬编码的形式生成并插入代码。用户如果应用了这些标签,那么就可以从相对应的云平台控制台或是命令行等其他交互接口中查询到的信息,从云端资源追溯回到对应的 Terraform 代码仓库、模块信息以及细致的版本信息。
美国关于加强软件供应链安全的总统行政令全名是 "Executive Order on Improving the Nation's Cybersecurity"。该行政令于2021年5月12日发布,旨在加强美国政府和私营部门的网络安全和抵御网络攻击的能力。其中,涉及到加强软件供应链安全的相关措施,以防止恶意软件和恶意代码的注入。
该行政令要求联邦机构采取措施确保采购的软件产品符合安全标准,要求软件供应商提供更多的可追溯性和透明性。这是为了加强软件供应链的安全性,防止恶意软件和恶意代码的注入。
具体来说,联邦机构在采购软件产品时,需要考虑安全标准并要求软件供应商提供相应的安全证明,包括代码审查报告、漏洞披露机制等。此外,软件供应商还应提供更多的可追溯性和透明性,包括提供组成软件的各个组件的来源、版本、证书等信息,以便于联邦机构和客户了解软件的安全性。
这些措施旨在提高软件供应链的安全性,降低恶意软件和恶意代码的注入风险,从而保障联邦机构和私营部门的网络安全。
我们如果把云端基础设施视作服务与软件的组成部分,那么按照加强供应链安全的要求,使用云服务构建自己服务的厂商有责任提升对组成自身基础设施的各个组件的来源和成分的可追溯性,这必然也包括了用来定义和构建这些基础设施所使用到的 Terraform 代码。Yor 可以说是填补了一项重要的空白。
Yor 的问题

Yor 的问题其实也很明显,那就是它生成到 tags 都是被硬编码的。如果今天我们是在编写一些开源的 Terraform Module,我们的目的是让其他不知名的社区成员使用我们的 Module,那么我们必然要考虑到有些用户可能并不喜欢这些 tags。他们会想要关闭这些 tags,很遗憾 Yor 并没有给我们这样的选择。
解决方法

一个想法是给 Yor 的 tags 套一个“盒子”,并且给这个盒子设置一个开关,这样用户就可以从外部调整这个开关来选择打开或是关闭这些标签。例如:
resource "azurerm_storage_account" "security_storage_account" {
  name                      = "securitystorageaccount-${var.environment}${random_integer.rnd_int.result}"
  resource_group_name       = azurerm_resource_group.example.name
  location                  = azurerm_resource_group.example.location
  account_tier              = "Standard"
  account_replication_type  = "LRS"
  enable_https_traffic_only = true
  tags                      = merge(var.tags, (var.yor_toggle ? {
    git_commit           = "a1d1c1ce31a1bde6dafa188846d90eca82abe5fd"
    git_file             = "terraform/azure/mssql.tf"
    git_last_modified_at = "2022-01-20 05:32:41"
    git_last_modified_by = "28880387+tsmithv11@users.noreply.github.com"
    git_modifiers        = "28880387+tsmithv11"
    git_org              = "bridgecrewio"
    git_repo             = "terragoat"
    yor_trace            = "4b504d4d-608c-45fe-ae56-807bde6d969f"
  } : {}))
}
当我们把标签装进这样一个 (var.yor_toggle ? {<YOR_TAGS>} : {}) 的盒子以后,用户就可以通过设置 var.toggle = false 来关闭这些标签。
当然我们可以给 Yor 提交一个补丁来实现这个功能,但这样做有两个风险:

  • Yor 可能并不喜欢这个想法
  • Yor 自身的代码已经比较复杂了,而且。。。比较脆弱,时不时会在一些略显复杂的表达式中出现错误,我个人不太想进一步增加 Yor 的复杂度,除非 Yor 的代码结构经过明显的整理和简化。
基于这样的想法,我决定给 Yor 做一个外挂,暂时就叫 yorbox 了,它要做的就是搜索 Yor 的标签,并套上一个盒子,同时要确保已经套在盒子里的标签在反复执行 yorbox 时不会被重复装箱,不能出现这样的代码:
tags                      = merge(var.tags, (var.yor_toggle ? (var.yor_toggle ? {
    git_commit           = "a1d1c1ce31a1bde6dafa188846d90eca82abe5fd"
    git_file             = "terraform/azure/mssql.tf"
    git_last_modified_at = "2022-01-20 05:32:41"
    git_last_modified_by = "28880387+tsmithv11@users.noreply.github.com"
    git_modifiers        = "28880387+tsmithv11"
    git_org              = "bridgecrewio"
    git_repo             = "terragoat"
    yor_trace            = "4b504d4d-608c-45fe-ae56-807bde6d969f"
  } : {}) : {}))
就不把完整代码拿出来了,简单说一下这三件事怎么做的吧。
搜索 Yor 的标签

首先要先介绍要用来分析和操纵 Terraform 代码的工具,一般我们会用两个包来做这方面的工作:hclsyntax 和 hclwrite。
hclwrite 包和 hclsyntax 包都是用于处理 HashiCorp Configuration Language (HCL) 的工具包。
hclsyntax 包主要用于解析和生成 HCL 代码,可以将 HCL 代码解析为抽象语法树 (AST),也可以将 AST 转换为 HCL 代码。此外,hclsyntax 还提供了一些辅助函数,用于处理 HCL 中的注释、字符串、变量引用等等。
hclwrite 包则提供了一些工具函数,用于以编程方式构建 HCL 代码。它可以让开发人员使用 Go 代码来构建 HCL 文件,这些 HCL 文件可以用于配置基础设施、应用程序等等。与 hclsyntax 不同的是,hclwrite 不需要先解析 HCL 代码,而是直接构建 HCL 代码。
因此,hclsyntax 主要用于解析和生成 HCL 代码,而 hclwrite 则用于以编程方式构建 HCL 代码。
一开始我是想用 hclsyntax 来完成的,因为它可以提供富含语义信息的 AST,分析起来比较简单,但它无法灵活生成代码,它生成的 AST 是只读的,无法修改。即使你通过 AST 定位到了在表达式中要修改的目标节点位置,你仍然无法直接修改然后生成对应的新代码,或是将 AST 中的位置转成 hclwrite 解析出来的 Token 流中的对应位置(至少我没找到,ChatGPT 3.5 也试了几次没找到)。所以最后决定完全使用 hclwrite 来完成任务。
首先要找到文件中所有可以打标签的块,那就是 resource 和 module 块(是的,很多 Module 也有 tags 参数,对于这种 module 块 Yor 也会生成标签)。
func ProcessDirectory(options Options) error {
path := options.Path
files, err := os.ReadDir(path)
if err != nil {
  panic(err.Error())
}

for _, file := range files {
  if file.IsDir() || filepath.Ext(file.Name()) != ".tf" {
   continue
  }

  filePath := filepath.Join(path, file.Name())

  // Read the file contents
  data, err := os.ReadFile(filePath)
  if err != nil {
   panic(err.Error())
  }

  // Parse the file to *hclwrite.File
  f, diag := hclwrite.ParseConfig(data, file.Name(), hcl.InitialPos)
  if diag.HasErrors() {
   return diag
  }

  // Invoke BoxFile function
  BoxFile(f, options.ToggleName)

  // Write the updated file contents back to the file
  err = os.WriteFile(filePath, f.Bytes(), os.ModePerm)
  if err != nil {
   panic(err.Error())
  }
}
return nil
}

func BoxFile(file *hclwrite.File, toggleName string) {
for _, block := range file.Body().Blocks() {
  if block.Type() != "resource" && block.Type() != "module" {
   continue
  }
  boxTagsTokensForBlock(block, toggleName)
}
}
这两个函数搜索了一个文件夹内所有的 Terraform 代码,将之调用 hclwrite.ParseConfig 方法解析成一个 *hclwrite.File,通过判断 block.Type() 找出 resource 和 module 块进行进一步的处理。
在 boxTagsTokensForBlock 函数内部:
tags := block.Body().GetAttribute("tags")
if tags == nil {
return
}
tokens := tags.Expr().BuildTokens(hclwrite.Tokens{})
yorTagsRanges := scanYorTagsRanges(tokens)
搜索块内部名为 tags 的赋值,也就是 tags = xxx 这样的表达式。将这种表达式解析成为 Token 流,交给 scanYorTagsRanges 函数处理。
func scanYorTagsRanges(tokens hclwrite.Tokens) []tokensRange {
ranges := make([]tokensRange, 0)
latestOBrace := lls.New()
var previousYorTraceKey bool
yorTags := false
for i, token := range tokens {
  switch token.Type {
  case hclsyntax.TokenNewline:
   continue
  case hclsyntax.TokenQuotedLit:
   fallthrough
  case hclsyntax.TokenIdent:
   name := string(token.Bytes)
   previousYorTraceKey = name == "yor_trace" || name == "git_commit"
  case hclsyntax.TokenEqual:
   fallthrough
  case hclsyntax.TokenColon:
   // we're sure `yor_trace =` or `git_commit =` or `yor_trace:` or `git_commit:`
   if previousYorTraceKey {
    yorTags = true
   }
  case hclsyntax.TokenOBrace:
   latestOBrace.Push(i)
  case hclsyntax.TokenCBrace:
   start, _ := latestOBrace.Pop()
   if yorTags {
    ranges = append(ranges, tokensRange{Start: start.(int), End: i})
    yorTags = false
   }
  default:
  }
}
return ranges
}
scanYorTagsRanges 其实就是寻找有没有在 {} 中的,Key 为 yor_trace 或是 git_commit 的键值对声明,有的话就记录左右花括号的位置,该区域就是 Yor 标签的范围。有时 Yor 读取不到 git blame 信息,比如你当前本地的 git commit 没有 push 到远程仓库时,Yor 只会生成一个包含 yor_trace 的块。在下一次执行 Yor 命令后,Yor 读取到了信息,这时后续的包含 git commit 的标签块会是一个独立的块,和之前的块被 merge 函数连接起来,所以我们要确保包含这两种 Key 的块都会被正确装箱。
装箱

装箱比较简单。手写 (var.yor_toggle ? {<YOR_TAGS>} : {}) 代码,交给 hclwrite 解析后就可以直接观察生成的 Token 流。
// (var.yor_toggle ?
var togglePrefixTokens = []any{
  &hclwrite.Token{
   Type:  hclsyntax.TokenOParen,
   Bytes: []byte("("),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenIdent,
   Bytes: []byte("var"),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenDot,
   Bytes: []byte("."),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenIdent,
   Bytes: []byte(toggleName),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenQuestion,
   Bytes: []byte("?"),
  },
}

// ": {})"
var toggleSuffixTokens = []any{
  &hclwrite.Token{
   Type:  hclsyntax.TokenColon,
   Bytes: []byte(":"),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenOBrace,
   Bytes: []byte("{"),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenCBrace,
   Bytes: []byte("}"),
  },
  &hclwrite.Token{
   Type:  hclsyntax.TokenCParen,
   Bytes: []byte(")"),
  },
}
手工构造左右两边的 Token 串待用。
    yorTagsRanges := scanYorTagsRanges(tokens)
linq.From(yorTagsRanges).OrderByDescending(func(i interface{}) interface{} {
  return i.(tokensRange).End
}).ToSlice(&yorTagsRanges)
for _, r := range yorTagsRanges {
  if toggleTails.Contains(r.Start) {
   continue
  }
  output.Insert(r.End+1, toggleSuffixTokens...)
  output.Insert(r.Start, togglePrefixTokens...)
}
tokens = hclwrite.Tokens{}
it := output.Iterator()
for it.Next() {
  tokens = append(tokens, it.Value().(*hclwrite.Token))
}
block.Body().SetAttributeRaw("tags", tokens)
前面我们已经找到的 Yor 标签的范围,根据位置倒排一下,从后面开始插入(插入的新 Token 不会影响其他插入点的位置)。生成新的 Token 切片后,通过 block.Body().SetAttributeRaw("tags", tokens) 方法设置回去。
防止重复装箱

这里其实做的比较简单,在刚才的插入装箱 Token 的代码中:
for _, r := range yorTagsRanges {
  if toggleTails.Contains(r.Start) {
   continue
  }
  output.Insert(r.End+1, toggleSuffixTokens...)
  output.Insert(r.Start, togglePrefixTokens...)
}
假如 Yor 的标签位置已经存在于 toggleTails 里,则不装箱。这个 toggleTails 来源是:
toggleRanges := scanYorToggleRanges(tokens, toggleName)
toggleTails := hashset.New()
for _, r := range toggleRanges {
  toggleTails.Add(r.End + 1)
}
用 scanYorToggleRanges 方法找到 Token 中所有箱子左侧的位置,记录下来,假如一个 Yor 的标签块前面直接是一个箱子左侧,那么就不用重复装箱了。
func scanYorToggleRanges(tokens hclwrite.Tokens, toggleName string) []tokensRange {
ranges := make([]tokensRange, 0)
for i, token := range tokens {
  if token.Type != hclsyntax.TokenIdent || string(token.Bytes) != "var" {
   continue
  }
  if i+3 >= len(tokens) {
   continue
  }
  if tokens[i+1].Type != hclsyntax.TokenDot {
   continue
  }
  if tokens[i+2].Type == hclsyntax.TokenIdent && string(tokens[i+2].Bytes) == toggleName {
   ranges = append(ranges, tokensRange{Start: i, End: i + 3})
  }
}
return ranges
}
比较简单的暴力匹配,甚至不想用 KMP,足够用就行了。
总结

一个简单的小工具,试用了一下还不错,可以准确装箱,没有打算做拆箱。后续可能会考虑把箱子的左右侧做成可以配置的,这样就可以简单实现更复杂的装箱逻辑。
yorbox 的代码部分是由 ChatGPT 3.5 生成的,部分是由 Github Copilot 生成的,这个体验下来,在 ChatGPT 懂的领域,它生成的代码又快又好,比如 main 函数就是它生成的,我基本只调整了一下具体几个名词:
package main

import (
"flag"
"fmt"

"github.com/lonegunmanb/yorbox/pkg"
)

func main() {
// Define command line flags
var dirPath string
flag.StringVar(&dirPath, "dir", "", "Path to the directory containing .tf files")

var toggleName string
flag.StringVar(&toggleName, "toggleName", "yor_toggle", "Name of the toggle to add")

var help bool
flag.BoolVar(&help, "help", false, "Print help information")

flag.Parse()

if help {
  // Print help information
  fmt.Println("Usage: myprogram -dir <directory path> [-toggleName <toggle name>]")
  flag.PrintDefaults()
  return
}

if dirPath == "" {
  fmt.Println("Directory path is required. Use -help for more information.")
  return
}

err := pkg.ProcessDirectory(pkg.Options{
  Path:       dirPath,
  ToggleName: toggleName,
})
if err != nil {
  fmt.Println("Error processing directory:", err)
  return
}

fmt.Println("Directory processed successfully.")
}
除了部分实现代码,ChatGPT 还帮助我生成了一些单元测试用例,Copilot 帮我生成了部分单元测试代码。
Readme 基本上是 ChatGPT 生成的。本文关于 Yor 的介绍,以及美国关于加强软件供应链安全的总统行政令的介绍也是 ChatGPT 生成的。总体来说 ChatGPT 和 Github Copilot 可以有效提升个人开发的效率。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表