runsisi's

technical notes

go hcl 解析

2019-03-28 runsisi#golang

HCL 是 hashicorp 推出的一个配置语言,在 hashicorp 的产品,如 Consul、Terraform 中用作标准的配置语言,其语法结构有点类似于 nginx 的配置文件,一个简单的例子如下:

table = "filter"

enabled = ["mon","osd"]
disabled = []

rule "ipv4" "mon" {
    priority = 100

    tcp = [
        "-p tcp -m multiport --dport 6789,3300 -m state --state NEW -j ACCEPT",
        "-p tcp -m multiport --dport 6789,3300 -m state --state ESTABLISHED -j ACCEPT",
    ]
}

rule "ipv4" "osd" {
    priority = 200

    tcp = [
        "-p tcp -m multiport --dport 6800:7300 -m state --state NEW -j ACCEPT",
        "-p tcp -m multiport --dport 6800:7300 -m state --state ESTABLISHED -j ACCEPT",
    ]
}

针对 hcl 文档的操作,官方的 hashicorp/hcl2 库提供了非常完善的支持,本文用到的仅是该库关于 hcl 文档解析里非常、非常小的一部分。

hcl 文档结构

hcl 文档由顶层的 attribute(s) 和 block(s) 组成;

block 由 block header 以及内层的 attribute(s) 和 block(s) 组成;

block header 由 type 和 label(s) 组成。

hcl 文档解析上层接口

可以使用上层的接口,以类似 json、yaml 的方式进行解析,直接从 hcl 文档 decode 到 Go 数据结构,可以参考如下的例子:

package main

import (
	"github.com/hashicorp/hcl2/gohcl"
	"github.com/hashicorp/hcl2/hclparse"
	"github.com/pkg/errors"
	"github.com/sanity-io/litter"
	"log"
)

const data = `table = "filter"

enabled = ["mon","osd"]
disabled = []

ipv4 "mon" {
   priority = 100

   tcp = [
       "-p tcp -m multiport --dport 6789,3300 -m state --state NEW -j ACCEPT",
       "-p tcp -m multiport --dport 6789,3300 -m state --state ESTABLISHED -j ACCEPT",
   ]
}

ipv6 "osd" {
   priority = 200

   tcp = [
       "-p tcp -m multiport --dport 6800:7300 -m state --state NEW -j ACCEPT",
       "-p tcp -m multiport --dport 6800:7300 -m state --state ESTABLISHED -j ACCEPT",
   ]
}
`

type Config struct {
	Table *string `hcl:"table,attr"`

	Enabled  []string `hcl:"enabled,attr"`
	Disabled []string `hcl:"disabled,optional"`

	RulesV4 []Rule `hcl:"ipv4,block"`
	RulesV6 []Rule `hcl:"ipv6,block"`
}

type Rule struct {
	Name     string   `hcl:"name,label"`
	Priority *int     `hcl:"priority,optional"`
	TCP      []string `hcl:"tcp,optional"`
	UDP      []string `hcl:"udp,optional"`
}

func decode() (Config, error) {
	parser := hclparse.NewParser()
	file, diags := parser.ParseHCL([]byte(data), "literal-document")
	if file == nil || file.Body == nil {
		return Config{}, errors.Wrap(diags, "failed to parse hcl document")
	}

	var config Config
	if diags := gohcl.DecodeBody(file.Body, nil, &config); diags != nil {
		return Config{}, errors.Wrap(diags, "failed to decode hcl document")
	}
	return config, nil
}

func main() {
	config, err := decode()
	if err != nil {
		log.Fatal(err)
	}
	litter.Dump(config)
}

hcl 文档解析底层接口

也可以根据 hcl 文档的结构使用底层接口进行更灵活的解析,可以参考如下的例子:

package main

import (
	"github.com/hashicorp/hcl2/gohcl"
	"github.com/hashicorp/hcl2/hcl"
	"github.com/hashicorp/hcl2/hclparse"
	"github.com/pkg/errors"
	"github.com/sanity-io/litter"
	"log"
)

