coder/provisioner/terraform/parse.go

234 lines
6.6 KiB
Go

package terraform
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/provisionersdk/proto"
)
const featureUseManagedVariables = "feature_use_managed_variables"
var terraformWithFeaturesSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "provider",
LabelNames: []string{"type"},
},
},
}
var providerFeaturesConfigSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: featureUseManagedVariables,
},
},
}
// Parse extracts Terraform variables from source-code.
func (s *server) Parse(request *proto.Parse_Request, stream proto.DRPCProvisioner_ParseStream) error {
_, span := s.startTrace(stream.Context(), tracing.FuncName())
defer span.End()
// Load the module and print any parse errors.
module, diags := tfconfig.LoadModule(request.Directory)
if diags.HasErrors() {
return xerrors.Errorf("load module: %s", formatDiagnostics(request.Directory, diags))
}
flags, flagsDiags := loadEnabledFeatures(request.Directory)
if flagsDiags.HasErrors() {
return xerrors.Errorf("load coder provider features: %s", formatDiagnostics(request.Directory, diags))
}
// Sort variables by (filename, line) to make the ordering consistent
variables := make([]*tfconfig.Variable, 0, len(module.Variables))
for _, v := range module.Variables {
variables = append(variables, v)
}
sort.Slice(variables, func(i, j int) bool {
return compareSourcePos(variables[i].Pos, variables[j].Pos)
})
var templateVariables []*proto.TemplateVariable
useManagedVariables := flags != nil && flags[featureUseManagedVariables]
if useManagedVariables {
for _, v := range variables {
mv, err := convertTerraformVariableToManagedVariable(v)
if err != nil {
return xerrors.Errorf("can't convert the Terraform variable to a managed one: %w", err)
}
templateVariables = append(templateVariables, mv)
}
} else if len(variables) > 0 {
return xerrors.Errorf("legacy parameters are not supported anymore, use %q flag to enable managed Terraform variables", featureUseManagedVariables)
}
return stream.Send(&proto.Parse_Response{
Type: &proto.Parse_Response_Complete{
Complete: &proto.Parse_Complete{
TemplateVariables: templateVariables,
},
},
})
}
func loadEnabledFeatures(moduleDir string) (map[string]bool, hcl.Diagnostics) {
flags := map[string]bool{}
var diags hcl.Diagnostics
entries, err := os.ReadDir(moduleDir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read module directory",
Detail: fmt.Sprintf("Module directory %s does not exist or cannot be read.", moduleDir),
})
return flags, diags
}
var found bool
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".tf") {
continue
}
flags, found, diags = parseFeatures(path.Join(moduleDir, entry.Name()))
if found {
break
}
}
return flags, diags
}
func parseFeatures(hclFilepath string) (map[string]bool, bool, hcl.Diagnostics) {
flags := map[string]bool{}
var diags hcl.Diagnostics
_, err := os.Stat(hclFilepath)
if os.IsNotExist(err) {
return flags, false, diags
} else if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Failed to open %q file", hclFilepath),
})
return flags, false, diags
}
parser := hclparse.NewParser()
parsedHCL, diags := parser.ParseHCLFile(hclFilepath)
if diags.HasErrors() {
return flags, false, diags
}
var found bool
content, _ := parsedHCL.Body.Content(terraformWithFeaturesSchema)
for _, block := range content.Blocks {
if block.Type == "provider" && block.Labels[0] == "coder" {
content, _, partialDiags := block.Body.PartialContent(providerFeaturesConfigSchema)
diags = append(diags, partialDiags...)
if attr, defined := content.Attributes[featureUseManagedVariables]; defined {
found = true
var useManagedVariables bool
partialDiags := gohcl.DecodeExpression(attr.Expr, nil, &useManagedVariables)
diags = append(diags, partialDiags...)
flags[featureUseManagedVariables] = useManagedVariables
}
}
}
return flags, found, diags
}
// Converts a Terraform variable to a managed variable.
func convertTerraformVariableToManagedVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
var defaultData string
if variable.Default != nil {
var valid bool
defaultData, valid = variable.Default.(string)
if !valid {
defaultDataRaw, err := json.Marshal(variable.Default)
if err != nil {
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
}
defaultData = string(defaultDataRaw)
}
}
return &proto.TemplateVariable{
Name: variable.Name,
Description: variable.Description,
Type: variable.Type,
DefaultValue: defaultData,
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
Required: variable.Default == nil,
Sensitive: variable.Sensitive,
}, nil
}
// formatDiagnostics returns a nicely formatted string containing all of the
// error details within the tfconfig.Diagnostics. We need to use this because
// the default format doesn't provide much useful information.
func formatDiagnostics(baseDir string, diags tfconfig.Diagnostics) string {
var msgs strings.Builder
for _, d := range diags {
// Convert severity.
severity := "UNKNOWN SEVERITY"
switch {
case d.Severity == tfconfig.DiagError:
severity = "ERROR"
case d.Severity == tfconfig.DiagWarning:
severity = "WARN"
}
// Determine filepath and line
location := "unknown location"
if d.Pos != nil {
filename, err := filepath.Rel(baseDir, d.Pos.Filename)
if err != nil {
filename = d.Pos.Filename
}
location = fmt.Sprintf("%s:%d", filename, d.Pos.Line)
}
_, _ = msgs.WriteString(fmt.Sprintf("\n%s: %s (%s)\n", severity, d.Summary, location))
// Wrap the details to 80 characters and indent them.
if d.Detail != "" {
wrapped := wordwrap.WrapString(d.Detail, 78)
for _, line := range strings.Split(wrapped, "\n") {
_, _ = msgs.WriteString(fmt.Sprintf("> %s\n", line))
}
}
}
spacer := " "
if len(diags) > 1 {
spacer = "\n\n"
}
return spacer + strings.TrimSpace(msgs.String())
}
func compareSourcePos(x, y tfconfig.SourcePos) bool {
if x.Filename != y.Filename {
return x.Filename < y.Filename
}
return x.Line < y.Line
}