| package main |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "io/ioutil" |
| "os" |
| "strings" |
| |
| "github.com/hashicorp/hcl/v2" |
| "github.com/hashicorp/hcl/v2/hcldec" |
| "github.com/hashicorp/hcl/v2/hclparse" |
| flag "github.com/spf13/pflag" |
| "github.com/zclconf/go-cty/cty" |
| "github.com/zclconf/go-cty/cty/function" |
| 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") |
| diagsFormat = flag.StringP("diags", "", "", "format any returned diagnostics in the given format; currently only \"json\" is accepted") |
| showVarRefs = flag.BoolP("var-refs", "", false, "rather than decoding input, produce a JSON description of the variables referenced by it") |
| withType = flag.BoolP("with-type", "", false, "include an additional object level at the top describing the HCL-oriented type of the result value") |
| showVersion = flag.BoolP("version", "v", false, "show the version number and immediately exit") |
| keepNulls = flag.BoolP("keep-nulls", "", false, "retain object properties that have null as their value (they are removed by default)") |
| ) |
| |
| 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)") |
| } |
| |
| func main() { |
| flag.Usage = usage |
| flag.Parse() |
| |
| if *showVersion { |
| fmt.Println(versionStr) |
| os.Exit(0) |
| } |
| |
| args := flag.Args() |
| |
| switch *diagsFormat { |
| case "": |
| 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) |
| case "json": |
| diagWr = &jsonDiagWriter{w: os.Stderr} |
| default: |
| fmt.Fprintf(os.Stderr, "Invalid diagnostics format %q: only \"json\" is supported.\n", *diagsFormat) |
| os.Exit(2) |
| } |
| |
| 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 |
| |
| specContent, specDiags := loadSpecFile(*specFile) |
| diags = append(diags, specDiags...) |
| if specDiags.HasErrors() { |
| diagWr.WriteDiagnostics(diags) |
| flush(diagWr) |
| os.Exit(2) |
| } |
| |
| spec := specContent.RootSpec |
| |
| ctx := &hcl.EvalContext{ |
| Variables: map[string]cty.Value{}, |
| Functions: map[string]function.Function{}, |
| } |
| for name, val := range specContent.Variables { |
| ctx.Variables[name] = val |
| } |
| for name, f := range specContent.Functions { |
| ctx.Functions[name] = f |
| } |
| if len(*vars) != 0 { |
| 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 |
| } |
| } |
| } |
| |
| // If we have empty context elements then we'll nil them out so that |
| // we'll produce e.g. "variables are not allowed" errors instead of |
| // "variable not found" errors. |
| if len(ctx.Variables) == 0 { |
| ctx.Variables = nil |
| } |
| if len(ctx.Functions) == 0 { |
| ctx.Functions = nil |
| } |
| if ctx.Variables == nil && ctx.Functions == nil { |
| ctx = nil |
| } |
| |
| 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 { |
| var f *hcl.File |
| var fDiags hcl.Diagnostics |
| if strings.HasSuffix(filename, ".json") { |
| f, fDiags = parser.ParseJSONFile(filename) |
| } else { |
| f, fDiags = parser.ParseHCLFile(filename) |
| } |
| diags = append(diags, fDiags...) |
| if !fDiags.HasErrors() { |
| bodies = append(bodies, f.Body) |
| } |
| } |
| } |
| |
| if diags.HasErrors() { |
| diagWr.WriteDiagnostics(diags) |
| flush(diagWr) |
| 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) |
| } |
| |
| if *showVarRefs { |
| vars := hcldec.Variables(body, spec) |
| return showVarRefsJSON(vars, ctx) |
| } |
| |
| val, decDiags := hcldec.Decode(body, spec, ctx) |
| diags = append(diags, decDiags...) |
| |
| if diags.HasErrors() { |
| diagWr.WriteDiagnostics(diags) |
| flush(diagWr) |
| os.Exit(2) |
| } |
| |
| wantType := val.Type() |
| if *withType { |
| // We'll instead ask to encode as dynamic, which will make the |
| // marshaler include type information. |
| wantType = cty.DynamicPseudoType |
| } |
| out, err := ctyjson.Marshal(val, wantType) |
| 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. |
| if !*keepNulls { |
| 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 showVarRefsJSON(vars []hcl.Traversal, ctx *hcl.EvalContext) error { |
| type PosJSON struct { |
| Line int `json:"line"` |
| Column int `json:"column"` |
| Byte int `json:"byte"` |
| } |
| type RangeJSON struct { |
| Filename string `json:"filename"` |
| Start PosJSON `json:"start"` |
| End PosJSON `json:"end"` |
| } |
| type StepJSON struct { |
| Kind string `json:"kind"` |
| Name string `json:"name,omitempty"` |
| Key json.RawMessage `json:"key,omitempty"` |
| Range RangeJSON `json:"range"` |
| } |
| type TraversalJSON struct { |
| RootName string `json:"root_name"` |
| Value json.RawMessage `json:"value,omitempty"` |
| Steps []StepJSON `json:"steps"` |
| Range RangeJSON `json:"range"` |
| } |
| |
| ret := make([]TraversalJSON, 0, len(vars)) |
| for _, traversal := range vars { |
| tJSON := TraversalJSON{ |
| Steps: make([]StepJSON, 0, len(traversal)), |
| } |
| |
| for _, step := range traversal { |
| var sJSON StepJSON |
| rng := step.SourceRange() |
| sJSON.Range.Filename = rng.Filename |
| sJSON.Range.Start.Line = rng.Start.Line |
| sJSON.Range.Start.Column = rng.Start.Column |
| sJSON.Range.Start.Byte = rng.Start.Byte |
| sJSON.Range.End.Line = rng.End.Line |
| sJSON.Range.End.Column = rng.End.Column |
| sJSON.Range.End.Byte = rng.End.Byte |
| switch ts := step.(type) { |
| case hcl.TraverseRoot: |
| sJSON.Kind = "root" |
| sJSON.Name = ts.Name |
| tJSON.RootName = ts.Name |
| case hcl.TraverseAttr: |
| sJSON.Kind = "attr" |
| sJSON.Name = ts.Name |
| case hcl.TraverseIndex: |
| sJSON.Kind = "index" |
| src, err := ctyjson.Marshal(ts.Key, ts.Key.Type()) |
| if err == nil { |
| sJSON.Key = json.RawMessage(src) |
| } |
| default: |
| // Should never get here, since the above should be exhaustive |
| // for all possible traversal step types. |
| sJSON.Kind = "(unknown)" |
| } |
| tJSON.Steps = append(tJSON.Steps, sJSON) |
| } |
| |
| // Best effort, we'll try to include the current known value of this |
| // traversal, if any. |
| val, diags := traversal.TraverseAbs(ctx) |
| if !diags.HasErrors() { |
| enc, err := ctyjson.Marshal(val, val.Type()) |
| if err == nil { |
| tJSON.Value = json.RawMessage(enc) |
| } |
| } |
| |
| rng := traversal.SourceRange() |
| tJSON.Range.Filename = rng.Filename |
| tJSON.Range.Start.Line = rng.Start.Line |
| tJSON.Range.Start.Column = rng.Start.Column |
| tJSON.Range.Start.Byte = rng.Start.Byte |
| tJSON.Range.End.Line = rng.End.Line |
| tJSON.Range.End.Column = rng.End.Column |
| tJSON.Range.End.Byte = rng.End.Byte |
| |
| ret = append(ret, tJSON) |
| } |
| |
| out, err := json.MarshalIndent(ret, "", " ") |
| if err != nil { |
| return fmt.Errorf("failed to marshal variable references as JSON: %s", err) |
| } |
| |
| 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 stripJSONNullProperties(src []byte) []byte { |
| dec := json.NewDecoder(bytes.NewReader(src)) |
| dec.UseNumber() |
| |
| var v interface{} |
| err := dec.Decode(&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 |
| } |
| } |