const data = `table = "filter"

enabled = ["mon","osd"]
disabled = []

ipv4 "mon" {
    priority = 100

    tcp = [
        "-p tcp -m multiport --dport 6789,3300 -m state --state NEW -j ACCEPT",
        "-p tcp -m multiport --dport 6789,3300 -m state --state ESTABLISHED -j ACCEPT",
    ]
}

ipv6 "osd" {
    priority = 200

    tcp = [
        "-p tcp -m multiport --dport 6800:7300 -m state --state NEW -j ACCEPT",
        "-p tcp -m multiport --dport 6800:7300 -m state --state ESTABLISHED -j ACCEPT",
    ]
}
`

type Config struct {
	Table *string

	Enabled  []string
	Disabled []string

	RulesV4 []Rule
	RulesV6 []Rule
}

type Rule struct {
	Name     string
	Priority *int
	TCP      []string
	UDP      []string
}

var fileSchema = &hcl.BodySchema{
	Attributes: []hcl.AttributeSchema{
		{
			Name: "table",
		},
		{
			Name: "enabled",
		},
		{
			Name:     "disabled",
			Required: false,
		},
	},
	Blocks: []hcl.BlockHeaderSchema{
		{
			Type:       "ipv4",
			LabelNames: []string{"name"},
		},
		{
			Type:       "ipv6",
			LabelNames: []string{"name"},
		},
	},
}

var ruleBlockSchema = &hcl.BodySchema{
	Attributes: []hcl.AttributeSchema{
		{
			Name:     "priority",
			Required: false,
		},
		{
			Name:     "tcp",
			Required: false,
		},
		{
			Name:     "udp",
			Required: false,
		},
	},
}

func decode() (Config, error) {
	parser := hclparse.NewParser()
	file, diags := parser.ParseHCL([]byte(data), "literal-document")
	if file == nil || file.Body == nil {
		return Config{}, errors.Wrap(diags, "failed to parse hcl document")
	}

	content, diags := file.Body.Content(fileSchema)
	if content == nil {
		return Config{}, errors.Wrap(diags, "failed to decode hcl document")
	}

	var config Config
	for _, attr := range content.Attributes {
		switch attr.Name {
		case "table":
			attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &config.Table)
			diags = append(diags, attrDiags...)
		case "enabled":
			attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &config.Enabled)
			diags = append(diags, attrDiags...)
		case "disabled":
			attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &config.Disabled)
			diags = append(diags, attrDiags...)
		default:
			// Should never happen because the above cases should be exhaustive
			// for all attribute type names in our schema.
			continue
		}
	}

	for _, block := range content.Blocks {
		switch block.Type {
		case "ipv4":
			rule, blockDiags := decodeRuleBlock(block)
			config.RulesV4 = append(config.RulesV4, rule)
			diags = append(diags, blockDiags...)
		case "ipv6":
			rule, blockDiags := decodeRuleBlock(block)
			config.RulesV6 = append(config.RulesV6, rule)
			diags = append(diags, blockDiags...)
		default:
			// Should never happen because the above cases should be exhaustive
			// for all block type names in our schema.
			continue
		}
	}

	if diags.HasErrors() {
		return Config{}, errors.Wrap(diags, "failed to decode hcl content")
	}
	return config, nil
}

func decodeRuleBlock(block *hcl.Block) (Rule, hcl.Diagnostics) {
	content, diags := block.Body.Content(ruleBlockSchema)
	if content == nil {
		return Rule{}, diags
	}

	rule := Rule{
		Name: block.Labels[0],
	}
	if attr, exists := content.Attributes["priority"]; exists {
		attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &rule.Priority)
		diags = append(diags, attrDiags...)
	}
	if attr, exists := content.Attributes["tcp"]; exists {
		attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &rule.TCP)
		diags = append(diags, attrDiags...)
	}
	if attr, exists := content.Attributes["udp"]; exists {
		attrDiags := gohcl.DecodeExpression(attr.Expr, nil, &rule.UDP)
		diags = append(diags, attrDiags...)
	}
	return rule, diags
}

func main() {
	config, err := decode()
	if err != nil {
		log.Fatal(err)
	}
	litter.Dump(config)
}

参考资料

A small repo of sample HCL2 apps

https://github.com/sean-/hcl2tests

HCL Config Language Toolkit

https://hcl.readthedocs.io/en/latest/

Terraform is a tool for building, changing, and combining infrastructure safely and efficiently.

https://github.com/hashicorp/terraform/tree/master/configs