Decoding With Dynamic Schema

In section Decoding Into Native Go Values, we saw the most straightforward way to access the content from an HCL file, decoding directly into a Go value whose type is known at application compile time.

For some applications, it is not possible to know the schema of the entire configuration when the application is built. For example, HashiCorp Terraform uses HCL as the foundation of its configuration language, but parts of the configuration are handled by plugins loaded dynamically at runtime, and so the schemas for these portions cannot be encoded directly in the Terraform source code.

HCL’s hcldec package offers a different approach to decoding that allows schemas to be created at runtime, and the result to be decoded into dynamically-typed data structures.

The sections below are an overview of the main parts of package hcldec. For full details, see the package godoc.

Decoder Specification

Whereas gohcl infers the expected schema by using reflection against the given value, hcldec obtains schema through a decoding specification, which is a set of instructions for mapping HCL constructs onto a dynamic data structure.

The hcldec package contains a number of different specifications, each implementing hcldec.Spec and having a Spec suffix on its name. Each spec has two distinct functions:

  • Adding zero or more validation constraints on the input configuration file.
  • Producing a result value based on some elements from the input file.

The most common pattern is for the top-level spec to be a hcldec.ObjectSpec with nested specifications defining either blocks or attributes, depending on whether the configuration file will be block-structured or flat.

spec := hcldec.ObjectSpec{
    "io_mode": &hcldec.AttrSpec{
        Name: "io_mode",
        Type: cty.String,
    },
    "services": &hcldec.BlockMapSpec{
        TypeName:   "service",
        LabelNames: []string{"type", "name"},
        Nested:     hcldec.ObjectSpec{
            "listen_addr": &hcldec.AttrSpec{
                Name:     "listen_addr",
                Type:     cty.String,
                Required: true,
            },
            "processes": &hcldec.BlockMapSpec{
                TypeName:   "service",
                LabelNames: []string{"name"},
                Nested:     hcldec.ObjectSpec{
                    "command": &hcldec.AttrSpec{
                        Name:     "command",
                        Type:     cty.List(cty.String),
                        Required: true,
                    },
                },
            },
        },
    },
}
val, moreDiags := hcldec.Decode(f.Body, spec, nil)
diags = append(diags, moreDiags...)

The above specification expects a configuration shaped like our example in Introduction to HCL, and calls for it to be decoded into a dynamic data structure that would have the following shape if serialized to JSON:

{
  "io_mode": "async",
  "services": {
    "http": {
      "web_proxy": {
        "listen_addr": "127.0.0.1:8080",
        "processes": {
          "main": {
            "command": ["/usr/local/bin/awesome-app", "server"]
          },
          "mgmt": {
            "command": ["/usr/local/bin/awesome-app", "mgmt"]
          }
        }
      }
    }
  }
}

Types and Values With cty

HCL’s expression interpreter is implemented in terms of another library called cty, which provides a type system which HCL builds on and a robust representation of dynamic values in that type system. You could think of cty as being a bit like Go’s own reflect, but for the results of HCL expressions rather than Go programs.

The full details of this system can be found in its own repository, but this section will cover the most important highlights, because hcldec specifications include cty types (as seen in the above example) and its results are cty values.

hcldec works directly with cty — as opposed to converting values directly into Go native types — because the functionality of the cty packages then allows further processing of those values without any loss of fidelity or range. For example, cty defines a JSON encoding of its values that can be decoded losslessly as long as both sides agree on the value type that is expected, which is a useful capability in systems where some sort of RPC barrier separates the main program from its plugins.

Types are instances of cty.Type, and are constructed from functions and variables in cty as shown in the above example, where the string attributes are typed as cty.String, which is a primitive type, and the list attribute is typed as cty.List(cty.String), which constructs a new list type with string elements.

Values are instances of cty.Value, and can also be constructed from functions in cty, using the functions that include Val in their names or using the operation methods available on cty.Value.

In most cases you will eventually want to use the resulting data as native Go types, to pass it to non-cty-aware code. To do this, see the guides on Converting between types (staying within cty) and Converting to and from native Go values.

Partial Decoding

Because the hcldec result is always a value, the input is always entirely processed in a single call, unlike with gohcl.

However, both gohcl and hcldec take hcl.Body as the representation of input, and so it is possible and common to mix them both in the same program.

A common situation is that gohcl is used in the main program to decode the top level of configuration, which then allows the main program to determine which plugins need to be loaded to process the leaf portions of configuration. In this case, the portions that will be interpreted by plugins are retained as opaque hcl.Body until the plugins have been loaded, and then each plugin provides its hcldec.Spec to allow decoding the plugin-specific configuration into a cty.Value which be transmitted to the plugin for further processing.

In our example from Introduction to HCL, perhaps each of the different service types is managed by a plugin, and so the main program would decode the block headers to learn which plugins are needed, but process the block bodies dynamically:

type ServiceConfig struct {
  Type         string   `hcl:"type,label"`
  Name         string   `hcl:"name,label"`
  PluginConfig hcl.Body `hcl:",remain"`
}
type Config struct {
  IOMode   string          `hcl:"io_mode"`
  Services []ServiceConfig `hcl:"service,block"`
}

var c Config
moreDiags := gohcl.DecodeBody(f.Body, nil, &c)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
    // (show diags in the UI)
    return
}

for _, sc := range c.Services {
    pluginName := block.Type

    // Totally-hypothetical plugin manager (not part of HCL)
    plugin, err := pluginMgr.GetPlugin(pluginName)
    if err != nil {
        diags = diags.Append(&hcl.Diagnostic{ /* ... */ })
        continue
    }
    spec := plugin.ConfigSpec() // returns hcldec.Spec

    // Decode the block body using the plugin's given specification
    configVal, moreDiags := hcldec.Decode(sc.PluginConfig, spec, nil)
    diags = append(diags, moreDiags...)
    if moreDiags.HasErrors() {
        continue
    }

    // Again, hypothetical API within your application itself, and not
    // part of HCL. Perhaps plugin system serializes configVal as JSON
    // and sends it over to the plugin.
    svc := plugin.NewService(configVal)
    serviceMgr.AddService(sc.Name, svc)
}

Variables and Functions

The final argument to hcldec.Decode is an expression evaluation context, just as with gohcl.DecodeBlock.

This object can be constructed using the gohcl helper function as before if desired, but you can also choose to work directly with hcl.EvalContext as discussed in Expression Evaluation:

 ctx := &hcl.EvalContext{
     Variables: map[string]cty.Value{
         "pid": cty.NumberIntVal(int64(os.Getpid())),
     },
 }
val, moreDiags := hcldec.Decode(f.Body, spec, ctx)
diags = append(diags, moreDiags...)

As you can see, this lower-level API also uses cty, so it can be particularly convenient in situations where the result of dynamically decoding one block must be available to expressions in another block.