Hate YAML? Build your next tool with HCL!
Published: 2022-03-30 16:24:37

In this post, I want to show how you can implement your own tool using the HCL format. 
The HCL configuration format is used by all the amazing HasiCorp tools like [Terraform](https://www.terraform.io/), [Vault](https://www.vaultproject.io/), and [Nomad](https://www.nomadproject.io/).

Most modern applications and tools use YAML these days which is generally an easy-to-read format but can also be the cause of a lot of pain because of the white space sensitivity. HCL on the other hand provides an easy-to-read format with a clear structure and additional features like variable interpolation and inline function calls. 


Let's start with a really simple example to parse HCL.

```
task "first_task" {
    exec "list_current_dir" {
        command = "ls ."
    }

    exec "list_var_dir" {
        command = "ls /var"
    }
}
```

To map the HCL to our structs we can use struct tags:

```golang
type Config struct {
    Tasks []*Task `hcl:"task,block"`
}
type Task struct {
    Name  string     `hcl:"name,label"`
    Steps []*ExecStep `hcl:"exec,block"`
}

type ExecStep struct {
    Name    string `hcl:"name,label"`
    Command string `hcl:"command"`
}

func (s *ExecStep) Run() error {
    return nil
}

```

And to decode the HCL we can use the `decode` function from the `hclsimple` package

```golang
import (
    "fmt"
    "os"
    "github.com/hashicorp/hcl/v2/hclsimple"
)

var (
    exampleHCL = `
        task "first_task" {
            exec "list_current_dir" {
                command = "ls ."
            }

            exec "list_var_dir" {
                command = "ls /var"
            }
        }
    `
)

func main() {
    config := &Config{}
    err := hclsimple.Decode("example.hcl", []byte(exampleHCL), nil, config)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    for _, task := range config.Tasks {
        fmt.Printf("Task: %s\n", task.Name)
        for _, step := range task.Steps {
            fmt.Printf("    Step: %s %q\n", step.Name, step.Command)
            err = step.Run()
            if err != nil {
                fmt.Println(err)
                os.Exit(1)
            }
        }
    }
}

```

That was easy! 

But what is if I want to support a different Step types? Let's say I want to support `mkdir` to easily create directories.

```
task "first_task" {
    mkdir "build_dir" {
        path = "./build"
    }
    exec "list_var_dir" {
        command = "ls /var"
    }
}
```

If I run our tool I get the following error:

```
example.hcl:3,4-9: Unsupported block type; Blocks of type "mkdir" are not expected here.
```

We could update our Task struct and add a block list for "mkdir":

```golang
type Task struct {
    Name      string       `hcl:"name,label"`
    ExecSteps []*ExecStep  `hcl:"exec,block"`
    MkdirStep []*MkdirStep `hcl:"mkdir,block"`
}
```

but obviously, we would lose the execution order since we have two separate lists. This is not going to work for us.

As an alternative solution we could change our configuration and make the Step type a label:

```
task "first_task" {
    step "mkdir" "build_dir" {
        path = "./build/"
    }
    step "exec" "list_build_dir" {
        command = "ls ./build/"
    }
}
```

Let's reflect the configuration change to our structs.

```golang
type Config struct {
    Tasks []*Task `hcl:"task,block"`
}

type Task struct {
    Name  string  `hcl:"name,label"`
    Steps []*Step `hcl:"step,block"`
}

type Step struct {
    Type   string   `hcl:"type,label"`
    Name   string   `hcl:"name,label"`
    Remain hcl.Body `hcl:",remain"`
}

type ExecStep struct {
    Command string `hcl:"command"`
}

func (s *ExecStep) Run() error {
    return nil
}

type MkdirStep struct {
    Path string `hcl:"path"`
}

func (s *MkdirStep) Run() error {
    return nil
}
```

As you can see we have added a new `Step` struct and use it in the `Tasks` struct instead of the `ExecStep`
The `Step` struct has an extra field called `Remain`. This field is required to capture all fields of the actual Step implementation.We will see later how we use the `Remain` field to decode the fields into our actual Step implementation.

Lastly, we add a new interface that allows us to run the Step implementation:

```golang
type Runner interface {
    Run() error
}
```
You can see above that all our Steps implement the Runner interface.

Now we have to adapt our parsing code:

```golang
import (
    "fmt"
    "os"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/gohcl"
    "github.com/hashicorp/hcl/v2/hclsimple"
)

var (
    exampleHCL = `
        task "first_task" {
            step "mkdir" "build_dir" {
                path = "./build/"
            }
            step "exec" "list_build_dir" {
                command = "ls ./build/"
            }
        }
    `
)

func main() {
    config := &Config{}
    err := hclsimple.Decode("example.hcl", []byte(exampleHCL), nil, config)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    for _, task := range config.Tasks {
        fmt.Printf("Task: %s\n", task.Name)
        for _, step := range task.Steps {
            fmt.Printf("    Step: %s %s\n", step.Type, step.Name)
            // The actual step implementation 
            // which implements the Runner interface
            var runner Runner

            // Determine the step implementation
            switch step.Type {
            case "mkdir":
                runner = &MkdirStep{}
            case "exec":
                runner = &ExecStep{}
            default:
                fmt.Printf("Unknown step type %q\n", step.Type)
                os.Exit(1)
            }

            // Decode remaining fields into our step implementation.
            diags := gohcl.DecodeBody(step.Remain, nil, runner)
            if diags.HasErrors() {
                fmt.Println(diags)
                os.Exit(1)
            }

            err = runner.Run()
            if err != nil {
                fmt.Println(err)
                os.Exit(1)
            }
        }
    }
}

```
The parsing does determine the Step type in the switch statement and creates an instance of the Struct. The struct is then the target of `gohcl.DecodeBody(step.Remain, nil, runner)` which decodes the remaining fields. 

Voilà, we have an easy-to-extend Task execution engine.

## Resources

Godocs:
- [hcl](https://pkg.go.dev/github.com/hashicorp/hcl/v2)
- [hclsimple](https://pkg.go.dev/github.com/hashicorp/hcl/v2/hclsimple)
- [gohcl](https://pkg.go.dev/github.com/hashicorp/hcl/v2/gohcl)

Others:
- [buildmedia.readthedocs.org/hcl.pdf](https://buildmedia.readthedocs.org/media/pdf/hcl/guide/hcl.pdf)

Source Gists:
- [Example 1](https://gist.github.com/weakpixel/8501a0dabeeb800ef38b6d1cc9fa1e72)
- [Example 2](https://gist.github.com/weakpixel/356f44e8a3d0a74c6e542967ade5eb00)

## Full Source Code

```golang
package main

import (
    "fmt"
    "os"

    "github.com/hashicorp/hcl/v2"
    "github.com/hashicorp/hcl/v2/gohcl"
    "github.com/hashicorp/hcl/v2/hclsimple"
)

var (
    exampleHCL = `
        task "first_task" {
            step "mkdir" "build_dir" {
                path = "./build/"
            }
            step "exec" "list_build_dir" {
                command = "ls ./build/"
            }
        }
    `
)

func main() {
    config := &Config{}
    err := hclsimple.Decode("example.hcl", []byte(exampleHCL), nil, config)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    for _, task := range config.Tasks {
        fmt.Printf("Task: %s\n", task.Name)
        for _, step := range task.Steps {
            fmt.Printf("    Step: %s %s\n", step.Type, step.Name)

            var runner Runner

            switch step.Type {
            case "mkdir":
                runner = &MkdirStep{}
            case "exec":
                runner = &ExecStep{}
            default:
                fmt.Printf("Unknown step type %q\n", step.Type)
                os.Exit(1)
            }

            diags := gohcl.DecodeBody(step.Remain, nil, runner)
            if diags.HasErrors() {
                fmt.Println(diags)
                os.Exit(1)
            }
            err = runner.Run()
            if err != nil {
                fmt.Println(err)
                os.Exit(1)
            }
        }
    }
}

type Config struct {
    Tasks []*Task `hcl:"task,block"`
}
type Task struct {
    Name  string  `hcl:"name,label"`
    Steps []*Step `hcl:"step,block"`
}

type Step struct {
    Type   string   `hcl:"type,label"`
    Name   string   `hcl:"name,label"`
    Remain hcl.Body `hcl:",remain"`
}

type ExecStep struct {
    Command string `hcl:"command"`
}

func (s *ExecStep) Run() error {
    // Implement me
    return nil
}

type MkdirStep struct {
    Path string `hcl:"path"`
}

func (s *MkdirStep) Run() error {
    // Implement me
    return nil
}

type Runner interface {
    Run() error
}
```