pkg/config/yaml.go (322 lines of code) (raw):

// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "bufio" "bytes" "encoding/json" "fmt" "os" "regexp" "strconv" "strings" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/pkg/errors" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ctyJson "github.com/zclconf/go-cty/cty/json" "gopkg.in/yaml.v3" ) // yPath is a helper for YamlCtx to build "Path". It's agnostic to the Blueprint structure. type yPath string // At is a builder method for a path of a child in a sequence. func (p yPath) At(i int) yPath { return yPath(fmt.Sprintf("%s[%d]", p, i)) } // Dot is a builder method for a path of a child in a mapping. func (p yPath) Dot(k string) yPath { if p == "" { return yPath(k) } return yPath(fmt.Sprintf("%s.%s", p, k)) } // Pos is a position in the blueprint file. type Pos struct { Line int Column int } func parseYaml[T any](y []byte) (T, YamlCtx, error) { var s T yamlCtx, err := NewYamlCtx(y) if err != nil { // YAML parsing error return s, yamlCtx, err } decoder := yaml.NewDecoder(bytes.NewReader(y)) decoder.KnownFields(true) if err = decoder.Decode(&s); err != nil { return s, yamlCtx, parseYamlV3Error(err) } return s, yamlCtx, nil } func parseYamlFile[T any](path string) (T, YamlCtx, error) { y, err := os.ReadFile(path) if err != nil { var s T return s, YamlCtx{}, fmt.Errorf("failed to read the input yaml, filename=%s: %v", path, err) } return parseYaml[T](y) } // YamlCtx is a contextual information to render errors. type YamlCtx struct { pathToPos map[yPath]Pos Lines []string } // Pos returns a position of a given path if one is found. func (c YamlCtx) Pos(p Path) (Pos, bool) { pos, ok := c.pathToPos[yPath(p.String())] return pos, ok } func syntheticOutputsNode(name string, ln int, col int) *yaml.Node { return &yaml.Node{ Kind: yaml.MappingNode, Content: []*yaml.Node{ { Kind: yaml.ScalarNode, Value: "name", Line: ln, Column: col, }, { Kind: yaml.ScalarNode, Value: name, Line: ln, Column: col, }, }, Line: ln, Column: col, } } // normalizeNode is treating variadic YAML syntax, ensuring that // there is only one (canonical) way to refer to a piece of blueprint. // Handled cases: // * Module.outputs: // ``` // outputs: // - name: grog # canonical path to "grog" value is `...outputs[0].name` // - mork # canonical path to "mork" value is `...outputs[1].name`, NOT `...outputs[1]` // ``` func normalizeYamlNode(p yPath, n *yaml.Node) *yaml.Node { switch { case n.Kind == yaml.ScalarNode && regexp.MustCompile(`^deployment_groups\[\d+\]\.modules\[\d+\]\.outputs\[\d+\]$`).MatchString(string(p)): return syntheticOutputsNode(n.Value, n.Line, n.Column) default: return n } } // NewYamlCtx creates a new YamlCtx from a given YAML data. // NOTE: The data should be a valid blueprint YAML (previously used to parse Blueprint), // this function will panic if it's not valid YAML and doesn't validate Blueprint structure. func NewYamlCtx(data []byte) (YamlCtx, error) { var lines []string sc := bufio.NewScanner(bytes.NewReader(data)) for sc.Scan() { lines = append(lines, sc.Text()) } var c nodeCapturer m := map[yPath]Pos{} // error may happen if YAML is not valid, regardless of Blueprint schema if err := yaml.Unmarshal(data, &c); err != nil { return YamlCtx{m, lines}, parseYamlV3Error(err) } var walk func(n *yaml.Node, p yPath, posOf *yaml.Node) walk = func(n *yaml.Node, p yPath, posOf *yaml.Node) { n = normalizeYamlNode(p, n) if posOf == nil { // use position of node itself if posOf is not set posOf = n } m[p] = Pos{posOf.Line, posOf.Column} if n.Kind == yaml.MappingNode { for i := 0; i < len(n.Content); i += 2 { // for mapping items use position of the key walk(n.Content[i+1], p.Dot(n.Content[i].Value), n.Content[i]) } } else if n.Kind == yaml.SequenceNode { for i, c := range n.Content { walk(c, p.At(i), nil) } } } if c.n != nil { walk(c.n, "", nil) } return YamlCtx{m, lines}, nil } type nodeCapturer struct{ n *yaml.Node } func nodeToPosErr(n *yaml.Node, err error) PosError { return PosError{Pos{Line: n.Line, Column: n.Column}, err} } func (c *nodeCapturer) UnmarshalYAML(n *yaml.Node) error { c.n = n return nil } // UnmarshalYAML implements a custom unmarshaler from YAML string to ModuleKind func (mk *ModuleKind) UnmarshalYAML(n *yaml.Node) error { var kind string err := n.Decode(&kind) if err == nil && IsValidModuleKind(kind) { mk.kind = kind return nil } return nodeToPosErr(n, errors.New(`kind must be "packer" or "terraform" or removed from YAML`)) } // MarshalYAML implements a custom marshaler from ModuleKind to YAML string func (mk ModuleKind) MarshalYAML() (interface{}, error) { return mk.String(), nil } // UnmarshalYAML is a custom unmarshaler for Module.Use, that will print nice error message. func (ms *ModuleIDs) UnmarshalYAML(n *yaml.Node) error { var ids []ModuleID if err := n.Decode(&ids); err != nil { return nodeToPosErr(n, errors.New("`use` must be a list of module ids")) } *ms = ids return nil } // YamlValue is wrapper around cty.Value to handle YAML unmarshal. type YamlValue struct { v cty.Value // do not use this field directly, use Wrap() and Unwrap() instead } // Unwrap returns wrapped cty.Value. func (y YamlValue) Unwrap() cty.Value { if y.v == cty.NilVal { // we can't use 0-value of cty.Value (NilVal) // instead it should be a proper null(any) value return cty.NullVal(cty.DynamicPseudoType) } return y.v } func (y *YamlValue) Wrap(v cty.Value) { y.v = v } // UnmarshalYAML implements custom YAML unmarshaling. func (y *YamlValue) UnmarshalYAML(n *yaml.Node) error { var err error switch n.Kind { case yaml.ScalarNode: err = y.unmarshalScalar(n) case yaml.MappingNode: err = y.unmarshalObject(n) case yaml.SequenceNode: err = y.unmarshalTuple(n) default: err = nodeToPosErr(n, fmt.Errorf("cannot decode node with unknown kind %d", n.Kind)) } return err } func (y *YamlValue) unmarshalScalar(n *yaml.Node) error { var s interface{} if err := n.Decode(&s); err != nil { return err } ty, err := gocty.ImpliedType(s) if err != nil { return nodeToPosErr(n, err) } v, err := gocty.ToCtyValue(s, ty) if err != nil { return err } if v.Type() == cty.String { if v, err = parseYamlString(v.AsString()); err != nil { return fmt.Errorf("line %d: %w", n.Line, err) } } y.Wrap(v) return nil } func isHCLLiteral(s string) bool { return strings.HasPrefix(s, "((") && strings.HasSuffix(s, "))") } func parseYamlString(s string) (cty.Value, error) { if isHCLLiteral(s) { if e, err := ParseExpression(s[2 : len(s)-2]); err != nil { return cty.NilVal, err } else { return e.AsValue(), nil } } if strings.HasPrefix(s, `\((`) && strings.HasSuffix(s, `))`) { return cty.StringVal(s[1:]), nil // escaped HCL literal } return parseBpLit(s) } func (y *YamlValue) unmarshalObject(n *yaml.Node) error { var my map[string]YamlValue if err := n.Decode(&my); err != nil { return err } mv := map[string]cty.Value{} for k, y := range my { mv[k] = y.Unwrap() } y.Wrap(cty.ObjectVal(mv)) return nil } func (y *YamlValue) unmarshalTuple(n *yaml.Node) error { var ly []YamlValue if err := n.Decode(&ly); err != nil { return err } lv := []cty.Value{} for _, y := range ly { lv = append(lv, y.Unwrap()) } y.Wrap(cty.TupleVal(lv)) return nil } // MarshalYAML implements custom YAML marshaling. func (y YamlValue) MarshalYAML() (interface{}, error) { m, err := cty.Transform(y.Unwrap(), func(p cty.Path, v cty.Value) (cty.Value, error) { if v.IsNull() { return v, nil } if e, is := IsExpressionValue(v); is { s := string(hclwrite.Format(e.Tokenize().Bytes())) return cty.StringVal("((" + s + "))"), nil } if v.Type() == cty.String { // Need to escape back the non-expressions (both HCL and blueprint ones) s := v.AsString() if isHCLLiteral(s) { // yaml: "\((foo))" -unmarshal-> cty: "((foo))" -marshall-> yaml: "\((foo))" // NOTE: don't attempt to escape both HCL and blueprint expressions // they don't get unmarshalled together, terminate here return cty.StringVal(`\` + s), nil } // yaml: "\$(var.foo)" -unmarshal-> cty: "$(var.foo)" -marshall-> yaml: "\$(var.foo)" return cty.StringVal(strings.ReplaceAll(s, `$(`, `\$(`)), nil } return v, nil }) if err != nil { return nil, err } j := ctyJson.SimpleJSONValue{Value: m} b, err := j.MarshalJSON() if err != nil { return nil, fmt.Errorf("failed to marshal JSON: %v", err) } var g interface{} err = json.Unmarshal(b, &g) if err != nil { return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) } return g, nil } // UnmarshalYAML implements custom YAML unmarshaling. func (d *Dict) UnmarshalYAML(n *yaml.Node) error { var vm map[string]YamlValue if err := n.Decode(&vm); err != nil { return err } for k, v := range vm { if d.m == nil { d.m = map[string]cty.Value{} } d.m[k] = v.Unwrap() } return nil } // MarshalYAML implements custom YAML marshaling. func (d Dict) MarshalYAML() (interface{}, error) { m := map[string]interface{}{} for k, v := range d.m { y, err := YamlValue{v}.MarshalYAML() if err != nil { return nil, err } m[k] = y } return m, nil } // yaml.v3 errors are either TypeError - collection of error message or single error message. // Parse error messages to extract short error message and position. func parseYamlV3Error(err error) error { errs := Errors{} switch err := err.(type) { case *yaml.TypeError: for _, s := range err.Errors { errs.Add(parseYamlV3ErrorString(s)) } case PosError: errs.Add(err) default: errs.Add(parseYamlV3ErrorString(err.Error())) } if !errs.Any() { // should never happen errs.Add(parseYamlV3ErrorString(err.Error())) } return errs } // parseYamlV3Error attempts to extract position and nice error message from yaml.v3 error message. // yaml.v3 errors are unstructured, use string parsing to extract information. // If no position can be extracted, returns error without position. // Else returns PosError{Pos{Line: line_number}, error_message}. func parseYamlV3ErrorString(s string) error { match := regexp.MustCompile(`^(yaml: )?(line (\d+): )?((.|\n)*)$`).FindStringSubmatch(s) if match == nil { return errors.New(s) } lns, errMsg := match[3], match[4] ln, _ := strconv.Atoi(lns) // Atoi returns 0 on error, which is fine here return PosError{Pos{Line: ln}, errors.New(errMsg)} }