cmd/hcldec: Command-line tool for converting HCL config to JSON
This is essentially a CLI wrapper around the hcldec package, accepting a decoding specification via a HCL-based language and using it to translate input HCL files into JSON values while performing basic structural and type validation of the input files.
This commit is contained in:
parent
102e698035
commit
ee147d9ee6
100
cmd/hcldec/README.md
Normal file
100
cmd/hcldec/README.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# hcldec
|
||||||
|
|
||||||
|
`hcldec` is a command line tool that transforms HCL input into JSON output
|
||||||
|
using a decoding specification given by the user.
|
||||||
|
|
||||||
|
This tool is intended as a "glue" tool, with use-cases like the following:
|
||||||
|
|
||||||
|
* Define a HCL-based configuration format for a third-party tool that takes
|
||||||
|
JSON as input, and then translate the HCL configuration into JSON before
|
||||||
|
running the tool. (See [the `npm-package` example](examples/npm-package).)
|
||||||
|
|
||||||
|
* Use HCL from languages where a HCL parser/decoder is not yet available.
|
||||||
|
At the time of writing, that's any language other than Go.
|
||||||
|
|
||||||
|
* In particular, define a HCL-based configuration format for a shell script
|
||||||
|
and then use `jq` to load the result into environment variables for
|
||||||
|
further processing. (See [the `sh-config-file` example](examples/sh-config-file).)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
If you have a working Go development environment, you can install this tool
|
||||||
|
with `go get` in the usual way:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ go get -u github.com/hashicorp/hcl2/cmd/hcldec
|
||||||
|
```
|
||||||
|
|
||||||
|
This will install `hcldec` in `$GOPATH/bin`, which usually places it into
|
||||||
|
your shell `PATH` so you can then run it as `hcldec`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: hcldec --spec=<spec-file> [options] [hcl-file ...]
|
||||||
|
-o, --out string write to the given file, instead of stdout
|
||||||
|
-s, --spec string path to spec file (required)
|
||||||
|
-V, --vars json-or-file provide variables to the given configuration file(s)
|
||||||
|
-v, --version show the version number and immediately exit
|
||||||
|
```
|
||||||
|
|
||||||
|
The most important step in using `hcldec` is to write the specification that
|
||||||
|
defines how to interpret the given configuration files and translate them
|
||||||
|
into JSON. The following is a simple specification that creates a JSON
|
||||||
|
object from two top-level attributes in the input configuration:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
attr "is_member" {
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Specification files are conventionally kept in files with a `.hcldec`
|
||||||
|
extension. We'll call this one `example.hcldec`.
|
||||||
|
|
||||||
|
With the above specification, the following input file `example.conf` is
|
||||||
|
valid:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
name = "Raul"
|
||||||
|
```
|
||||||
|
|
||||||
|
The spec and the input file can then be provided to `hcldec` to extract a
|
||||||
|
JSON representation:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hcldec --spec=example.hcldec example.conf
|
||||||
|
{"name": "Raul"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The specification defines both how to map the input into a JSON data structure
|
||||||
|
and what input is valid. The `required = true` specified for the `name`
|
||||||
|
allows `hcldec` to detect and raise an error when an attribute of that name
|
||||||
|
is not provided:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hcldec --spec=example.hcldec typo.conf
|
||||||
|
Error: Unsupported attribute
|
||||||
|
|
||||||
|
on example.conf line 1:
|
||||||
|
1: namme = "Juan"
|
||||||
|
|
||||||
|
An attribute named "namme" is not expected here. Did you mean "name"?
|
||||||
|
|
||||||
|
Error: Missing required attribute
|
||||||
|
|
||||||
|
on example.conf line 2:
|
||||||
|
|
||||||
|
The attribute "name" is required, but no definition was found.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
For more details on the `.hcldec` specification file format, see
|
||||||
|
[the spec file documentation](spec-format.md).
|
14
cmd/hcldec/examples/npm-package/example.npmhcl
Normal file
14
cmd/hcldec/examples/npm-package/example.npmhcl
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
name = "hello-world"
|
||||||
|
version = "v0.0.1"
|
||||||
|
|
||||||
|
author {
|
||||||
|
name = "Иван Петрович Сидоров"
|
||||||
|
}
|
||||||
|
|
||||||
|
contributor {
|
||||||
|
name = "Juan Pérez"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies = {
|
||||||
|
left-pad = "1.2.0"
|
||||||
|
}
|
136
cmd/hcldec/examples/npm-package/spec.hcldec
Normal file
136
cmd/hcldec/examples/npm-package/spec.hcldec
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
attr "version" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
attr "description" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "keywords" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
attr "homepage" {
|
||||||
|
# "homepage_url" in input file is translated to "homepage" in output
|
||||||
|
name = "homepage_url"
|
||||||
|
}
|
||||||
|
block "bugs" {
|
||||||
|
object {
|
||||||
|
attr "url" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "email" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attr "license" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
block "author" {
|
||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "email" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "url" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block_list "contributors" {
|
||||||
|
block_type = "contributor"
|
||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "email" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "url" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attr "files" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
attr "main" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "bin" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "man" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
attr "directories" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
block "repository" {
|
||||||
|
object {
|
||||||
|
attr "type" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
attr "url" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attr "scripts" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "config" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "dependencies" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "devDependencies" {
|
||||||
|
name = "dev_dependencies"
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "peerDependencies" {
|
||||||
|
name = "peer_dependencies"
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "bundledDependencies" {
|
||||||
|
name = "bundled_dependencies"
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "optionalDependencies" {
|
||||||
|
name = "optional_dependencies"
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "engines" {
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
attr "os" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
attr "cpu" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
attr "prefer_global" {
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
default "private" {
|
||||||
|
attr {
|
||||||
|
name = "private"
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
literal {
|
||||||
|
value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attr "publishConfig" {
|
||||||
|
type = map(any)
|
||||||
|
}
|
||||||
|
}
|
10
cmd/hcldec/examples/sh-config-file/example.conf
Normal file
10
cmd/hcldec/examples/sh-config-file/example.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
name = "Juan"
|
||||||
|
friend {
|
||||||
|
name = "John"
|
||||||
|
}
|
||||||
|
friend {
|
||||||
|
name = "Yann"
|
||||||
|
}
|
||||||
|
friend {
|
||||||
|
name = "Ermintrude"
|
||||||
|
}
|
26
cmd/hcldec/examples/sh-config-file/example.sh
Executable file
26
cmd/hcldec/examples/sh-config-file/example.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# All paths from this point on are relative to the directory containing this
|
||||||
|
# script, for simplicity's sake.
|
||||||
|
cd "$( dirname "${BASH_SOURCE[0]}" )"
|
||||||
|
|
||||||
|
# Read the config file using hcldec and then use jq to extract values in a
|
||||||
|
# shell-friendly form. jq will ensure that the values are properly quoted and
|
||||||
|
# escaped for consumption by the shell.
|
||||||
|
CONFIG_VARS="$(hcldec --spec=spec.hcldec example.conf | jq -r '@sh "NAME=\(.name) GREETING=\(.greeting) FRIENDS=(\(.friends))"')"
|
||||||
|
if [ $? != 0 ]; then
|
||||||
|
# If hcldec or jq failed then it has already printed out some error messages
|
||||||
|
# and so we can bail out.
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Import our settings into our environment
|
||||||
|
eval "$CONFIG_VARS"
|
||||||
|
|
||||||
|
# ...and now, some contrived usage of the settings we loaded:
|
||||||
|
echo "$GREETING $NAME!"
|
||||||
|
for name in ${FRIENDS[@]}; do
|
||||||
|
echo "$GREETING $name, too!"
|
||||||
|
done
|
23
cmd/hcldec/examples/sh-config-file/spec.hcldec
Normal file
23
cmd/hcldec/examples/sh-config-file/spec.hcldec
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
default "greeting" {
|
||||||
|
attr {
|
||||||
|
name = "greeting"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
literal {
|
||||||
|
value = "Hello"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block_list "friends" {
|
||||||
|
block_type = "friend"
|
||||||
|
attr {
|
||||||
|
name = "name"
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
214
cmd/hcldec/main.go
Normal file
214
cmd/hcldec/main.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl2/hcl"
|
||||||
|
"github.com/hashicorp/hcl2/hcldec"
|
||||||
|
"github.com/hashicorp/hcl2/hclparse"
|
||||||
|
flag "github.com/spf13/pflag"
|
||||||
|
"github.com/zclconf/go-cty/cty"
|
||||||
|
ctyjson "github.com/zclconf/go-cty/cty/json"
|
||||||
|
"golang.org/x/crypto/ssh/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
const versionStr = "0.0.1-dev"
|
||||||
|
|
||||||
|
// vars is populated from --vars arguments on the command line, via a flag
|
||||||
|
// registration in init() below.
|
||||||
|
var vars = &varSpecs{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
specFile = flag.StringP("spec", "s", "", "path to spec file (required)")
|
||||||
|
outputFile = flag.StringP("out", "o", "", "write to the given file, instead of stdout")
|
||||||
|
showVersion = flag.BoolP("version", "v", false, "show the version number and immediately exit")
|
||||||
|
)
|
||||||
|
|
||||||
|
var parser = hclparse.NewParser()
|
||||||
|
var diagWr hcl.DiagnosticWriter // initialized in init
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.VarP(vars, "vars", "V", "provide variables to the given configuration file(s)")
|
||||||
|
|
||||||
|
color := terminal.IsTerminal(int(os.Stderr.Fd()))
|
||||||
|
w, _, err := terminal.GetSize(int(os.Stdout.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
w = 80
|
||||||
|
}
|
||||||
|
diagWr = hcl.NewDiagnosticTextWriter(os.Stderr, parser.Files(), uint(w), color)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = usage
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(versionStr)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
|
||||||
|
err := realmain(args)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %s\n\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func realmain(args []string) error {
|
||||||
|
|
||||||
|
if *specFile == "" {
|
||||||
|
return fmt.Errorf("the --spec=... argument is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var diags hcl.Diagnostics
|
||||||
|
|
||||||
|
spec, specDiags := loadSpecFile(*specFile)
|
||||||
|
diags = append(diags, specDiags...)
|
||||||
|
if specDiags.HasErrors() {
|
||||||
|
diagWr.WriteDiagnostics(diags)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx *hcl.EvalContext
|
||||||
|
if len(*vars) != 0 {
|
||||||
|
ctx = &hcl.EvalContext{
|
||||||
|
Variables: map[string]cty.Value{},
|
||||||
|
}
|
||||||
|
for i, varsSpec := range *vars {
|
||||||
|
var vals map[string]cty.Value
|
||||||
|
var valsDiags hcl.Diagnostics
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(varsSpec), "{") {
|
||||||
|
// literal JSON object on the command line
|
||||||
|
vals, valsDiags = parseVarsArg(varsSpec, i)
|
||||||
|
} else {
|
||||||
|
// path to a file containing either HCL or JSON (by file extension)
|
||||||
|
vals, valsDiags = parseVarsFile(varsSpec)
|
||||||
|
}
|
||||||
|
diags = append(diags, valsDiags...)
|
||||||
|
for k, v := range vals {
|
||||||
|
ctx.Variables[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodies []hcl.Body
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
src, err := ioutil.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read stdin: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, fDiags := parser.ParseHCL(src, "<stdin>")
|
||||||
|
diags = append(diags, fDiags...)
|
||||||
|
if !fDiags.HasErrors() {
|
||||||
|
bodies = append(bodies, f.Body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, filename := range args {
|
||||||
|
f, fDiags := parser.ParseHCLFile(filename)
|
||||||
|
diags = append(diags, fDiags...)
|
||||||
|
if !fDiags.HasErrors() {
|
||||||
|
bodies = append(bodies, f.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if diags.HasErrors() {
|
||||||
|
diagWr.WriteDiagnostics(diags)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body hcl.Body
|
||||||
|
switch len(bodies) {
|
||||||
|
case 0:
|
||||||
|
// should never happen, but... okay?
|
||||||
|
body = hcl.EmptyBody()
|
||||||
|
case 1:
|
||||||
|
body = bodies[0]
|
||||||
|
default:
|
||||||
|
body = hcl.MergeBodies(bodies)
|
||||||
|
}
|
||||||
|
|
||||||
|
val, decDiags := hcldec.Decode(body, spec, ctx)
|
||||||
|
diags = append(diags, decDiags...)
|
||||||
|
|
||||||
|
if diags.HasErrors() {
|
||||||
|
diagWr.WriteDiagnostics(diags)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ctyjson.Marshal(val, val.Type())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// hcldec will include explicit nulls where an ObjectSpec has a spec
|
||||||
|
// that refers to a missing item, but that'll probably be annoying for
|
||||||
|
// a consumer of our output to deal with so we'll just strip those
|
||||||
|
// out and reduce to only the non-null values.
|
||||||
|
out = stripJSONNullProperties(out)
|
||||||
|
|
||||||
|
target := os.Stdout
|
||||||
|
if *outputFile != "" {
|
||||||
|
target, err = os.OpenFile(*outputFile, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("can't open %s for writing: %s", *outputFile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(target, "%s\n", out)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: hcldec --spec=<spec-file> [options] [hcl-file ...]\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripJSONNullProperties(src []byte) []byte {
|
||||||
|
var v interface{}
|
||||||
|
err := json.Unmarshal(src, &v)
|
||||||
|
if err != nil {
|
||||||
|
// We expect valid JSON
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v = stripNullMapElements(v)
|
||||||
|
|
||||||
|
new, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return new
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripNullMapElements(v interface{}) interface{} {
|
||||||
|
switch tv := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
for k, ev := range tv {
|
||||||
|
if ev == nil {
|
||||||
|
delete(tv, k)
|
||||||
|
} else {
|
||||||
|
tv[k] = stripNullMapElements(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case []interface{}:
|
||||||
|
for i, ev := range tv {
|
||||||
|
tv[i] = stripNullMapElements(ev)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
339
cmd/hcldec/spec-format.md
Normal file
339
cmd/hcldec/spec-format.md
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
# `hcldec` spec format
|
||||||
|
|
||||||
|
The `hcldec` spec format instructs [`hcldec`](README.md) on how to validate
|
||||||
|
one or more configuration files given in the HCL syntax and how to translate
|
||||||
|
the result into JSON format.
|
||||||
|
|
||||||
|
The spec format is itself built from HCL syntax, with each HCL block serving
|
||||||
|
as a _spec_ whose block type and contents together describe a single mapping
|
||||||
|
action and, in most cases, a validation constraint. Each spec block produces
|
||||||
|
one JSON value.
|
||||||
|
|
||||||
|
A spec _file_ must have a single top-level spec block that describes the
|
||||||
|
top-level JSON value `hcldec` will return, and that spec block may have other
|
||||||
|
nested spec blocks (depending on its type) that produce nested structures and
|
||||||
|
additional validation constraints.
|
||||||
|
|
||||||
|
The most common usage of `hcldec` is to produce a JSON object whose properties
|
||||||
|
are derived from the top-level content of the input file. In this case, the
|
||||||
|
root of the given spec file will have an `object` spec block whose contents
|
||||||
|
describe how each of the object's properties are to be populated using
|
||||||
|
nested spec blocks.
|
||||||
|
|
||||||
|
Each spec is evaluated in the context of an HCL _body_, which is the HCL
|
||||||
|
terminology for one level of nesting in a configuration file. The top-level
|
||||||
|
objects in a file all belong to the root body of that file, and then each
|
||||||
|
nested block has its own body containing the elements within that block.
|
||||||
|
Some spec types select a new body as the context for their nested specs,
|
||||||
|
allowing nested HCL structures to be decoded.
|
||||||
|
|
||||||
|
## Spec Block Types
|
||||||
|
|
||||||
|
The following sections describe the different block types that can be used to
|
||||||
|
define specs within a spec file.
|
||||||
|
|
||||||
|
### `object` spec blocks
|
||||||
|
|
||||||
|
The `object` spec type is the most commonly used at the root of a spec file.
|
||||||
|
Its result is a JSON object whose properties are set based on any nested
|
||||||
|
spec blocks:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
object {
|
||||||
|
attr "name" {
|
||||||
|
type = "string"
|
||||||
|
}
|
||||||
|
block "address" {
|
||||||
|
object {
|
||||||
|
attr "street" {
|
||||||
|
type = "string"
|
||||||
|
}
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested spec blocks inside `object` must always have an extra block label
|
||||||
|
`"name"`, `"address"` and `"street"` in the above example) that specifies
|
||||||
|
the name of the property that should be created in the JSON object result.
|
||||||
|
This label also acts as a default name selector for the nested spec, allowing
|
||||||
|
the `attr` blocks in the above example to omit the usually-required `name`
|
||||||
|
argument in cases where the HCL input name and JSON output name are the same.
|
||||||
|
|
||||||
|
An `object` spec block creates no validation constraints, but it passes on
|
||||||
|
any validation constraints created by the nested specs.
|
||||||
|
|
||||||
|
### `array` spec blocks
|
||||||
|
|
||||||
|
The `array` spec type produces a JSON array whose elements are set based on
|
||||||
|
any nested spec blocks:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
array {
|
||||||
|
attr {
|
||||||
|
name = "first_element"
|
||||||
|
type = "string"
|
||||||
|
}
|
||||||
|
attr {
|
||||||
|
name = "second_element"
|
||||||
|
type = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An `array` spec block creates no validation constraints, but it passes on
|
||||||
|
any validation constraints created by the nested specs.
|
||||||
|
|
||||||
|
### `attr` spec blocks
|
||||||
|
|
||||||
|
The `attr` spec type reads the value of an attribute in the current body
|
||||||
|
and returns that value as its result. It also creates validation constraints
|
||||||
|
for the given attribute name and its value.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
attr {
|
||||||
|
name = "document_root"
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`attr` spec blocks accept the following arguments:
|
||||||
|
|
||||||
|
* `name` (required) - The attribute name to expect within the HCL input file.
|
||||||
|
This may be omitted when a default name selector is created by a parent
|
||||||
|
`object` spec, if the input attribute name should match the output JSON
|
||||||
|
object property name.
|
||||||
|
|
||||||
|
* `type` (optional) - A [type expression](#type-expressions) that the given
|
||||||
|
attribute value must conform to. If this argument is set, `hcldec` will
|
||||||
|
automatically convert the given input value to this type or produce an
|
||||||
|
error if that is not possible.
|
||||||
|
|
||||||
|
* `required` (optional) - If set to `true`, `hcldec` will produce an error
|
||||||
|
if a value is not provided for the source attribute.
|
||||||
|
|
||||||
|
`attr` is a leaf spec type, so no nested spec blocks are permitted.
|
||||||
|
|
||||||
|
### `block` spec blocks
|
||||||
|
|
||||||
|
The `block` spec type applies one nested spec block to the contents of a
|
||||||
|
block within the current body and returns the result of that spec. It also
|
||||||
|
creates validation constraints for the given block type name.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
block {
|
||||||
|
block_type = "logging"
|
||||||
|
|
||||||
|
object {
|
||||||
|
attr "level" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "file" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`block` spec blocks accept the following arguments:
|
||||||
|
|
||||||
|
* `block_type` (required) - The block type name to expect within the HCL
|
||||||
|
input file. This may be omitted when a default name selector is created
|
||||||
|
by a parent `object` spec, if the input block type name should match the
|
||||||
|
output JSON object property name.
|
||||||
|
|
||||||
|
* `required` (optional) - If set to `true`, `hcldec` will produce an error
|
||||||
|
if a block of the specified type is not present in the current body.
|
||||||
|
|
||||||
|
`block` creates a validation constraint that there must be zero or one blocks
|
||||||
|
of the given type name, or exactly one if `required` is set.
|
||||||
|
|
||||||
|
`block` expects a single nested spec block, which is applied to the body of
|
||||||
|
the block of the given type when it is present.
|
||||||
|
|
||||||
|
### `block_list` spec blocks
|
||||||
|
|
||||||
|
The `block_list` spec type is similar to `block`, but it accepts zero or
|
||||||
|
more blocks of a specified type rather than requiring zero or one. The
|
||||||
|
result is a JSON array with one entry per block of the given type.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
block_list {
|
||||||
|
block_type = "log_file"
|
||||||
|
|
||||||
|
object {
|
||||||
|
attr "level" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "filename" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`block_list` spec blocks accept the following arguments:
|
||||||
|
|
||||||
|
* `block_type` (required) - The block type name to expect within the HCL
|
||||||
|
input file. This may be omitted when a default name selector is created
|
||||||
|
by a parent `object` spec, if the input block type name should match the
|
||||||
|
output JSON object property name.
|
||||||
|
|
||||||
|
* `min_items` (optional) - If set to a number greater than zero, `hcldec` will
|
||||||
|
produce an error if fewer than the given number of blocks are present.
|
||||||
|
|
||||||
|
* `max_items` (optional) - If set to a number greater than zero, `hcldec` will
|
||||||
|
produce an error if more than the given number of blocks are present. This
|
||||||
|
attribute must be greater than or equal to `min_items` if both are set.
|
||||||
|
|
||||||
|
`block` creates a validation constraint on the number of blocks of the given
|
||||||
|
type that must be present.
|
||||||
|
|
||||||
|
`block` expects a single nested spec block, which is applied to the body of
|
||||||
|
each matching block to produce the resulting list items.
|
||||||
|
|
||||||
|
### `block_set` spec blocks
|
||||||
|
|
||||||
|
The `block_set` spec type behaves the same as `block_list` except that
|
||||||
|
the result is in no specific order and any duplicate items are removed.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
block_set {
|
||||||
|
block_type = "log_file"
|
||||||
|
|
||||||
|
object {
|
||||||
|
attr "level" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
attr "filename" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The contents of `block_set` are the same as for `block_list`.
|
||||||
|
|
||||||
|
### `block_map` spec blocks
|
||||||
|
|
||||||
|
The `block_map` spec type is similar to `block`, but it accepts zero or
|
||||||
|
more blocks of a specified type rather than requiring zero or one. The
|
||||||
|
result is a JSON object, or possibly multiple nested JSON objects, whose
|
||||||
|
properties are derived from the labels set on each matching block.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
block_map {
|
||||||
|
block_type = "log_file"
|
||||||
|
labels = ["filename"]
|
||||||
|
|
||||||
|
object {
|
||||||
|
attr "level" {
|
||||||
|
type = string
|
||||||
|
required = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`block_map` spec blocks accept the following arguments:
|
||||||
|
|
||||||
|
* `block_type` (required) - The block type name to expect within the HCL
|
||||||
|
input file. This may be omitted when a default name selector is created
|
||||||
|
by a parent `object` spec, if the input block type name should match the
|
||||||
|
output JSON object property name.
|
||||||
|
|
||||||
|
* `labels` (required) - A list of user-oriented block label names. Each entry
|
||||||
|
in this list creates one level of object within the output value, and
|
||||||
|
requires one additional block header label on any child block of this type.
|
||||||
|
Block header labels are the quoted strings that appear after the block type
|
||||||
|
name but before the opening `{`.
|
||||||
|
|
||||||
|
`block` creates a validation constraint on the number of labels that blocks
|
||||||
|
of the given type must have.
|
||||||
|
|
||||||
|
`block` expects a single nested spec block, which is applied to the body of
|
||||||
|
each matching block to produce the resulting map items.
|
||||||
|
|
||||||
|
## `literal` spec blocks
|
||||||
|
|
||||||
|
The `literal` spec type returns a given literal value, and creates no
|
||||||
|
validation constraints. It is most commonly used with the `default` spec
|
||||||
|
type to create a fallback value, but can also be used e.g. to fill out
|
||||||
|
required properties in an `object` spec that do not correspond to any
|
||||||
|
construct in the input configuration.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
literal {
|
||||||
|
value = "hello world"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`literal` spec blocks accept the following argument:
|
||||||
|
|
||||||
|
* `value` (required) - The value to return.
|
||||||
|
|
||||||
|
`literal` is a leaf spec type, so no nested spec blocks are permitted.
|
||||||
|
|
||||||
|
## `default` spec blocks
|
||||||
|
|
||||||
|
The `default` spec type evaluates a sequence of nested specs in turn and
|
||||||
|
returns the result of the first one that produces a non-null value.
|
||||||
|
It creates no validation constraints of its own, but passes on the validation
|
||||||
|
constraints from its first nested block.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
default {
|
||||||
|
attr {
|
||||||
|
name = "private"
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
literal {
|
||||||
|
value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `default` spec block must have at least one nested spec block, and should
|
||||||
|
generally have at least two since otherwise the `default` wrapper is a no-op.
|
||||||
|
|
||||||
|
The second and any subsequent spec blocks are _fallback_ specs. These exhibit
|
||||||
|
their usual behavior but are not able to impose validation constraints on the
|
||||||
|
current body since they are not evaluated unless all prior specs produce
|
||||||
|
`null` as their result.
|
||||||
|
|
||||||
|
## Type Expressions
|
||||||
|
|
||||||
|
Type expressions are used to describe the expected type of an attribute, as
|
||||||
|
an additional validation constraint.
|
||||||
|
|
||||||
|
A type expression uses primitive type names and compound type constructors.
|
||||||
|
A type constructor builds a new type based on one or more type expression
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
The following type names and type constructors are supported:
|
||||||
|
|
||||||
|
* `any` is a wildcard that accepts a value of any type. (In HCL terms, this
|
||||||
|
is the _dynamic pseudo-type_.)
|
||||||
|
* `string` is a Unicode string.
|
||||||
|
* `number` is an arbitrary-precision floating point number.
|
||||||
|
* `bool` is a boolean value (`true` or `false`)
|
||||||
|
* `list(element_type)` constructs a list type with the given element type
|
||||||
|
* `set(element_type)` constructs a set type with the given element type
|
||||||
|
* `map(element_type)` constructs a map type with the given element type
|
||||||
|
* `object({name1 = element_type, name2 = element_type, ...})` constructs
|
||||||
|
an object type with the given attribute types.
|
||||||
|
* `tuple([element_type, element_type, ...])` constructs a tuple type with
|
||||||
|
the given element types. This can be used, for example, to require an
|
||||||
|
array with a particular number of elements, or with elements of different
|
||||||
|
types.
|
||||||
|
|
||||||
|
The above types are as defined by
|
||||||
|
[the HCL syntax-agnostic information model](../../hcl/spec.md). After
|
||||||
|
validation, values are lowered to JSON's type system, which is a subset
|
||||||
|
of the HCL type system.
|
||||||
|
|
||||||
|
`null` is a valid value of any type, and not a type itself.
|
474
cmd/hcldec/spec.go
Normal file
474
cmd/hcldec/spec.go
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl2/gohcl"
|
||||||
|
"github.com/hashicorp/hcl2/hcl"
|
||||||
|
"github.com/hashicorp/hcl2/hcldec"
|
||||||
|
"github.com/zclconf/go-cty/cty"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadSpecFile(filename string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
file, diags := parser.ParseHCLFile(filename)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeSpecRoot(file.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSpecRoot(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
content, diags := body.Content(specSchemaUnlabelled)
|
||||||
|
|
||||||
|
if len(content.Blocks) == 0 {
|
||||||
|
if diags.HasErrors() {
|
||||||
|
// If we already have errors then they probably explain
|
||||||
|
// why we have no blocks, so we'll skip our additional
|
||||||
|
// error message added below.
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing spec block",
|
||||||
|
Detail: "A spec file must have exactly one root block specifying how to map to a JSON value.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content.Blocks) > 1 {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Extraneous spec block",
|
||||||
|
Detail: "A spec file must have exactly one root block specifying how to map to a JSON value.",
|
||||||
|
Subject: &content.Blocks[1].DefRange,
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, specDiags := decodeSpecBlock(content.Blocks[0])
|
||||||
|
diags = append(diags, specDiags...)
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSpecBlock(block *hcl.Block) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
var impliedName string
|
||||||
|
if len(block.Labels) > 0 {
|
||||||
|
impliedName = block.Labels[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch block.Type {
|
||||||
|
|
||||||
|
case "object":
|
||||||
|
return decodeObjectSpec(block.Body)
|
||||||
|
|
||||||
|
case "attr":
|
||||||
|
return decodeAttrSpec(block.Body, impliedName)
|
||||||
|
|
||||||
|
case "block":
|
||||||
|
return decodeBlockSpec(block.Body, impliedName)
|
||||||
|
|
||||||
|
case "block_list":
|
||||||
|
return decodeBlockListSpec(block.Body, impliedName)
|
||||||
|
|
||||||
|
case "block_set":
|
||||||
|
return decodeBlockSetSpec(block.Body, impliedName)
|
||||||
|
|
||||||
|
case "block_map":
|
||||||
|
return decodeBlockMapSpec(block.Body, impliedName)
|
||||||
|
|
||||||
|
case "default":
|
||||||
|
return decodeDefaultSpec(block.Body)
|
||||||
|
|
||||||
|
case "literal":
|
||||||
|
return decodeLiteralSpec(block.Body)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Should never happen, because the above cases should be exhaustive
|
||||||
|
// for our schema.
|
||||||
|
var diags hcl.Diagnostics
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Invalid spec block",
|
||||||
|
Detail: fmt.Sprintf("Blocks of type %q are not expected here.", block.Type),
|
||||||
|
Subject: &block.TypeRange,
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeObjectSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
content, diags := body.Content(specSchemaLabelled)
|
||||||
|
|
||||||
|
spec := make(hcldec.ObjectSpec)
|
||||||
|
for _, block := range content.Blocks {
|
||||||
|
propSpec, propDiags := decodeSpecBlock(block)
|
||||||
|
diags = append(diags, propDiags...)
|
||||||
|
spec[block.Labels[0]] = propSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAttrSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
Name *string `hcl:"name"`
|
||||||
|
Type hcl.Expression `hcl:"type"`
|
||||||
|
Required *bool `hcl:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &hcldec.AttrSpec{
|
||||||
|
Name: impliedName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Required != nil {
|
||||||
|
spec.Required = *args.Required
|
||||||
|
}
|
||||||
|
if args.Name != nil {
|
||||||
|
spec.Name = *args.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeDiags hcl.Diagnostics
|
||||||
|
spec.Type, typeDiags = evalTypeExpr(args.Type)
|
||||||
|
diags = append(diags, typeDiags...)
|
||||||
|
|
||||||
|
if spec.Name == "" {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing name in attribute spec",
|
||||||
|
Detail: "The name attribute is required, to specify the attribute name that is expected in an input HCL file.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBlockSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
TypeName *string `hcl:"block_type"`
|
||||||
|
Required *bool `hcl:"required"`
|
||||||
|
Nested hcl.Body `hcl:",remain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &hcldec.BlockSpec{
|
||||||
|
TypeName: impliedName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Required != nil {
|
||||||
|
spec.Required = *args.Required
|
||||||
|
}
|
||||||
|
if args.TypeName != nil {
|
||||||
|
spec.TypeName = *args.TypeName
|
||||||
|
}
|
||||||
|
|
||||||
|
nested, nestedDiags := decodeBlockNestedSpec(args.Nested)
|
||||||
|
diags = append(diags, nestedDiags...)
|
||||||
|
spec.Nested = nested
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBlockListSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
TypeName *string `hcl:"block_type"`
|
||||||
|
MinItems *int `hcl:"min_items"`
|
||||||
|
MaxItems *int `hcl:"max_items"`
|
||||||
|
Nested hcl.Body `hcl:",remain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &hcldec.BlockListSpec{
|
||||||
|
TypeName: impliedName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.MinItems != nil {
|
||||||
|
spec.MinItems = *args.MinItems
|
||||||
|
}
|
||||||
|
if args.MaxItems != nil {
|
||||||
|
spec.MaxItems = *args.MaxItems
|
||||||
|
}
|
||||||
|
if args.TypeName != nil {
|
||||||
|
spec.TypeName = *args.TypeName
|
||||||
|
}
|
||||||
|
|
||||||
|
nested, nestedDiags := decodeBlockNestedSpec(args.Nested)
|
||||||
|
diags = append(diags, nestedDiags...)
|
||||||
|
spec.Nested = nested
|
||||||
|
|
||||||
|
if spec.TypeName == "" {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing block_type in block_list spec",
|
||||||
|
Detail: "The block_type attribute is required, to specify the block type name that is expected in an input HCL file.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBlockSetSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
TypeName *string `hcl:"block_type"`
|
||||||
|
MinItems *int `hcl:"min_items"`
|
||||||
|
MaxItems *int `hcl:"max_items"`
|
||||||
|
Nested hcl.Body `hcl:",remain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &hcldec.BlockSetSpec{
|
||||||
|
TypeName: impliedName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.MinItems != nil {
|
||||||
|
spec.MinItems = *args.MinItems
|
||||||
|
}
|
||||||
|
if args.MaxItems != nil {
|
||||||
|
spec.MaxItems = *args.MaxItems
|
||||||
|
}
|
||||||
|
if args.TypeName != nil {
|
||||||
|
spec.TypeName = *args.TypeName
|
||||||
|
}
|
||||||
|
|
||||||
|
nested, nestedDiags := decodeBlockNestedSpec(args.Nested)
|
||||||
|
diags = append(diags, nestedDiags...)
|
||||||
|
spec.Nested = nested
|
||||||
|
|
||||||
|
if spec.TypeName == "" {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing block_type in block_set spec",
|
||||||
|
Detail: "The block_type attribute is required, to specify the block type name that is expected in an input HCL file.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBlockMapSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
TypeName *string `hcl:"block_type"`
|
||||||
|
Labels []string `hcl:"labels"`
|
||||||
|
Nested hcl.Body `hcl:",remain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &hcldec.BlockMapSpec{
|
||||||
|
TypeName: impliedName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.TypeName != nil {
|
||||||
|
spec.TypeName = *args.TypeName
|
||||||
|
}
|
||||||
|
spec.LabelNames = args.Labels
|
||||||
|
|
||||||
|
nested, nestedDiags := decodeBlockNestedSpec(args.Nested)
|
||||||
|
diags = append(diags, nestedDiags...)
|
||||||
|
spec.Nested = nested
|
||||||
|
|
||||||
|
if spec.TypeName == "" {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing block_type in block_map spec",
|
||||||
|
Detail: "The block_type attribute is required, to specify the block type name that is expected in an input HCL file.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
if len(spec.LabelNames) < 1 {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Invalid block label name list",
|
||||||
|
Detail: "A block_map must have at least one label specified.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBlockNestedSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
content, diags := body.Content(specSchemaUnlabelled)
|
||||||
|
|
||||||
|
if len(content.Blocks) == 0 {
|
||||||
|
if diags.HasErrors() {
|
||||||
|
// If we already have errors then they probably explain
|
||||||
|
// why we have no blocks, so we'll skip our additional
|
||||||
|
// error message added below.
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing spec block",
|
||||||
|
Detail: "A block spec must have exactly one child spec specifying how to decode block contents.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content.Blocks) > 1 {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Extraneous spec block",
|
||||||
|
Detail: "A block spec must have exactly one child spec specifying how to decode block contents.",
|
||||||
|
Subject: &content.Blocks[1].DefRange,
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, specDiags := decodeSpecBlock(content.Blocks[0])
|
||||||
|
diags = append(diags, specDiags...)
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLiteralSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
type content struct {
|
||||||
|
Value cty.Value `hcl:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var args content
|
||||||
|
diags := gohcl.DecodeBody(body, nil, &args)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return &hcldec.LiteralSpec{
|
||||||
|
Value: args.Value,
|
||||||
|
}, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDefaultSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) {
|
||||||
|
content, diags := body.Content(specSchemaUnlabelled)
|
||||||
|
|
||||||
|
if len(content.Blocks) == 0 {
|
||||||
|
if diags.HasErrors() {
|
||||||
|
// If we already have errors then they probably explain
|
||||||
|
// why we have no blocks, so we'll skip our additional
|
||||||
|
// error message added below.
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Missing spec block",
|
||||||
|
Detail: "A default block must have at least one nested spec, each specifying a possible outcome.",
|
||||||
|
Subject: body.MissingItemRange().Ptr(),
|
||||||
|
})
|
||||||
|
return errSpec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content.Blocks) == 1 && !diags.HasErrors() {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagWarning,
|
||||||
|
Summary: "Useless default block",
|
||||||
|
Detail: "A default block with only one spec is equivalent to using that spec alone.",
|
||||||
|
Subject: &content.Blocks[1].DefRange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var spec hcldec.Spec
|
||||||
|
for _, block := range content.Blocks {
|
||||||
|
candidateSpec, candidateDiags := decodeSpecBlock(block)
|
||||||
|
diags = append(diags, candidateDiags...)
|
||||||
|
if candidateDiags.HasErrors() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec == nil {
|
||||||
|
spec = candidateSpec
|
||||||
|
} else {
|
||||||
|
spec = &hcldec.DefaultSpec{
|
||||||
|
Primary: spec,
|
||||||
|
Default: candidateSpec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
var errSpec = &hcldec.LiteralSpec{
|
||||||
|
Value: cty.NullVal(cty.DynamicPseudoType),
|
||||||
|
}
|
||||||
|
|
||||||
|
var specBlockTypes = []string{
|
||||||
|
"object",
|
||||||
|
"tuple",
|
||||||
|
|
||||||
|
"literal",
|
||||||
|
|
||||||
|
"attr",
|
||||||
|
|
||||||
|
"block",
|
||||||
|
"block_list",
|
||||||
|
"block_map",
|
||||||
|
"block_set",
|
||||||
|
|
||||||
|
"default",
|
||||||
|
}
|
||||||
|
|
||||||
|
var specSchemaUnlabelled *hcl.BodySchema
|
||||||
|
var specSchemaLabelled *hcl.BodySchema
|
||||||
|
|
||||||
|
var specSchemaLabelledLabels = []string{"key"}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
specSchemaLabelled = &hcl.BodySchema{
|
||||||
|
Blocks: make([]hcl.BlockHeaderSchema, 0, len(specBlockTypes)),
|
||||||
|
}
|
||||||
|
specSchemaUnlabelled = &hcl.BodySchema{
|
||||||
|
Blocks: make([]hcl.BlockHeaderSchema, 0, len(specBlockTypes)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range specBlockTypes {
|
||||||
|
specSchemaLabelled.Blocks = append(
|
||||||
|
specSchemaLabelled.Blocks,
|
||||||
|
hcl.BlockHeaderSchema{
|
||||||
|
Type: name,
|
||||||
|
LabelNames: specSchemaLabelledLabels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
specSchemaUnlabelled.Blocks = append(
|
||||||
|
specSchemaUnlabelled.Blocks,
|
||||||
|
hcl.BlockHeaderSchema{
|
||||||
|
Type: name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
129
cmd/hcldec/type_expr.go
Normal file
129
cmd/hcldec/type_expr.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl2/hcl"
|
||||||
|
"github.com/zclconf/go-cty/cty"
|
||||||
|
"github.com/zclconf/go-cty/cty/function"
|
||||||
|
)
|
||||||
|
|
||||||
|
var typeType = cty.Capsule("type", reflect.TypeOf(cty.NilType))
|
||||||
|
|
||||||
|
var typeEvalCtx = &hcl.EvalContext{
|
||||||
|
Variables: map[string]cty.Value{
|
||||||
|
"string": wrapTypeType(cty.String),
|
||||||
|
"bool": wrapTypeType(cty.Bool),
|
||||||
|
"number": wrapTypeType(cty.Number),
|
||||||
|
"any": wrapTypeType(cty.DynamicPseudoType),
|
||||||
|
},
|
||||||
|
Functions: map[string]function.Function{
|
||||||
|
"list": function.New(&function.Spec{
|
||||||
|
Params: []function.Parameter{
|
||||||
|
{
|
||||||
|
Name: "element_type",
|
||||||
|
Type: typeType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: function.StaticReturnType(typeType),
|
||||||
|
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||||
|
ety := unwrapTypeType(args[0])
|
||||||
|
ty := cty.List(ety)
|
||||||
|
return wrapTypeType(ty), nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"set": function.New(&function.Spec{
|
||||||
|
Params: []function.Parameter{
|
||||||
|
{
|
||||||
|
Name: "element_type",
|
||||||
|
Type: typeType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: function.StaticReturnType(typeType),
|
||||||
|
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||||
|
ety := unwrapTypeType(args[0])
|
||||||
|
ty := cty.Set(ety)
|
||||||
|
return wrapTypeType(ty), nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"map": function.New(&function.Spec{
|
||||||
|
Params: []function.Parameter{
|
||||||
|
{
|
||||||
|
Name: "element_type",
|
||||||
|
Type: typeType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: function.StaticReturnType(typeType),
|
||||||
|
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||||
|
ety := unwrapTypeType(args[0])
|
||||||
|
ty := cty.Map(ety)
|
||||||
|
return wrapTypeType(ty), nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"tuple": function.New(&function.Spec{
|
||||||
|
Params: []function.Parameter{
|
||||||
|
{
|
||||||
|
Name: "element_types",
|
||||||
|
Type: cty.List(typeType),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: function.StaticReturnType(typeType),
|
||||||
|
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||||
|
etysVal := args[0]
|
||||||
|
etys := make([]cty.Type, 0, etysVal.LengthInt())
|
||||||
|
for it := etysVal.ElementIterator(); it.Next(); {
|
||||||
|
_, wrapEty := it.Element()
|
||||||
|
etys = append(etys, unwrapTypeType(wrapEty))
|
||||||
|
}
|
||||||
|
ty := cty.Tuple(etys)
|
||||||
|
return wrapTypeType(ty), nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"object": function.New(&function.Spec{
|
||||||
|
Params: []function.Parameter{
|
||||||
|
{
|
||||||
|
Name: "attribute_types",
|
||||||
|
Type: cty.Map(typeType),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: function.StaticReturnType(typeType),
|
||||||
|
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||||
|
atysVal := args[0]
|
||||||
|
atys := make(map[string]cty.Type)
|
||||||
|
for it := atysVal.ElementIterator(); it.Next(); {
|
||||||
|
nameVal, wrapAty := it.Element()
|
||||||
|
name := nameVal.AsString()
|
||||||
|
atys[name] = unwrapTypeType(wrapAty)
|
||||||
|
}
|
||||||
|
ty := cty.Object(atys)
|
||||||
|
return wrapTypeType(ty), nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalTypeExpr(expr hcl.Expression) (cty.Type, hcl.Diagnostics) {
|
||||||
|
result, diags := expr.Value(typeEvalCtx)
|
||||||
|
if result.IsNull() {
|
||||||
|
return cty.DynamicPseudoType, diags
|
||||||
|
}
|
||||||
|
if !result.Type().Equals(typeType) {
|
||||||
|
diags = append(diags, &hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Invalid type expression",
|
||||||
|
Detail: fmt.Sprintf("A type is required, not %s.", result.Type().FriendlyName()),
|
||||||
|
})
|
||||||
|
return cty.DynamicPseudoType, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrapTypeType(result), diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTypeType(ty cty.Type) cty.Value {
|
||||||
|
return cty.CapsuleVal(typeType, &ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapTypeType(val cty.Value) cty.Type {
|
||||||
|
return *(val.EncapsulatedValue().(*cty.Type))
|
||||||
|
}
|
74
cmd/hcldec/vars.go
Normal file
74
cmd/hcldec/vars.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl2/hcl"
|
||||||
|
"github.com/zclconf/go-cty/cty"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseVarsArg(src string, argIdx int) (map[string]cty.Value, hcl.Diagnostics) {
|
||||||
|
fakeFn := fmt.Sprintf("<vars argument %d>", argIdx)
|
||||||
|
f, diags := parser.ParseJSON([]byte(src), fakeFn)
|
||||||
|
if f == nil {
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
vals, valsDiags := parseVarsBody(f.Body)
|
||||||
|
diags = append(diags, valsDiags...)
|
||||||
|
return vals, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVarsFile(filename string) (map[string]cty.Value, hcl.Diagnostics) {
|
||||||
|
var f *hcl.File
|
||||||
|
var diags hcl.Diagnostics
|
||||||
|
|
||||||
|
if strings.HasSuffix(filename, ".json") {
|
||||||
|
f, diags = parser.ParseJSONFile(filename)
|
||||||
|
} else {
|
||||||
|
f, diags = parser.ParseHCLFile(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
vals, valsDiags := parseVarsBody(f.Body)
|
||||||
|
diags = append(diags, valsDiags...)
|
||||||
|
return vals, diags
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVarsBody(body hcl.Body) (map[string]cty.Value, hcl.Diagnostics) {
|
||||||
|
attrs, diags := body.JustAttributes()
|
||||||
|
if attrs == nil {
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
vals := make(map[string]cty.Value, len(attrs))
|
||||||
|
for name, attr := range attrs {
|
||||||
|
val, valDiags := attr.Expr.Value(nil)
|
||||||
|
diags = append(diags, valDiags...)
|
||||||
|
vals[name] = val
|
||||||
|
}
|
||||||
|
return vals, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
// varSpecs is an implementation of pflag.Value that accumulates a list of
|
||||||
|
// raw values, ignoring any quoting. This is similar to pflag.StringSlice
|
||||||
|
// but does not complain if there are literal quotes inside the value, which
|
||||||
|
// is important for us to accept JSON literals here.
|
||||||
|
type varSpecs []string
|
||||||
|
|
||||||
|
func (vs *varSpecs) String() string {
|
||||||
|
return strings.Join([]string(*vs), ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *varSpecs) Set(new string) error {
|
||||||
|
*vs = append(*vs, new)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *varSpecs) Type() string {
|
||||||
|
return "json-or-file"
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user