379 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			379 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
package hcllang
 | 
						|
 | 
						|
import (
 | 
						|
	nativejson "encoding/json"
 | 
						|
	"fmt"
 | 
						|
	"slices"
 | 
						|
	"strings"
 | 
						|
 | 
						|
	"dario.cat/mergo"
 | 
						|
	"github.com/hashicorp/hcl/v2"
 | 
						|
	"github.com/hashicorp/hcl/v2/hclparse"
 | 
						|
	"github.com/hashicorp/hcl/v2/hclsyntax"
 | 
						|
	"github.com/variantdev/dag/pkg/dag"
 | 
						|
	"github.com/zclconf/go-cty/cty"
 | 
						|
	"github.com/zclconf/go-cty/cty/json"
 | 
						|
	"go.uber.org/zap"
 | 
						|
 | 
						|
	"github.com/helmfile/helmfile/pkg/filesystem"
 | 
						|
)
 | 
						|
 | 
						|
const (
 | 
						|
	badIdentifierDetail   = "A name must start with a letter or underscore and may contain only letters, digits, underscores, and dashes."
 | 
						|
	ValuesBlockIdentifier = "values"
 | 
						|
	LocalsBlockIdentifier = "locals"
 | 
						|
	valuesAccessorPrefix  = "hv"
 | 
						|
	localsAccessorPrefix  = "local"
 | 
						|
)
 | 
						|
 | 
						|
// HelmfileHCLValue represents a single entry from a "values" or "locals" block file.
 | 
						|
// The blocks itself is not represented, because it serves only to
 | 
						|
// provide context for us to interpret its contents.
 | 
						|
type HelmfileHCLValue struct {
 | 
						|
	Name  string
 | 
						|
	Expr  hcl.Expression
 | 
						|
	Range hcl.Range
 | 
						|
}
 | 
						|
 | 
						|
type HCLLoader struct {
 | 
						|
	hclFilesPath []string
 | 
						|
	fs           *filesystem.FileSystem
 | 
						|
	logger       *zap.SugaredLogger
 | 
						|
}
 | 
						|
 | 
						|
func NewHCLLoader(fs *filesystem.FileSystem, logger *zap.SugaredLogger) *HCLLoader {
 | 
						|
	return &HCLLoader{
 | 
						|
		fs:     fs,
 | 
						|
		logger: logger,
 | 
						|
	}
 | 
						|
}
 | 
						|
