diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 29aa55d3a4d0..5c1d4f849bda 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -11,10 +11,12 @@ import ( "time" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/views" + viewsjson "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" @@ -232,9 +234,24 @@ func (b *Local) opApply( // Set up our hook for continuous state updates stateHook.StateMgr = opState - var applyOpts *terraform.ApplyOpts - if len(op.Variables) != 0 && !combinedPlanApply { - applyTimeValues := make(terraform.InputValues, plan.ApplyTimeVariables.Len()) + applyTimeValues := make(terraform.InputValues, plan.ApplyTimeVariables.Len()) + + // In a combined plan/apply run, getting the context already gathers the interactive + // input, therefore we need to make sure to pass the ephemeral variables to the applyOpts. + if combinedPlanApply { + for varName, v := range lr.PlanOpts.SetVariables { + decl, ok := lr.Config.Module.Variables[varName] + if !ok { + continue // This should never happen, but we'll ignore it if it does. + } + + if v.SourceType == terraform.ValueFromInput && decl.Ephemeral { + applyTimeValues[varName] = v + } + } + } + + if len(op.Variables) != 0 { for varName, rawV := range op.Variables { // We're "parsing" only to get the resulting value's SourceType, // so we'll use configs.VariableParseLiteral just because it's @@ -247,16 +264,19 @@ func (b *Local) opApply( continue } - if v.SourceType == terraform.ValueFromCLIArg || v.SourceType == terraform.ValueFromNamedFile { - var rng *hcl.Range - if v.HasSourceRange() { - rng = v.SourceRange.ToHCL().Ptr() - } + var rng *hcl.Range + if v.HasSourceRange() { + rng = v.SourceRange.ToHCL().Ptr() + } - // If the variable isn't declared in config at all, take - // this opportunity to give the user a helpful error, - // rather than waiting for a less helpful one later. - decl, ok := lr.Config.Module.Variables[varName] + decl, ok := lr.Config.Module.Variables[varName] + + // If the variable isn't declared in config at all, take + // this opportunity to give the user a helpful error, + // rather than waiting for a less helpful one later. + // We are ok with over-supplying variables through environment variables + // since it would be a breaking change to disallow it. + if v.SourceType == terraform.ValueFromCLIArg || v.SourceType == terraform.ValueFromNamedFile { if !ok { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -266,85 +286,94 @@ func (b *Local) opApply( }) continue } + } - // If the var is declared as ephemeral in config, go ahead and handle it - if decl.Ephemeral { - // Determine whether this is an apply-time variable, i.e. an - // ephemeral variable that was set (non-null) during the - // planning phase. - applyTimeVar := false - for avName := range plan.ApplyTimeVariables.All() { - if varName == avName { - applyTimeVar = true - } - } - - // If this isn't an apply-time variable, it's not valid to - // set it during apply. - if !applyTimeVar { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Ephemeral variable was not set during planning", - Detail: fmt.Sprintf( - "The ephemeral input variable %q was not set during the planning phase, and so must remain unset during the apply phase.", - varName, - ), - Subject: rng, - }) - continue + // If the var is declared as ephemeral in config, go ahead and handle it + if ok && decl.Ephemeral { + // Determine whether this is an apply-time variable, i.e. an + // ephemeral variable that was set (non-null) during the + // planning phase. + applyTimeVar := false + for avName := range plan.ApplyTimeVariables.All() { + if varName == avName { + applyTimeVar = true } + } - // Get the value of the variable, because we'll need it for - // the next two steps. - val, valDiags := rawV.ParseVariableValue(decl.ParsingMode) - diags = diags.Append(valDiags) - if valDiags.HasErrors() { - continue - } + // If this isn't an apply-time variable, it's not valid to + // set it during apply. + if !applyTimeVar { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Ephemeral variable was not set during planning", + Detail: fmt.Sprintf( + "The ephemeral input variable %q was not set during the planning phase, and so must remain unset during the apply phase.", + varName, + ), + Subject: rng, + }) + continue + } - // If this is an apply-time variable, the user must supply a - // value during apply: it can't be null. - if applyTimeVar && val.Value.IsNull() { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Ephemeral variable must be set for apply", - Detail: fmt.Sprintf( - "The ephemeral input variable %q was set during the planning phase, and so must be set again during the apply phase.", - varName, - ), - }) - continue - } + // Get the value of the variable, because we'll need it for + // the next two steps. + val, valDiags := rawV.ParseVariableValue(decl.ParsingMode) + diags = diags.Append(valDiags) + if valDiags.HasErrors() { + continue + } - // If we get here, we are in possession of a non-null - // ephemeral apply-time input variable, and need only pass - // its value on to the ApplyOpts. - applyTimeValues[varName] = val - } else { - // TODO: We should probably actually tolerate this if the new - // value is equal to the value that was saved in the plan, since - // that'd make it possible to, for example, reuse a .tfvars file - // containing a mixture of ephemeral and non-ephemeral definitions - // during the apply phase, rather than having to split ephemeral - // and non-ephemeral definitions into separate files. For initial - // experiment we'll keep things a little simpler, though, and - // just skip this check if we're doing a combined plan/apply where - // the apply phase will therefore always have exactly the same - // inputs as the plan phase. + // If this is an apply-time variable, the user must supply a + // value during apply: it can't be null. + if applyTimeVar && val.Value.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Ephemeral variable must be set for apply", + Detail: fmt.Sprintf( + "The ephemeral input variable %q was set during the planning phase, and so must be set again during the apply phase.", + varName, + ), + }) + continue + } + // If we get here, we are in possession of a non-null + // ephemeral apply-time input variable, and need only pass + // its value on to the ApplyOpts. + applyTimeValues[varName] = val + } else { + // If a non-ephemeral variable is set differently between plan and apply, we should emit a diagnostic. + plannedVariableValue, ok := plan.VariableValues[varName] + if !ok { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Can't set variable when applying a saved plan", - Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because a saved plan includes the variable values that were set when it was created. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName), + Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because it is neither ephemeral nor has it been declared during the plan operation. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName), Subject: rng, }) + continue } + val, err := plannedVariableValue.Decode(cty.DynamicPseudoType) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Could not decode variable value from plan", + Detail: fmt.Sprintf("The variable %s could not be decoded from the plan. %s. This is a bug in Terraform, please report it.", varName, err), + Subject: rng, + }) + } else { + if v.Value.Equals(val).False() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Can't change variable when applying a saved plan", + Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because a saved plan includes the variable values that were set when it was created. The saved plan specifies %s as the value whereas during apply the value %s was %s. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName, viewsjson.CompactValueStr(v.Value), viewsjson.CompactValueStr(val), v.SourceType.DiagnosticLabel()), + Subject: rng, + }) + } + } } } - applyOpts = &terraform.ApplyOpts{ - SetVariables: applyTimeValues, - } if diags.HasErrors() { op.ReportResult(runningOp, diags) return @@ -360,7 +389,9 @@ func (b *Local) opApply( defer close(doneCh) log.Printf("[INFO] backend/local: apply calling Apply") - applyState, applyDiags = lr.Core.Apply(plan, lr.Config, applyOpts) + applyState, applyDiags = lr.Core.Apply(plan, lr.Config, &terraform.ApplyOpts{ + SetVariables: applyTimeValues, + }) }() if b.opWait(doneCh, stopCtx, cancelCtx, lr.Core, opState, op.View) { diff --git a/internal/command/apply.go b/internal/command/apply.go index d889f92345da..3bf441ecadd5 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -385,6 +385,15 @@ Options: -state-out=path Path to write state to that is different than "-state". This can be used to preserve the old state. + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. If you don't provide a saved plan file then this command will also accept all of the plan-customization options accepted by the terraform plan command. diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 794a76651eab..bb9db4f3b93a 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -29,6 +29,7 @@ import ( testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -863,7 +864,8 @@ func TestApply_planWithVarFile(t *testing.T) { t.Fatalf("err: %s", err) } - planPath := applyFixturePlanFile(t) + // The value of foo is the same as in the var file + planPath := applyFixturePlanFileWithVariableValue(t, "bar") statePath := testTempFile(t) cwd, err := os.Getwd() @@ -904,20 +906,26 @@ func TestApply_planWithVarFile(t *testing.T) { } } -func TestApply_planVars(t *testing.T) { - // This test ensures that it isn't allowed to set non-ephemeral input - // variables when applying from a saved plan file, since in that case the - // variable values come from the saved plan file. - // - // This situation was originally checked by the apply command itself, - // and that's what this test was originally exercising. This rule - // is now enforced by the "local" backend instead, but this test - // is still valid since the command instance delegates to the - // local backend. +func TestApply_planWithVarFilePreviouslyUnset(t *testing.T) { + varFileDir := testTempDir(t) + varFilePath := filepath.Join(varFileDir, "terraform.tfvars") + if err := ioutil.WriteFile(varFilePath, []byte(applyVarFile), 0644); err != nil { + t.Fatalf("err: %s", err) + } + // The value of foo is not set planPath := applyFixturePlanFile(t) statePath := testTempFile(t) + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(varFileDir); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + p := applyFixtureProvider() view, done := testView(t) c := &ApplyCommand{ @@ -928,72 +936,40 @@ func TestApply_planVars(t *testing.T) { } args := []string{ - "-state", statePath, - "-var", "foo=bar", + "-state-out", statePath, planPath, } code := c.Run(args) output := done(t) if code == 0 { - t.Fatal("should've failed: ", output.Stdout()) + t.Fatalf("expected to fail, but succeeded. \n\n%s", output.All()) + } + + expectedTitle := "Can't set variable when applying a saved plan" + if !strings.Contains(output.Stderr(), expectedTitle) { + t.Fatalf("Expected stderr to contain %q, got %q", expectedTitle, output.Stderr()) } } -// A saved plan includes a list of "apply-time variables", i.e. ephemeral -// input variables that were set during the plan, and must therefore be set -// during apply. No other variables may be set during apply. -// -// Test that an apply supplying all apply-time variables succeeds, and then test -// that supplying a declared ephemeral input variable that is *not* in the list -// of apply-time variables fails. -func TestApply_planVarsEphemeral_applyTime(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath("apply-ephemeral-variable"), td) - defer testChdir(t, td)() +func TestApply_planWithVarFileChangingVariableValue(t *testing.T) { + varFileDir := testTempDir(t) + varFilePath := filepath.Join(varFileDir, "terraform.tfvars") + if err := ioutil.WriteFile(varFilePath, []byte(applyVarFile), 0644); err != nil { + t.Fatalf("err: %s", err) + } - _, snap := testModuleWithSnapshot(t, "apply-ephemeral-variable") - plannedVal := cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "ami": cty.StringVal("bar"), - }) - priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + // The value of foo is differnet from the var file + planPath := applyFixturePlanFileWithVariableValue(t, "lorem ipsum") + statePath := testTempFile(t) + + cwd, err := os.Getwd() if err != nil { - t.Fatal(err) + t.Fatalf("err: %s", err) } - plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) - if err != nil { - t.Fatal(err) + if err := os.Chdir(varFileDir); err != nil { + t.Fatalf("err: %s", err) } - plan := testPlan(t) - plan.Changes.AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ - Addr: addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_instance", - Name: "foo", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), - ProviderAddr: addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ChangeSrc: plans.ChangeSrc{ - Action: plans.Create, - Before: priorValRaw, - After: plannedValRaw, - }, - }) - applyTimeVariables := collections.NewSetCmp[string]() - applyTimeVariables.Add("foo") - plan.ApplyTimeVariables = applyTimeVariables - - planPath := testPlanFileMatchState( - t, - snap, - states.NewState(), - plan, - statemgr.SnapshotMeta{}, - ) - - statePath := testTempFile(t) + defer os.Chdir(cwd) p := applyFixtureProvider() view, done := testView(t) @@ -1004,56 +980,312 @@ func TestApply_planVarsEphemeral_applyTime(t *testing.T) { }, } - // Test first that an apply supplying only the apply-time variable "foo" - // succeeds. args := []string{ - "-state", statePath, - "-var", "foo=bar", + "-state-out", statePath, planPath, } code := c.Run(args) output := done(t) - if code != 0 { - t.Fatal("should've succeeded: ", output.Stderr()) + if code == 0 { + t.Fatalf("expected to fail, but succeeded. \n\n%s", output.All()) + } + + expectedTitle := "Can't change variable when applying a saved plan" + if !strings.Contains(output.Stderr(), expectedTitle) { + t.Fatalf("Expected stderr to contain %q, got %q", expectedTitle, output.Stderr()) } +} + +func TestApply_planVars(t *testing.T) { + // This test ensures that it isn't allowed to set non-ephemeral input + // variables when applying from a saved plan file, since in that case the + // variable values come from the saved plan file. + // + // This situation was originally checked by the apply command itself, + // and that's what this test was originally exercising. This rule + // is now enforced by the "local" backend instead, but this test + // is still valid since the command instance delegates to the + // local backend. + + planPath := applyFixturePlanFile(t) + statePath := testTempFile(t) - // Now test that supplying "bar", which is not an apply-time variable, fails. - view, done = testView(t) - c = &ApplyCommand{ + p := applyFixtureProvider() + view, done := testView(t) + c := &ApplyCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), View: view, }, } - args = []string{ + + args := []string{ "-state", statePath, "-var", "foo=bar", - "-var", "bar=bar", planPath, } - code = c.Run(args) - output = done(t) + code := c.Run(args) + output := done(t) if code == 0 { t.Fatal("should've failed: ", output.Stdout()) } +} - // Finally, test that the apply also fails if we do *not* supply a value for - // the apply-time variable foo. - view, done = testView(t) - c = &ApplyCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(p), - View: view, +// A saved plan includes a list of "apply-time variables", i.e. ephemeral +// input variables that were set during the plan, and must therefore be set +// during apply. No other variables may be set during apply. +// +// Test that an apply supplying all apply-time variables succeeds, and then test +// that supplying a declared ephemeral input variable that is *not* in the list +// of apply-time variables fails. +// +// In the fixture used for this test foo is a required ephemeral variable, whereas bar is +// an optional one. +func TestApply_planVarsEphemeral_applyTime(t *testing.T) { + for name, tc := range map[string]func(*testing.T, *ApplyCommand, string, string, func(*testing.T) *terminal.TestOutput){ + "with planfile only passing ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + "-var", "foo=bar", + planPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } }, - } - args = []string{ - "-state", statePath, - planPath, - } - code = c.Run(args) - output = done(t) - if code == 0 { - t.Fatal("should've failed: ", output.Stdout()) + + "with planfile passing non-ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + "-var", "foo=bar", + "-var", "bar=bar", + planPath, + } + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatal("should've failed: ", output.All()) + } + }, + + "with planfile missing ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + planPath, + } + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatal("should've failed: ", output.All()) + } + }, + + "with planfile passing ephemeral variable through vars file": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + const planVarFile = ` +foo = "bar" +` + + // Write a tfvars file with the variable + tfVarsPath := testVarsFile(t) + err := os.WriteFile(tfVarsPath, []byte(planVarFile), 0600) + if err != nil { + t.Fatalf("Could not write vars file %e", err) + } + + args := []string{ + "-state", statePath, + "-var-file", tfVarsPath, + planPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "with planfile passing ephemeral variable through environment variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + t.Setenv("TF_VAR_foo", "bar") + defer t.Setenv("TF_VAR_foo", "") + + args := []string{ + "-state", statePath, + planPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "with planfile passing ephemeral variable through interactive prompts": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + close := testInteractiveInput(t, []string{"bar"}) + defer close() + + args := []string{ + "-state", statePath, + planPath, + } + code := c.Run(args) + output := done(t) + if code == 0 { + // We don't support interactive inputs for apply-time variables + t.Fatal("should have failed: ", output.All()) + } + }, + + "without planfile only passing ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + "-var", "foo=bar", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "without planfile passing non-ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + "-var", "foo=bar", + "-var", "bar=bar", + } + code := c.Run(args) + output := done(t) + + // For a combined plan & apply operation it's okay (and expected) to also be able to pass non-ephemeral variables + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "without planfile missing ephemeral variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + args := []string{ + "-state", statePath, + } + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatal("should've failed: ", output.All()) + } + }, + + "without planfile passing ephemeral variable through vars file": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + const planVarFile = ` +foo = "bar" +` + + // Write a tfvars file with the variable + tfVarsPath := testVarsFile(t) + err := os.WriteFile(tfVarsPath, []byte(planVarFile), 0600) + if err != nil { + t.Fatalf("Could not write vars file %e", err) + } + + args := []string{ + "-state", statePath, + "-var-file", tfVarsPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "without planfile passing ephemeral variable through environment variable": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + t.Setenv("TF_VAR_foo", "bar") + defer t.Setenv("TF_VAR_foo", "") + + args := []string{ + "-state", statePath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + + "without planfile passing ephemeral variable through interactive prompts": func(t *testing.T, c *ApplyCommand, statePath, planPath string, done func(*testing.T) *terminal.TestOutput) { + close := testInteractiveInput(t, []string{"bar"}) + defer close() + + args := []string{ + "-state", statePath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatal("should've succeeded: ", output.All()) + } + }, + } { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("apply-ephemeral-variable"), td) + defer testChdir(t, td)() + + _, snap := testModuleWithSnapshot(t, "apply-ephemeral-variable") + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plan := testPlan(t) + plan.Changes.AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: priorValRaw, + After: plannedValRaw, + }, + }) + applyTimeVariables := collections.NewSetCmp[string]() + applyTimeVariables.Add("foo") + plan.ApplyTimeVariables = applyTimeVariables + + planPath := testPlanFileMatchState( + t, + snap, + states.NewState(), + plan, + statemgr.SnapshotMeta{}, + ) + + statePath := testTempFile(t) + + p := applyFixtureProvider() + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + tc(t, c, statePath, planPath, done) + }) } } @@ -2359,6 +2591,53 @@ func applyFixturePlanFileMatchState(t *testing.T, stateMeta statemgr.SnapshotMet ) } +// applyFixturePlanFileWithVariableValue creates a plan file at a temporary location containing +// a single change to create the test_instance.foo and a variable value that is included in the +// "apply" test fixture, returning the location of that plan file. +func applyFixturePlanFileWithVariableValue(t *testing.T, value string) string { + _, snap := testModuleWithSnapshot(t, "apply") + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plan := testPlan(t) + plan.Changes.AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: priorValRaw, + After: plannedValRaw, + }, + }) + + plan.VariableValues = map[string]plans.DynamicValue{ + "foo": mustNewDynamicValue(value, cty.DynamicPseudoType), + } + return testPlanFileMatchState( + t, + snap, + states.NewState(), + plan, + statemgr.SnapshotMeta{}, + ) +} + const applyVarFile = ` foo = "bar" ` @@ -2366,3 +2645,12 @@ foo = "bar" const applyVarFileJSON = ` { "foo": "bar" } ` + +func mustNewDynamicValue(val string, ty cty.Type) plans.DynamicValue { + realVal := cty.StringVal(val) + ret, err := plans.NewDynamicValue(realVal, ty) + if err != nil { + panic(err) + } + return ret +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 563c74479a33..023e34962556 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -553,6 +553,12 @@ func testTempFile(t *testing.T) string { return filepath.Join(testTempDir(t), "state.tfstate") } +func testVarsFile(t *testing.T) string { + t.Helper() + + return filepath.Join(testTempDir(t), "variables.tfvars") +} + func testTempDir(t *testing.T) string { t.Helper() d, err := filepath.EvalSymlinks(t.TempDir()) diff --git a/internal/command/testdata/apply-ephemeral-variable/main.tf b/internal/command/testdata/apply-ephemeral-variable/main.tf index 247cbcbfef7e..f1b83b2052e3 100644 --- a/internal/command/testdata/apply-ephemeral-variable/main.tf +++ b/internal/command/testdata/apply-ephemeral-variable/main.tf @@ -1,6 +1,5 @@ variable "foo" { type = string - default = null ephemeral = true } diff --git a/internal/command/views/json/diagnostic.go b/internal/command/views/json/diagnostic.go index 51f987b61876..4d4032814a08 100644 --- a/internal/command/views/json/diagnostic.go +++ b/internal/command/views/json/diagnostic.go @@ -438,13 +438,17 @@ func parseRange(src []byte, rng hcl.Range) (*hcl.File, int) { return file, offset } -// compactValueStr produces a compact, single-line summary of a given value +// CompactValueStr produces a compact, single-line summary of a given value // that is suitable for display in the UI. // // For primitives it returns a full representation, while for more complex // types it instead summarizes the type, size, etc to produce something // that is hopefully still somewhat useful but not as verbose as a rendering // of the entire data structure. +func CompactValueStr(val cty.Value) string { + return compactValueStr(val) +} + func compactValueStr(val cty.Value) string { // This is a specialized subset of value rendering tailored to producing // helpful but concise messages in diagnostics. It is not comprehensive diff --git a/internal/terraform/variables.go b/internal/terraform/variables.go index 4b2984789c49..4adae05a7d79 100644 --- a/internal/terraform/variables.go +++ b/internal/terraform/variables.go @@ -137,6 +137,27 @@ func (v ValueSourceType) GoString() string { return fmt.Sprintf("terraform.%s", v) } +func (v ValueSourceType) DiagnosticLabel() string { + switch v { + case ValueFromConfig: + return "set by the default value in configuration" + case ValueFromAutoFile: + return "set by an automatically loaded .tfvars file" + case ValueFromNamedFile: + return "set by a .tfvars file passed through -var-file argument" + case ValueFromCLIArg: + return "set by a CLI argument" + case ValueFromEnvVar: + return "set by an environment variable" + case ValueFromInput: + return "set by an interactive input" + case ValueFromPlan: + return "set by the plan" + default: + return "unknown" + } +} + //go:generate go run golang.org/x/tools/cmd/stringer -type ValueSourceType // InputValues is a map of InputValue instances.