Skip to content
13 changes: 13 additions & 0 deletions cli/templatecreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ func (r *RootCmd) templateCreate() *clibase.Cmd {

message := uploadFlags.templateMessage(inv)

var varsFiles []string
if !uploadFlags.stdin() {
varsFiles, err = DiscoverVarsFiles(uploadFlags.directory)
if err != nil {
return err
}
Comment on lines +112 to +115
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could imagine a case where someone had tfvars files lying around unused before, worked around the issue, and left them there. Now we're going to auto-discover them. I'm not sure what this will break.
Should at the very least add an info message about the auto-discovered vars files?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I changed the code to print a message alerting about the presence of tfvars 👍


if len(varsFiles) > 0 {
_, _ = fmt.Fprintln(inv.Stdout, "Auto-discovered Terraform tfvars files. Make sure to review and clean up any unused files.")
}
}

// Confirm upload of the directory.
resp, err := uploadFlags.upload(inv, client)
if err != nil {
Expand All @@ -119,6 +131,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd {
}

userVariableValues, err := ParseUserVariableValues(
varsFiles,
variablesFile,
commandLineVariables)
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions cli/templatepush.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ func (r *RootCmd) templatePush() *clibase.Cmd {

message := uploadFlags.templateMessage(inv)

var varsFiles []string
if !uploadFlags.stdin() {
varsFiles, err = DiscoverVarsFiles(uploadFlags.directory)
if err != nil {
return err
}

if len(varsFiles) > 0 {
_, _ = fmt.Fprintln(inv.Stdout, "Auto-discovered Terraform tfvars files. Make sure to review and clean up any unused files.")
}
}

resp, err := uploadFlags.upload(inv, client)
if err != nil {
return err
Expand All @@ -89,6 +101,7 @@ func (r *RootCmd) templatePush() *clibase.Cmd {
}

userVariableValues, err := ParseUserVariableValues(
varsFiles,
variablesFile,
commandLineVariables)
if err != nil {
Expand Down
180 changes: 178 additions & 2 deletions cli/templatevariables.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,65 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"golang.org/x/xerrors"
"gopkg.in/yaml.v3"

"github.com/hashicorp/hcl/v2/hclparse"
"github.com/zclconf/go-cty/cty"

"github.com/coder/coder/v2/codersdk"
)

func ParseUserVariableValues(variablesFile string, commandLineVariables []string) ([]codersdk.VariableValue, error) {
/**
* DiscoverVarsFiles function loads vars files in a predefined order:
* 1. terraform.tfvars
* 2. terraform.tfvars.json
* 3. *.auto.tfvars
* 4. *.auto.tfvars.json
*/
func DiscoverVarsFiles(workDir string) ([]string, error) {
var found []string

fi, err := os.Stat(filepath.Join(workDir, "terraform.tfvars"))
if err == nil {
found = append(found, filepath.Join(workDir, fi.Name()))
} else if !os.IsNotExist(err) {
return nil, err
}

fi, err = os.Stat(filepath.Join(workDir, "terraform.tfvars.json"))
if err == nil {
found = append(found, filepath.Join(workDir, fi.Name()))
} else if !os.IsNotExist(err) {
return nil, err
}

dirEntries, err := os.ReadDir(workDir)
if err != nil {
return nil, err
}

for _, dirEntry := range dirEntries {
if strings.HasSuffix(dirEntry.Name(), ".auto.tfvars") || strings.HasSuffix(dirEntry.Name(), ".auto.tfvars.json") {
found = append(found, filepath.Join(workDir, dirEntry.Name()))
}
}
return found, nil
}

func ParseUserVariableValues(varsFiles []string, variablesFile string, commandLineVariables []string) ([]codersdk.VariableValue, error) {
fromVars, err := parseVariableValuesFromVarsFiles(varsFiles)
if err != nil {
return nil, err
}

fromFile, err := parseVariableValuesFromFile(variablesFile)
if err != nil {
return nil, err
Expand All @@ -21,7 +70,131 @@ func ParseUserVariableValues(variablesFile string, commandLineVariables []string
return nil, err
}

return combineVariableValues(fromFile, fromCommandLine), nil
return combineVariableValues(fromVars, fromFile, fromCommandLine), nil
}

func parseVariableValuesFromVarsFiles(varsFiles []string) ([]codersdk.VariableValue, error) {
var parsed []codersdk.VariableValue
for _, varsFile := range varsFiles {
content, err := os.ReadFile(varsFile)
if err != nil {
return nil, err
}

var t []codersdk.VariableValue
ext := filepath.Ext(varsFile)
switch ext {
case ".tfvars":
t, err = parseVariableValuesFromHCL(content)
if err != nil {
return nil, xerrors.Errorf("unable to parse HCL content: %w", err)
}
case ".json":
t, err = parseVariableValuesFromJSON(content)
if err != nil {
return nil, xerrors.Errorf("unable to parse JSON content: %w", err)
}
default:
return nil, xerrors.Errorf("unexpected tfvars format: %s", ext)
}

parsed = append(parsed, t...)
}
return parsed, nil
}

func parseVariableValuesFromHCL(content []byte) ([]codersdk.VariableValue, error) {
parser := hclparse.NewParser()
hclFile, diags := parser.ParseHCL(content, "file.hcl")
if diags.HasErrors() {
return nil, diags
}

attrs, diags := hclFile.Body.JustAttributes()
if diags.HasErrors() {
return nil, diags
}

stringData := map[string]string{}
for _, attribute := range attrs {
ctyValue, diags := attribute.Expr.Value(nil)
if diags.HasErrors() {
return nil, diags
}

ctyType := ctyValue.Type()
if ctyType.Equals(cty.String) {
stringData[attribute.Name] = ctyValue.AsString()
} else if ctyType.Equals(cty.Number) {
stringData[attribute.Name] = ctyValue.AsBigFloat().String()
} else if ctyType.IsTupleType() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I immediately saw this, thought 🤔 "y no switch", and then saw how annoying this is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unfortunately the API isn't too friendly :)

// In case of tuples, Coder only supports the list(string) type.
var items []string
var err error
_ = ctyValue.ForEachElement(func(key, val cty.Value) (stop bool) {
if !val.Type().Equals(cty.String) {
err = xerrors.Errorf("unsupported tuple item type: %s ", val.GoString())
return true
}
items = append(items, val.AsString())
return false
})
if err != nil {
return nil, err
}

m, err := json.Marshal(items)
if err != nil {
return nil, err
}
stringData[attribute.Name] = string(m)
} else {
return nil, xerrors.Errorf("unsupported value type (name: %s): %s", attribute.Name, ctyType.GoString())
}
}

return convertMapIntoVariableValues(stringData), nil
}

// parseVariableValuesFromJSON converts the .tfvars.json content into template variables.
// The function visits only root-level properties as template variables do not support nested
// structures.
func parseVariableValuesFromJSON(content []byte) ([]codersdk.VariableValue, error) {
var data map[string]interface{}
err := json.Unmarshal(content, &data)
if err != nil {
return nil, err
}

stringData := map[string]string{}
for key, value := range data {
switch value.(type) {
case string, int, bool:
stringData[key] = fmt.Sprintf("%v", value)
default:
m, err := json.Marshal(value)
if err != nil {
return nil, err
}
stringData[key] = string(m)
}
}

return convertMapIntoVariableValues(stringData), nil
}

func convertMapIntoVariableValues(m map[string]string) []codersdk.VariableValue {
var parsed []codersdk.VariableValue
for key, value := range m {
parsed = append(parsed, codersdk.VariableValue{
Name: key,
Value: value,
})
}
sort.Slice(parsed, func(i, j int) bool {
return parsed[i].Name < parsed[j].Name
})
return parsed
}

func parseVariableValuesFromFile(variablesFile string) ([]codersdk.VariableValue, error) {
Expand Down Expand Up @@ -94,5 +267,8 @@ func combineVariableValues(valuesSets ...[]codersdk.VariableValue) []codersdk.Va
result = append(result, codersdk.VariableValue{Name: name, Value: value})
}

sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result
}
Loading