func (hl *HCLLoader) AddFile(file string) {
 | 
						|
	hl.hclFilesPath = append(hl.hclFilesPath, file)
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) AddFiles(files []string) {
 | 
						|
	hl.hclFilesPath = append(hl.hclFilesPath, files...)
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) Length() int {
 | 
						|
	return len(hl.hclFilesPath)
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) HCLRender() (map[string]any, error) {
 | 
						|
	if hl.Length() == 0 {
 | 
						|
		return nil, fmt.Errorf("nothing to render")
 | 
						|
	}
 | 
						|
 | 
						|
	HelmfileHCLValues, locals, diags := hl.readHCLs()
 | 
						|
	if len(diags) > 0 {
 | 
						|
		return nil, diags.Errs()[0]
 | 
						|
	}
 | 
						|
 | 
						|
	// Decode all locals from all files first
 | 
						|
	// in order for them to be usable in values blocks
 | 
						|
	localsCty := map[string]map[string]cty.Value{}
 | 
						|
	for k, local := range locals {
 | 
						|
		dagPlan, err := hl.createDAGGraph(local, LocalsBlockIdentifier)
 | 
						|
		if err != nil {
 | 
						|
			return nil, err
 | 
						|
		}
 | 
						|
		localFileCty, err := hl.decodeGraph(dagPlan, LocalsBlockIdentifier, locals[k], nil)
 | 
						|
		if err != nil {
 | 
						|
			return nil, err
 | 
						|
		}
 | 
						|
		localsCty[k] = make(map[string]cty.Value)
 | 
						|
		localsCty[k][localsAccessorPrefix] = localFileCty[localsAccessorPrefix]
 | 
						|
	}
 | 
						|
 | 
						|
	// Decode Values
 | 
						|
	dagHelmfileValuePlan, err := hl.createDAGGraph(HelmfileHCLValues, ValuesBlockIdentifier)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
	helmfileVarCty, err := hl.decodeGraph(dagHelmfileValuePlan, ValuesBlockIdentifier, HelmfileHCLValues, localsCty)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
	nativeGovals, err := hl.convertToGo(helmfileVarCty)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
	return nativeGovals, nil
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) createDAGGraph(HelmfileHCLValues map[string]*HelmfileHCLValue, blockType string) (*dag.Topology, error) {
 | 
						|
	dagGraph := dag.New()
 | 
						|
 | 
						|
	for _, hv := range HelmfileHCLValues {
 | 
						|
		var traversals []string
 | 
						|
		for _, tr := range hv.Expr.Variables() {
 | 
						|
			attr, diags := hl.parseSingleAttrRef(tr, blockType)
 | 
						|
			if diags != nil {
 | 
						|
				return nil, fmt.Errorf("%s", diags.Errs()[0])
 | 
						|
			}
 | 
						|
			if attr != "" && !slices.Contains(traversals, attr) {
 | 
						|
				traversals = append(traversals, attr)
 | 
						|
			}
 | 
						|
		}
 | 
						|
		hl.logger.Debugf("Adding Dependency : %s  => [%s]", hv.Name, strings.Join(traversals, ", "))
 | 
						|
		dagGraph.Add(hv.Name, dag.Dependencies(traversals))
 | 
						|
	}
 | 
						|
 | 
						|
	//Generate Dag Plan which will provide the order from which to interpolate vars
 | 
						|
	plan, err := dagGraph.Plan(dag.SortOptions{
 | 
						|
		WithDependencies: true,
 | 
						|
	})
 | 
						|
	if err == nil {
 | 
						|
		return &plan, nil
 | 
						|
	}
 | 
						|
 | 
						|
	if ude, ok := err.(*dag.UndefinedDependencyError); ok {
 | 
						|
		var quotedVariableNames []string
 | 
						|
		for _, d := range ude.Dependents {
 | 
						|
			quotedVariableNames = append(quotedVariableNames, fmt.Sprintf("%q", d))
 | 
						|
		}
 | 
						|
		return nil, fmt.Errorf("variables %s depend(s) on undefined vars %q", strings.Join(quotedVariableNames, ", "), ude.UndefinedNode)
 | 
						|
	} else {
 | 
						|
		return nil, fmt.Errorf("error while building the DAG variable graph : %s", err.Error())
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) decodeGraph(dagTopology *dag.Topology, blocktype string, vars map[string]*HelmfileHCLValue, additionalLocalContext map[string]map[string]cty.Value) (map[string]cty.Value, error) {
 | 
						|
	values := map[string]cty.Value{}
 | 
						|
	helmfileHCLValuesValues := map[string]cty.Value{}
 | 
						|
	var diags hcl.Diagnostics
 | 
						|
	hclFunctions, err := HCLFunctions(nil)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
	for groupIndex := 0; groupIndex < len(*dagTopology); groupIndex++ {
 | 
						|
		dagNodesInGroup := (*dagTopology)[groupIndex]
 | 
						|
 | 
						|
		for _, node := range dagNodesInGroup {
 | 
						|
			v := vars[node.String()]
 | 
						|
			if blocktype != LocalsBlockIdentifier && additionalLocalContext[v.Range.Filename] != nil {
 | 
						|
				values[localsAccessorPrefix] = additionalLocalContext[v.Range.Filename][localsAccessorPrefix]
 | 
						|
			}
 | 
						|
			ctx := &hcl.EvalContext{
 | 
						|
				Variables: values,
 | 
						|
				Functions: hclFunctions,
 | 
						|
			}
 | 
						|
			// Decode Value
 | 
						|
			helmfileHCLValuesValues[node.String()], diags = v.Expr.Value(ctx)
 | 
						|
			if len(diags) > 0 {
 | 
						|
				return nil, fmt.Errorf("error when trying to evaluate variable %s : %s", v.Name, diags.Errs()[0])
 | 
						|
			}
 | 
						|
			switch blocktype {
 | 
						|
			case ValuesBlockIdentifier:
 | 
						|
				// Update the eval context for the next value evaluation iteration
 | 
						|
				values[valuesAccessorPrefix] = cty.ObjectVal(helmfileHCLValuesValues)
 | 
						|
				// Set back local to nil to avoid an unexpected behavior when the next iteration is in another file
 | 
						|
				values[localsAccessorPrefix] = cty.NilVal
 | 
						|
			case LocalsBlockIdentifier:
 | 
						|
				values[localsAccessorPrefix] = cty.ObjectVal(helmfileHCLValuesValues)
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return values, nil
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) readHCLs() (map[string]*HelmfileHCLValue, map[string]map[string]*HelmfileHCLValue, hcl.Diagnostics) {
 | 
						|
	var variables map[string]*HelmfileHCLValue
 | 
						|
	var local map[string]*HelmfileHCLValue
 | 
						|
	locals := map[string]map[string]*HelmfileHCLValue{}
 | 
						|
	var diags hcl.Diagnostics
 | 
						|
	for _, file := range hl.hclFilesPath {
 | 
						|
		variables, local, diags = hl.readHCL(variables, file)
 | 
						|
		if diags != nil {
 | 
						|
			return nil, nil, diags
 | 
						|
		}
 | 
						|
		locals[file] = make(map[string]*HelmfileHCLValue)
 | 
						|
		locals[file] = local
 | 
						|
	}
 | 
						|
	return variables, locals, nil
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) readHCL(hvars map[string]*HelmfileHCLValue, file string) (map[string]*HelmfileHCLValue, map[string]*HelmfileHCLValue, hcl.Diagnostics) {
 | 
						|
	src, err := hl.fs.ReadFile(file)
 | 
						|
	if err != nil {
 | 
						|
		return nil, nil, hcl.Diagnostics{
 | 
						|
			{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  fmt.Sprintf("%s", err),
 | 
						|
				Detail:   "could not read file",
 | 
						|
				Subject:  &hcl.Range{},
 | 
						|
			},
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse file as HCL
 | 
						|
	p := hclparse.NewParser()
 | 
						|
	hclFile, diags := p.ParseHCL(src, file)
 | 
						|
	if hclFile == nil || hclFile.Body == nil || diags != nil {
 | 
						|
		return nil, nil, diags
 | 
						|
	}
 | 
						|
 | 
						|
	HelmfileHCLValuesSchema := &hcl.BodySchema{
 | 
						|
		Blocks: []hcl.BlockHeaderSchema{
 | 
						|
			{
 | 
						|
				Type: ValuesBlockIdentifier,
 | 
						|
			},
 | 
						|
			{
 | 
						|
				Type: LocalsBlockIdentifier,
 | 
						|
			},
 | 
						|
		},
 | 
						|
	}
 | 
						|
	// make sure content has a struct with helmfile_vars Schema defined
 | 
						|
	content, diags := hclFile.Body.Content(HelmfileHCLValuesSchema)
 | 
						|
	if diags != nil {
 | 
						|
		return nil, nil, diags
 | 
						|
	}
 | 
						|
 | 
						|
	var helmfileLocalsVars map[string]*HelmfileHCLValue
 | 
						|
	// Decode blocks to return HelmfileHCLValue object => (each var with expr + Name )
 | 
						|
 | 
						|
	if len(content.Blocks.OfType(LocalsBlockIdentifier)) > 1 {
 | 
						|
		return nil, nil, hcl.Diagnostics{
 | 
						|
			&hcl.Diagnostic{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  "A file can only support exactly 1 `locals` block",
 | 
						|
				Subject:  &content.Blocks[0].DefRange,
 | 
						|
			}}
 | 
						|
	}
 | 
						|
	for _, block := range content.Blocks {
 | 
						|
		var helmfileBlockVars map[string]*HelmfileHCLValue
 | 
						|
		if block.Type == ValuesBlockIdentifier {
 | 
						|
			helmfileBlockVars, diags = hl.decodeHelmfileHCLValuesBlock(block)
 | 
						|
			if diags != nil {
 | 
						|
				return nil, nil, diags
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if block.Type == LocalsBlockIdentifier {
 | 
						|
			helmfileLocalsVars, diags = hl.decodeHelmfileHCLValuesBlock(block)
 | 
						|
			if diags != nil {
 | 
						|
				return nil, nil, diags
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		// make sure vars are unique across blocks
 | 
						|
		for k := range helmfileBlockVars {
 | 
						|
			if hvars[k] != nil {
 | 
						|
				var diags hcl.Diagnostics
 | 
						|
				diags = append(diags, &hcl.Diagnostic{
 | 
						|
					Severity: hcl.DiagError,
 | 
						|
					Summary:  "Duplicate helmfile_vars definition",
 | 
						|
					Detail: fmt.Sprintf("The helmfile_var %q was already defined at %s:%d",
 | 
						|
						k, hvars[k].Range.Filename, hvars[k].Range.Start.Line),
 | 
						|
					Subject: &helmfileBlockVars[k].Range,
 | 
						|
				})
 | 
						|
				return nil, nil, diags
 | 
						|
			}
 | 
						|
		}
 | 
						|
		err = mergo.Merge(&hvars, &helmfileBlockVars)
 | 
						|
		if err != nil {
 | 
						|
			var diags hcl.Diagnostics
 | 
						|
			diags = append(diags, &hcl.Diagnostic{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  "Merge failed",
 | 
						|
				Detail:   err.Error(),
 | 
						|
				Subject:  nil,
 | 
						|
			})
 | 
						|
			return nil, nil, diags
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return hvars, helmfileLocalsVars, nil
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) decodeHelmfileHCLValuesBlock(block *hcl.Block) (map[string]*HelmfileHCLValue, hcl.Diagnostics) {
 | 
						|
	attrs, diags := block.Body.JustAttributes()
 | 
						|
	if len(attrs) == 0 || diags != nil {
 | 
						|
		return nil, diags
 | 
						|
	}
 | 
						|
 | 
						|
	hfVars := map[string]*HelmfileHCLValue{}
 | 
						|
	for name, attr := range attrs {
 | 
						|
		if !hclsyntax.ValidIdentifier(name) {
 | 
						|
			diags = append(diags, &hcl.Diagnostic{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  "Invalid helmfile_vars variable name",
 | 
						|
				Detail:   badIdentifierDetail,
 | 
						|
				Subject:  &attr.NameRange,
 | 
						|
			})
 | 
						|
		}
 | 
						|
 | 
						|
		hfVars[name] = &HelmfileHCLValue{
 | 
						|
			Name:  name,
 | 
						|
			Expr:  attr.Expr,
 | 
						|
			Range: attr.Range,
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return hfVars, diags
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) parseSingleAttrRef(traversal hcl.Traversal, blockType string) (string, hcl.Diagnostics) {
 | 
						|
	if len(traversal) == 0 {
 | 
						|
		return "", hcl.Diagnostics{
 | 
						|
			&hcl.Diagnostic{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  "An empty traversal can't be parsed",
 | 
						|
			},
 | 
						|
		}
 | 
						|
	}
 | 
						|
	root := traversal.RootName()
 | 
						|
	// In `values` blocks, Locals are always precomputed, so they don't need to be in the graph
 | 
						|
	if root == localsAccessorPrefix && blockType != LocalsBlockIdentifier {
 | 
						|
		return "", nil
 | 
						|
	}
 | 
						|
	rootRange := traversal[0].SourceRange()
 | 
						|
 | 
						|
	if len(traversal) < 2 {
 | 
						|
		return "", hcl.Diagnostics{
 | 
						|
			&hcl.Diagnostic{
 | 
						|
				Severity: hcl.DiagError,
 | 
						|
				Summary:  "Invalid reference",
 | 
						|
				Detail:   fmt.Sprintf("The %q object cannot be accessed directly. Instead, access it from one of its root.", root),
 | 
						|
				Subject:  &rootRange,
 | 
						|
			},
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if attrTrav, ok := traversal[1].(hcl.TraverseAttr); ok {
 | 
						|
		return attrTrav.Name, nil
 | 
						|
	}
 | 
						|
 | 
						|
	return "", hcl.Diagnostics{
 | 
						|
		&hcl.Diagnostic{
 | 
						|
			Severity: hcl.DiagError,
 | 
						|
			Summary:  "Invalid reference",
 | 
						|
			Detail:   fmt.Sprintf("The %q object does not support this operation.", root),
 | 
						|
			Subject:  traversal[1].SourceRange().Ptr(),
 | 
						|
		},
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (hl *HCLLoader) convertToGo(src map[string]cty.Value) (map[string]any, error) {
 | 
						|
	// Ugly workaround on value conversion
 | 
						|
	// CTY conversion to go natives requires much processing and complexity
 | 
						|
	// All of this, in our context, can go away because of the CTY capability to dump a cty.Value as Json
 | 
						|
	// The Json document outputs 2 keys : "type" and "value" which describe the mapping between the two
 | 
						|
	// We only care about the value
 | 
						|
	b, err := json.Marshal(src[valuesAccessorPrefix], cty.DynamicPseudoType)
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("could not marshal cty value : %s", err.Error())
 | 
						|
	}
 | 
						|
 | 
						|
	var jsonunm map[string]any
 | 
						|
	err = nativejson.Unmarshal(b, &jsonunm)
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("could not unmarshall json : %s", err.Error())
 | 
						|
	}
 | 
						|
 | 
						|
	if result, ok := jsonunm["value"].(map[string]any); ok {
 | 
						|
		return result, nil
 | 
						|
	} else {
 | 
						|
		return nil, fmt.Errorf("could extract a map object from json \"value\" key")
 | 
						|
	}
 | 
						|
}
 |