diff --git a/internal/backend/remote/backend_context.go b/internal/backend/remote/backend_context.go index 5d50d11d432e..b6469e5e0f78 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -111,8 +111,10 @@ func (b *Remote) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Fu } for _, v := range tfeVariables.Items { if v.Category == tfe.CategoryTerraform { - op.Variables[v.Key] = &remoteStoredVariableValue{ - definition: v, + if _, ok := op.Variables[v.Key]; !ok { + op.Variables[v.Key] = &remoteStoredVariableValue{ + definition: v, + } } } } diff --git a/internal/backend/remote/backend_context_test.go b/internal/backend/remote/backend_context_test.go index 819f583ec844..a67bd04c96b5 100644 --- a/internal/backend/remote/backend_context_test.go +++ b/internal/backend/remote/backend_context_test.go @@ -2,6 +2,9 @@ package remote import ( "context" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "reflect" "testing" tfe "github.com/hashicorp/go-tfe" @@ -233,3 +236,234 @@ func TestRemoteContextWithVars(t *testing.T) { }) } } + +func TestRemoteVariablesDoNotOverride(t *testing.T) { + catTerraform := tfe.CategoryTerraform + + varName1 := "key1" + varName2 := "key2" + varName3 := "key3" + + varValue1 := "value1" + varValue2 := "value2" + varValue3 := "value3" + + tests := map[string]struct { + localVariables map[string]backend.UnparsedVariableValue + remoteVariables []*tfe.VariableCreateOptions + expectedVariables terraform.InputValues + }{ + "no local variables": { + map[string]backend.UnparsedVariableValue{}, + []*tfe.VariableCreateOptions{ + { + Key: &varName1, + Value: &varValue1, + Category: &catTerraform, + }, + { + Key: &varName2, + Value: &varValue2, + Category: &catTerraform, + }, + { + Key: &varName3, + Value: &varValue3, + Category: &catTerraform, + }, + }, + terraform.InputValues{ + varName1: &terraform.InputValue{ + Value: cty.StringVal(varValue1), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName2: &terraform.InputValue{ + Value: cty.StringVal(varValue2), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName3: &terraform.InputValue{ + Value: cty.StringVal(varValue3), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + }, + }, + "single conflicting local variable": { + map[string]backend.UnparsedVariableValue{ + varName3: testUnparsedVariableValue(varValue3), + }, + []*tfe.VariableCreateOptions{ + { + Key: &varName1, + Value: &varValue1, + Category: &catTerraform, + }, { + Key: &varName2, + Value: &varValue2, + Category: &catTerraform, + }, { + Key: &varName3, + Value: &varValue3, + Category: &catTerraform, + }, + }, + terraform.InputValues{ + varName1: &terraform.InputValue{ + Value: cty.StringVal(varValue1), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName2: &terraform.InputValue{ + Value: cty.StringVal(varValue2), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName3: &terraform.InputValue{ + Value: cty.StringVal(varValue3), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + }, + }, + "no-conflicting local variable": { + map[string]backend.UnparsedVariableValue{ + varName3: testUnparsedVariableValue(varValue3), + }, + []*tfe.VariableCreateOptions{ + { + Key: &varName1, + Value: &varValue1, + Category: &catTerraform, + }, { + Key: &varName2, + Value: &varValue2, + Category: &catTerraform, + }, + }, + terraform.InputValues{ + varName1: &terraform.InputValue{ + Value: cty.StringVal(varValue1), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName2: &terraform.InputValue{ + Value: cty.StringVal(varValue2), + SourceType: terraform.ValueFromInput, + SourceRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + End: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, + }, + }, + varName3: &terraform.InputValue{ + Value: cty.StringVal(varValue3), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + configDir := "./testdata/variables" + + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + defer configCleanup() + + workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) + if err != nil { + t.Fatal(err) + } + + streams, _ := terminal.StreamsForTesting(t) + view := views.NewStateLocker(arguments.ViewHuman, views.NewView(streams)) + + op := &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + StateLocker: clistate.NewLocker(0, view), + Workspace: backend.DefaultStateName, + Variables: test.localVariables, + } + + for _, v := range test.remoteVariables { + b.client.Variables.Create(context.TODO(), workspaceID, *v) + } + + lr, _, diags := b.LocalRun(op) + + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) + } + // When Context() succeeds, this should fail w/ "workspace already locked" + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err == nil { + t.Fatal("unexpected success locking state after Context") + } + + actual := lr.PlanOpts.SetVariables + expected := test.expectedVariables + + for expectedKey := range expected { + actualValue := actual[expectedKey] + expectedValue := expected[expectedKey] + + if !reflect.DeepEqual(*actualValue, *expectedValue) { + t.Fatalf("unexpected variable '%s'\ngot: %v\nwant: %v", expectedKey, actualValue, expectedValue) + } + } + }) + } +} + +type testUnparsedVariableValue string + +func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(string(v)), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, nil +} diff --git a/internal/backend/remote/testdata/variables/main.tf b/internal/backend/remote/testdata/variables/main.tf new file mode 100644 index 000000000000..9e1a0a40ff6e --- /dev/null +++ b/internal/backend/remote/testdata/variables/main.tf @@ -0,0 +1,8 @@ +variable "key1" { +} + +variable "key2" { +} + +variable "key3" { +}