fix(run variables): support all variable types (map, list, bool, number, null, string)

All run variables remain encoded as strings in the API but will now be expressed as an HCL value to be evaluated correctly by the remote terraform. Previously, only strings were supported.

Examples:
string: `"quoted literal"` (strings must be quoted)
map:  `{ foo = "bar" }`
list: `["foo", "bar"]`
bool: `true`
null: `null`
number: `0.0001`

This requires the API to anticipate that all run variables will be HCL values
This commit is contained in:
Brandon Croft 2021-11-09 17:02:59 -07:00
parent 9441666d9a
commit 1f01ba4dbc
No known key found for this signature in database
GPG Key ID: B01E32423322EB9D
3 changed files with 85 additions and 31 deletions

View File

@ -477,7 +477,7 @@ func TestCloud_planWithRequiredVariables(t *testing.T) {
defer configCleanup() defer configCleanup()
defer done(t) defer done(t)
op.Variables = testVariables(terraform.ValueFromCLIArg, "foo") // "bar" variable value missing op.Variables = testVariables(terraform.ValueFromCLIArg, "foo") // "bar" variable defined in config is missing
op.Workspace = testBackendSingleWorkspaceName op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op) run, err := b.Operation(context.Background(), op)
@ -487,7 +487,7 @@ func TestCloud_planWithRequiredVariables(t *testing.T) {
<-run.Done() <-run.Done()
// The usual error of a required variable being missing is deferred and the operation // The usual error of a required variable being missing is deferred and the operation
// is successful // is successful.
if run.Result != backend.OperationSuccess { if run.Result != backend.OperationSuccess {
t.Fatal("expected plan operation to succeed") t.Fatal("expected plan operation to succeed")
} }

View File

@ -1,14 +1,11 @@
package cloud package cloud
import ( import (
"encoding/json" "github.com/hashicorp/hcl/v2/hclwrite"
"fmt"
"github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags" "github.com/hashicorp/terraform/internal/tfdiags"
ctyjson "github.com/zclconf/go-cty/cty/json"
) )
func allowedSourceType(source terraform.ValueSourceType) bool { func allowedSourceType(source terraform.ValueSourceType) bool {
@ -17,7 +14,7 @@ func allowedSourceType(source terraform.ValueSourceType) bool {
// ParseCloudRunVariables accepts a mapping of unparsed values and a mapping of variable // ParseCloudRunVariables accepts a mapping of unparsed values and a mapping of variable
// declarations and returns a name/value variable map appropriate for an API run context, // declarations and returns a name/value variable map appropriate for an API run context,
// that is, containing declared string variables only sourced from non-file inputs like CLI args // that is, containing variables only sourced from non-file inputs like CLI args
// and environment variables. However, all variable parsing diagnostics are returned // and environment variables. However, all variable parsing diagnostics are returned
// in order to allow callers to short circuit cloud runs that contain variable // in order to allow callers to short circuit cloud runs that contain variable
// declaration or parsing errors. The only exception is that missing required values are not // declaration or parsing errors. The only exception is that missing required values are not
@ -36,16 +33,9 @@ func ParseCloudRunVariables(vv map[string]backend.UnparsedVariableValue, decls m
continue continue
} }
valueData, err := ctyjson.Marshal(v.Value, v.Value.Type()) // RunVariables are always expressed as HCL strings
if err != nil { tokens := hclwrite.TokensForValue(v.Value)
return nil, diags.Append(fmt.Errorf("error marshaling input variable value as json: %w", err)) ret[name] = string(tokens.Bytes())
}
var variableValue string
if err = json.Unmarshal(valueData, &variableValue); err != nil {
// This should never happen since cty marshaled the value to begin with without error
return nil, diags.Append(fmt.Errorf("error unmarshaling run variable: %w", err))
}
ret[name] = variableValue
} }
return ret, diags return ret, diags

View File

@ -15,11 +15,16 @@ import (
func TestParseCloudRunVariables(t *testing.T) { func TestParseCloudRunVariables(t *testing.T) {
t.Run("populates variables from allowed sources", func(t *testing.T) { t.Run("populates variables from allowed sources", func(t *testing.T) {
vv := map[string]backend.UnparsedVariableValue{ vv := map[string]backend.UnparsedVariableValue{
"undeclared": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "0"}, "undeclared": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: cty.StringVal("0")},
"declaredFromConfig": testUnparsedVariableValue{source: terraform.ValueFromConfig, value: "1"}, "declaredFromConfig": testUnparsedVariableValue{source: terraform.ValueFromConfig, value: cty.StringVal("1")},
"declaredFromNamedFile": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: "2"}, "declaredFromNamedFileMapString": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.MapVal(map[string]cty.Value{"foo": cty.StringVal("bar")})},
"declaredFromCLIArg": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "3"}, "declaredFromNamedFileBool": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.BoolVal(true)},
"declaredFromEnvVar": testUnparsedVariableValue{source: terraform.ValueFromEnvVar, value: "4"}, "declaredFromNamedFileNumber": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.NumberIntVal(2)},
"declaredFromNamedFileListString": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.ListVal([]cty.Value{cty.StringVal("2a"), cty.StringVal("2b")})},
"declaredFromNamedFileNull": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.NullVal(cty.String)},
"declaredFromNamedMapComplex": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: cty.MapVal(map[string]cty.Value{"foo": cty.ObjectVal(map[string]cty.Value{"qux": cty.ListVal([]cty.Value{cty.BoolVal(true), cty.BoolVal(false)})})})},
"declaredFromCLIArg": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: cty.StringVal("3")},
"declaredFromEnvVar": testUnparsedVariableValue{source: terraform.ValueFromEnvVar, value: cty.StringVal("4")},
} }
decls := map[string]*configs.Variable{ decls := map[string]*configs.Variable{
@ -34,11 +39,66 @@ func TestParseCloudRunVariables(t *testing.T) {
End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
}, },
}, },
"declaredFromNamedFile": { "declaredFromNamedFileMapString": {
Name: "declaredFromNamedFile", Name: "declaredFromNamedFileMapString",
Type: cty.Map(cty.String),
ConstraintType: cty.Map(cty.String),
ParsingMode: configs.VariableParseHCL,
DeclRange: hcl.Range{
Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
},
},
"declaredFromNamedFileBool": {
Name: "declaredFromNamedFileBool",
Type: cty.Bool,
ConstraintType: cty.Bool,
ParsingMode: configs.VariableParseLiteral,
DeclRange: hcl.Range{
Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
},
},
"declaredFromNamedFileNumber": {
Name: "declaredFromNamedFileNumber",
Type: cty.Number,
ConstraintType: cty.Number,
ParsingMode: configs.VariableParseLiteral,
DeclRange: hcl.Range{
Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
},
},
"declaredFromNamedFileListString": {
Name: "declaredFromNamedFileListString",
Type: cty.List(cty.String),
ConstraintType: cty.List(cty.String),
ParsingMode: configs.VariableParseHCL,
DeclRange: hcl.Range{
Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
},
},
"declaredFromNamedFileNull": {
Name: "declaredFromNamedFileNull",
Type: cty.String, Type: cty.String,
ConstraintType: cty.String, ConstraintType: cty.String,
ParsingMode: configs.VariableParseLiteral, ParsingMode: configs.VariableParseHCL,
DeclRange: hcl.Range{
Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
End: hcl.Pos{Line: 2, Column: 1, Byte: 0},
},
},
"declaredFromNamedMapComplex": {
Name: "declaredFromNamedMapComplex",
Type: cty.DynamicPseudoType,
ConstraintType: cty.DynamicPseudoType,
ParsingMode: configs.VariableParseHCL,
DeclRange: hcl.Range{ DeclRange: hcl.Range{
Filename: "fake.tf", Filename: "fake.tf",
Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, Start: hcl.Pos{Line: 2, Column: 1, Byte: 0},
@ -81,10 +141,14 @@ func TestParseCloudRunVariables(t *testing.T) {
}, },
} }
wantVals := make(map[string]string) wantVals := make(map[string]string)
wantVals["declaredFromNamedFileBool"] = "true"
wantVals["declaredFromNamedFile"] = "2" wantVals["declaredFromNamedFileNumber"] = "2"
wantVals["declaredFromCLIArg"] = "3" wantVals["declaredFromNamedFileListString"] = `["2a", "2b"]`
wantVals["declaredFromEnvVar"] = "4" wantVals["declaredFromNamedFileNull"] = "null"
wantVals["declaredFromNamedFileMapString"] = "{\n foo = \"bar\"\n}"
wantVals["declaredFromNamedMapComplex"] = "{\n foo = {\n qux = [true, false]\n }\n}"
wantVals["declaredFromCLIArg"] = `"3"`
wantVals["declaredFromEnvVar"] = `"4"`
gotVals, diags := ParseCloudRunVariables(vv, decls) gotVals, diags := ParseCloudRunVariables(vv, decls)
if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" {
@ -103,12 +167,12 @@ func TestParseCloudRunVariables(t *testing.T) {
type testUnparsedVariableValue struct { type testUnparsedVariableValue struct {
source terraform.ValueSourceType source terraform.ValueSourceType
value string value cty.Value
} }
func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
return &terraform.InputValue{ return &terraform.InputValue{
Value: cty.StringVal(v.value), Value: v.value,
SourceType: v.source, SourceType: v.source,
SourceRange: tfdiags.SourceRange{ SourceRange: tfdiags.SourceRange{
Filename: "fake.tfvars", Filename: "fake.tfvars",