From 74c76613154dfbc104e5fdeb038dd93635fd104c Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 10 Mar 2026 15:34:20 +0100 Subject: [PATCH 01/20] command: Add vars to validate command --- internal/command/arguments/validate.go | 5 +- internal/command/arguments/validate_test.go | 61 ++++++++++++++++++++- internal/command/validate.go | 37 +++++++++++-- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/internal/command/arguments/validate.go b/internal/command/arguments/validate.go index 8c337b37e97f..2dcccb776a06 100644 --- a/internal/command/arguments/validate.go +++ b/internal/command/arguments/validate.go @@ -27,6 +27,8 @@ type Validate struct { // Query indicates that Terraform should also validate .tfquery files. Query bool + + Vars *Vars } // ParseValidate processes CLI arguments, returning a Validate value and errors. @@ -36,10 +38,11 @@ func ParseValidate(args []string) (*Validate, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics validate := &Validate{ Path: ".", + Vars: &Vars{}, } var jsonOutput bool - cmdFlags := defaultFlagSet("validate") + cmdFlags := extendedFlagSet("validate", nil, nil, validate.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") cmdFlags.StringVar(&validate.TestDirectory, "test-directory", "tests", "test-directory") cmdFlags.BoolVar(&validate.NoTests, "no-tests", false, "no-tests") diff --git a/internal/command/arguments/validate_test.go b/internal/command/arguments/validate_test.go index 1e9f0939dc35..8619822e0cad 100644 --- a/internal/command/arguments/validate_test.go +++ b/internal/command/arguments/validate_test.go @@ -6,6 +6,8 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +21,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, }, }, @@ -27,6 +30,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, }, @@ -35,6 +39,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: "foo", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, }, @@ -43,6 +48,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "other", + Vars: &Vars{}, ViewType: ViewHuman, }, }, @@ -51,25 +57,72 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, NoTests: true, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseValidate(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseValidate_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseValidate(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseValidate_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -81,6 +134,7 @@ func TestParseValidate_invalid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, }, tfdiags.Diagnostics{ @@ -96,6 +150,7 @@ func TestParseValidate_invalid(t *testing.T) { &Validate{ Path: "bar", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, tfdiags.Diagnostics{ @@ -108,10 +163,12 @@ func TestParseValidate_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseValidate(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/validate.go b/internal/command/validate.go index 952abb3fb2e9..5ac75db90289 100644 --- a/internal/command/validate.go +++ b/internal/command/validate.go @@ -47,6 +47,26 @@ func (c *ValidateCommand) Run(rawArgs []string) int { c.ParsedArgs = args view := views.NewValidate(args.ViewType, c.View) + // If the query flag is set, include query files in the validation. + c.includeQueryFiles = c.ParsedArgs.Query + + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + // After this point, we must only produce JSON output if JSON mode is // enabled, so all errors should be accumulated into diags and we'll // print out a suitable result at the end, depending on the format @@ -81,9 +101,6 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics var cfg *configs.Config - // If the query flag is set, include query files in the validation. - c.includeQueryFiles = c.ParsedArgs.Query - if c.ParsedArgs.NoTests { cfg, diags = c.loadConfig(dir) } else { @@ -360,9 +377,19 @@ Options: -no-tests If specified, Terraform will not validate test files. - -test-directory=path Set the Terraform test directory, defaults to "tests". - + -test-directory=path Set the Terraform test directory, defaults to "tests". + -query If specified, the command will also validate .tfquery.hcl files. + + -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. + ` return strings.TrimSpace(helpText) } From 568245f70d3ca7d8408708143815c115c9ba89c0 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:06:12 +0100 Subject: [PATCH 02/20] command: Add vars to graph command --- internal/command/arguments/graph.go | 6 ++- internal/command/arguments/graph_test.go | 64 +++++++++++++++++++++++- internal/command/graph.go | 47 +++++++++++------ 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/internal/command/arguments/graph.go b/internal/command/arguments/graph.go index 291ad2dfe0ad..cbcd45540384 100644 --- a/internal/command/arguments/graph.go +++ b/internal/command/arguments/graph.go @@ -24,6 +24,9 @@ type Graph struct { // Plan is the path to a saved plan file to render as a graph. Plan string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseGraph processes CLI arguments, returning a Graph value and errors. @@ -33,9 +36,10 @@ func ParseGraph(args []string) (*Graph, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics graph := &Graph{ ModuleDepth: -1, + Vars: &Vars{}, } - cmdFlags := defaultFlagSet("graph") + cmdFlags := extendedFlagSet("graph", nil, nil, graph.Vars) cmdFlags.BoolVar(&graph.DrawCycles, "draw-cycles", false, "draw-cycles") cmdFlags.StringVar(&graph.GraphType, "type", "", "type") cmdFlags.IntVar(&graph.ModuleDepth, "module-depth", -1, "module-depth") diff --git a/internal/command/arguments/graph_test.go b/internal/command/arguments/graph_test.go index 227707e0390a..1f5c83ff55df 100644 --- a/internal/command/arguments/graph_test.go +++ b/internal/command/arguments/graph_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +20,7 @@ func TestParseGraph_valid(t *testing.T) { nil, &Graph{ ModuleDepth: -1, + Vars: &Vars{}, }, }, "plan type": { @@ -26,6 +28,7 @@ func TestParseGraph_valid(t *testing.T) { &Graph{ GraphType: "plan", ModuleDepth: -1, + Vars: &Vars{}, }, }, "apply type": { @@ -33,6 +36,7 @@ func TestParseGraph_valid(t *testing.T) { &Graph{ GraphType: "apply", ModuleDepth: -1, + Vars: &Vars{}, }, }, "draw-cycles": { @@ -41,6 +45,7 @@ func TestParseGraph_valid(t *testing.T) { DrawCycles: true, GraphType: "plan", ModuleDepth: -1, + Vars: &Vars{}, }, }, "plan file": { @@ -48,6 +53,7 @@ func TestParseGraph_valid(t *testing.T) { &Graph{ Plan: "tfplan", ModuleDepth: -1, + Vars: &Vars{}, }, }, "verbose": { @@ -55,12 +61,14 @@ func TestParseGraph_valid(t *testing.T) { &Graph{ Verbose: true, ModuleDepth: -1, + Vars: &Vars{}, }, }, "module-depth": { []string{"-module-depth=2"}, &Graph{ ModuleDepth: 2, + Vars: &Vars{}, }, }, "all flags": { @@ -71,17 +79,20 @@ func TestParseGraph_valid(t *testing.T) { Plan: "tfplan", Verbose: true, ModuleDepth: 3, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseGraph(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -98,6 +109,7 @@ func TestParseGraph_invalid(t *testing.T) { []string{"-wat"}, &Graph{ ModuleDepth: -1, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -111,6 +123,7 @@ func TestParseGraph_invalid(t *testing.T) { []string{"extra"}, &Graph{ ModuleDepth: -1, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -124,6 +137,7 @@ func TestParseGraph_invalid(t *testing.T) { []string{"bad", "bad"}, &Graph{ ModuleDepth: -1, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -135,13 +149,59 @@ func TestParseGraph_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseGraph(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseGraph_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGraph(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/graph.go b/internal/command/graph.go index 8e4b8efa575f..02e8b3c924c7 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -84,6 +84,16 @@ func (c *GraphCommand) Run(rawArgs []string) int { return 1 } + var varDiags tfdiags.Diagnostics + opReq.Variables, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + opReq.ConfigLoader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + // Get the context lr, _, ctxDiags := local.LocalRun(opReq) diags = diags.Append(ctxDiags) @@ -296,23 +306,32 @@ Usage: terraform [global options] graph [options] Options: - -plan=tfplan Render graph using the specified plan file instead of the - configuration in the current directory. Implies -type=apply. + -plan=tfplan Render graph using the specified plan file instead of the + configuration in the current directory. Implies -type=apply. + + -draw-cycles Highlight any cycles in the graph with colored edges. + This helps when diagnosing cycle errors. This option is + supported only when illustrating a real evaluation graph, + selected using the -type=TYPE option. + + -type=TYPE Type of operation graph to output. Can be: plan, + plan-refresh-only, plan-destroy, or apply. By default + Terraform just summarizes the relationships between the + resources in your configuration, without any particular + operation in mind. Full operation graphs are more detailed + but therefore often harder to read. - -draw-cycles Highlight any cycles in the graph with colored edges. - This helps when diagnosing cycle errors. This option is - supported only when illustrating a real evaluation graph, - selected using the -type=TYPE option. + -module-depth=n (deprecated) In prior versions of Terraform, specified the + depth of modules to show in the output. - -type=TYPE Type of operation graph to output. Can be: plan, - plan-refresh-only, plan-destroy, or apply. By default - Terraform just summarizes the relationships between the - resources in your configuration, without any particular - operation in mind. Full operation graphs are more detailed - but therefore often harder to read. + -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. - -module-depth=n (deprecated) In prior versions of Terraform, specified the - depth of modules to show in the output. + -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. ` return strings.TrimSpace(helpText) } From ec8a9b7671327dd797119f6b4132c0a10d9c6508 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:06:40 +0100 Subject: [PATCH 03/20] command: Add vars to modules command --- internal/command/arguments/modules.go | 9 +++- internal/command/arguments/modules_test.go | 62 ++++++++++++++++++++-- internal/command/modules.go | 30 ++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/internal/command/arguments/modules.go b/internal/command/arguments/modules.go index af76bf01c787..547dd71633ca 100644 --- a/internal/command/arguments/modules.go +++ b/internal/command/arguments/modules.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" type Modules struct { // ViewType specifies which output format to use: human, JSON, or "raw" ViewType ViewType + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseModules processes CLI arguments, returning a Modules value and error @@ -18,8 +21,10 @@ func ParseModules(args []string) (*Modules, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var jsonOutput bool - modules := &Modules{} - cmdFlags := defaultFlagSet("modules") + modules := &Modules{ + Vars: &Vars{}, + } + cmdFlags := extendedFlagSet("modules", nil, nil, modules.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/modules_test.go b/internal/command/arguments/modules_test.go index f4d41b69711f..3a6ba941dcaa 100644 --- a/internal/command/arguments/modules_test.go +++ b/internal/command/arguments/modules_test.go @@ -6,6 +6,8 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,24 +20,28 @@ func TestParseModules_valid(t *testing.T) { nil, &Modules{ ViewType: ViewHuman, + Vars: &Vars{}, }, }, "json": { []string{"-json"}, &Modules{ ViewType: ViewJSON, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseModules(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) } }) } @@ -51,6 +57,7 @@ func TestParseModules_invalid(t *testing.T) { []string{"-sauron"}, &Modules{ ViewType: ViewHuman, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -64,6 +71,7 @@ func TestParseModules_invalid(t *testing.T) { []string{"-json", "frodo"}, &Modules{ ViewType: ViewJSON, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -75,13 +83,59 @@ func TestParseModules_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseModules(tc.args) - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseModules_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseModules(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/modules.go b/internal/command/modules.go index cb2972dbc41e..95d8acb7ad7e 100644 --- a/internal/command/modules.go +++ b/internal/command/modules.go @@ -48,6 +48,23 @@ func (c *ModulesCommand) Run(rawArgs []string) int { // Set up the command's view view := views.NewModules(c.viewType, c.View) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + rootModPath, err := ModulePath([]string{}) if err != nil { diags = diags.Append(err) @@ -127,6 +144,15 @@ Usage: terraform [global options] modules [options] Options: - -json If specified, output declared Terraform modules and - their resolved versions in a machine-readable format. + -json If specified, output declared Terraform modules and + their resolved versions in a machine-readable format. + + -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. ` From 8345e1d3d9c1c2d321f59cffc88c7a250220763e Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:01 +0100 Subject: [PATCH 04/20] command: Add vars to providers command --- internal/command/arguments/providers.go | 9 +++- internal/command/arguments/providers_test.go | 57 +++++++++++++++++++- internal/command/providers.go | 28 +++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/internal/command/arguments/providers.go b/internal/command/arguments/providers.go index 5637f47f030d..697376567f57 100644 --- a/internal/command/arguments/providers.go +++ b/internal/command/arguments/providers.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" type Providers struct { // TestsDirectory is the directory containing Terraform test files. TestsDirectory string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProviders processes CLI arguments, returning a Providers value and @@ -16,9 +19,11 @@ type Providers struct { // representing the best effort interpretation of the arguments. func ParseProviders(args []string) (*Providers, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providers := &Providers{} + providers := &Providers{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers") + cmdFlags := extendedFlagSet("providers", nil, nil, providers.Vars) cmdFlags.StringVar(&providers.TestsDirectory, "test-directory", "tests", "test-directory") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/providers_test.go b/internal/command/arguments/providers_test.go index b55f69704acd..02b9cedd1485 100644 --- a/internal/command/arguments/providers_test.go +++ b/internal/command/arguments/providers_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,23 +20,27 @@ func TestParseProviders_valid(t *testing.T) { nil, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, }, "test directory": { []string{"-test-directory=integration-tests"}, &Providers{ TestsDirectory: "integration-tests", + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProviders(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -52,6 +57,7 @@ func TestParseProviders_invalid(t *testing.T) { []string{"-wat"}, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -65,6 +71,7 @@ func TestParseProviders_invalid(t *testing.T) { []string{"foo"}, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -76,13 +83,59 @@ func TestParseProviders_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProviders(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProviders_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProviders(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/providers.go b/internal/command/providers.go index 4ada4675236d..7e936dbee882 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -43,6 +43,23 @@ func (c *ProvidersCommand) Run(args []string) int { return 1 } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + empty, err := configs.IsEmptyDir(configPath, parsedArgs.TestsDirectory) if err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -178,5 +195,14 @@ Usage: terraform [global options] providers [options] [DIR] Options: - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -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. ` From 6f5bcd419aea65d5d5ec881a7d8ae2c8fd98cffd Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:26 +0100 Subject: [PATCH 05/20] command: Add vars to pvoiders lock command --- internal/command/arguments/providers_lock.go | 9 ++- .../command/arguments/providers_lock_test.go | 58 ++++++++++++++++++- internal/command/providers_lock.go | 28 ++++++++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/internal/command/arguments/providers_lock.go b/internal/command/arguments/providers_lock.go index 57939271521e..fca6685e971d 100644 --- a/internal/command/arguments/providers_lock.go +++ b/internal/command/arguments/providers_lock.go @@ -14,6 +14,9 @@ type ProvidersLock struct { TestsDirectory string EnablePluginCache bool Providers []string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersLock processes CLI arguments, returning a ProvidersLock value @@ -21,9 +24,11 @@ type ProvidersLock struct { // returned representing the best effort interpretation of the arguments. func ParseProvidersLock(args []string) (*ProvidersLock, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersLock := &ProvidersLock{} + providersLock := &ProvidersLock{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers lock") + cmdFlags := extendedFlagSet("providers lock", nil, nil, providersLock.Vars) cmdFlags.Var(&providersLock.Platforms, "platform", "target platform") cmdFlags.StringVar(&providersLock.FSMirrorDir, "fs-mirror", "", "filesystem mirror directory") cmdFlags.StringVar(&providersLock.NetMirrorURL, "net-mirror", "", "network mirror base URL") diff --git a/internal/command/arguments/providers_lock_test.go b/internal/command/arguments/providers_lock_test.go index a59c83645384..f919b431cd42 100644 --- a/internal/command/arguments/providers_lock_test.go +++ b/internal/command/arguments/providers_lock_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +20,7 @@ func TestParseProvidersLock_valid(t *testing.T) { nil, &ProvidersLock{ TestsDirectory: "tests", + Vars: &Vars{}, }, }, "all options": { @@ -36,17 +38,20 @@ func TestParseProvidersLock_valid(t *testing.T) { TestsDirectory: "integration-tests", EnablePluginCache: true, Providers: []string{"hashicorp/test"}, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersLock(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -69,6 +74,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { NetMirrorURL: "https://example.com", TestsDirectory: "tests", Providers: []string{}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -83,6 +89,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { &ProvidersLock{ TestsDirectory: "tests", Providers: []string{}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -101,6 +108,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { &ProvidersLock{ TestsDirectory: "tests", Providers: []string{"-fs-mirror=foo", "-net-mirror=https://example.com"}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -112,13 +120,59 @@ func TestParseProvidersLock_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersLock(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersLock_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersLock(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/providers_lock.go b/internal/command/providers_lock.go index a13a1bc86eed..8f9a65659d0d 100644 --- a/internal/command/providers_lock.go +++ b/internal/command/providers_lock.go @@ -99,6 +99,23 @@ func (c *ProvidersLockCommand) Run(args []string) int { source = getproviders.NewRegistrySource(c.Services) } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfigWithTests(".", parsedArgs.TestsDirectory) diags = diags.Append(confDiags) reqs, hclDiags := config.ProviderRequirements() @@ -393,7 +410,16 @@ Options: This will speed up the locking process, but the providers won't be loaded from an authoritative source. - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -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. ` } From dc02f0fb4260b408aacf36e1a941976726e43686 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:48 +0100 Subject: [PATCH 06/20] command: Add vars to providers mirror command --- .../command/arguments/providers_mirror.go | 9 ++- .../arguments/providers_mirror_test.go | 59 ++++++++++++++++++- internal/command/providers_mirror.go | 54 ++++++++++++----- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/internal/command/arguments/providers_mirror.go b/internal/command/arguments/providers_mirror.go index 219ad5003228..6b7c671a0771 100644 --- a/internal/command/arguments/providers_mirror.go +++ b/internal/command/arguments/providers_mirror.go @@ -11,6 +11,9 @@ type ProvidersMirror struct { Platforms FlagStringSlice LockFile bool OutputDir string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersMirror processes CLI arguments, returning a ProvidersMirror @@ -18,9 +21,11 @@ type ProvidersMirror struct { // still returned representing the best effort interpretation of the arguments. func ParseProvidersMirror(args []string) (*ProvidersMirror, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersMirror := &ProvidersMirror{} + providersMirror := &ProvidersMirror{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers mirror") + cmdFlags := extendedFlagSet("providers mirror", nil, nil, providersMirror.Vars) cmdFlags.Var(&providersMirror.Platforms, "platform", "target platform") cmdFlags.BoolVar(&providersMirror.LockFile, "lock-file", true, "use lock file") diff --git a/internal/command/arguments/providers_mirror_test.go b/internal/command/arguments/providers_mirror_test.go index d6fb5598ee4e..596ed73d66cb 100644 --- a/internal/command/arguments/providers_mirror_test.go +++ b/internal/command/arguments/providers_mirror_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -20,6 +21,7 @@ func TestParseProvidersMirror_valid(t *testing.T) { &ProvidersMirror{ LockFile: true, OutputDir: "./mirror", + Vars: &Vars{}, }, }, "all options": { @@ -32,17 +34,20 @@ func TestParseProvidersMirror_valid(t *testing.T) { &ProvidersMirror{ Platforms: FlagStringSlice{"linux_amd64", "darwin_arm64"}, OutputDir: "./mirror", + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersMirror(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -59,6 +64,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { nil, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -72,6 +78,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { []string{"./mirror", "./extra"}, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -85,6 +92,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { []string{"-wat"}, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -101,13 +109,60 @@ func TestParseProvidersMirror_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersMirror(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersMirror_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "./mirror"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "./mirror"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "./mirror", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersMirror(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/providers_mirror.go b/internal/command/providers_mirror.go index cfff1b2e2c47..d17f5481893e 100644 --- a/internal/command/providers_mirror.go +++ b/internal/command/providers_mirror.go @@ -61,6 +61,23 @@ func (c *ProvidersMirrorCommand) Run(args []string) int { ctx, done := c.InterruptibleContext(c.CommandContext()) defer done() + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfig(".") diags = diags.Append(confDiags) reqs, moreDiags := config.ProviderRequirements() @@ -350,21 +367,30 @@ Usage: terraform [global options] providers mirror [options] Options: - -platform=os_arch Choose which target platform to build a mirror for. - By default Terraform will obtain plugin packages - suitable for the platform where you run this command. - Use this flag multiple times to include packages for - multiple target systems. + -platform=os_arch Choose which target platform to build a mirror for. + By default Terraform will obtain plugin packages + suitable for the platform where you run this command. + Use this flag multiple times to include packages for + multiple target systems. + + Target names consist of an operating system and a CPU + architecture. For example, "linux_amd64" selects the + Linux operating system running on an AMD64 or x86_64 + CPU. Each provider is available only for a limited + set of target platforms. + + -lock-file=false Ignore the provider lock file when fetching providers. + By default the mirror command will use the version info + in the lock file if the configuration directory has been + previously initialized. - Target names consist of an operating system and a CPU - architecture. For example, "linux_amd64" selects the - Linux operating system running on an AMD64 or x86_64 - CPU. Each provider is available only for a limited - set of target platforms. + -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. - -lock-file=false Ignore the provider lock file when fetching providers. - By default the mirror command will use the version info - in the lock file if the configuration directory has been - previously initialized. + -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. ` } From 324c4d821bbe10e829dc13286027f5926719938f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:08:08 +0100 Subject: [PATCH 07/20] command: Add vars to providers schema command --- .../command/arguments/providers_schema.go | 9 ++- .../arguments/providers_schema_test.go | 64 +++++++++++++++++-- internal/command/providers_schema.go | 24 ++++++- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/internal/command/arguments/providers_schema.go b/internal/command/arguments/providers_schema.go index 15afa0acfc76..6e7029fa1a57 100644 --- a/internal/command/arguments/providers_schema.go +++ b/internal/command/arguments/providers_schema.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" // schema command. type ProvidersSchema struct { JSON bool + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersSchema processes CLI arguments, returning a ProvidersSchema @@ -16,9 +19,11 @@ type ProvidersSchema struct { // still returned representing the best effort interpretation of the arguments. func ParseProvidersSchema(args []string) (*ProvidersSchema, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersSchema := &ProvidersSchema{} + providersSchema := &ProvidersSchema{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers schema") + cmdFlags := extendedFlagSet("providers schema", nil, nil, providersSchema.Vars) cmdFlags.BoolVar(&providersSchema.JSON, "json", false, "produce JSON output") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/providers_schema_test.go b/internal/command/arguments/providers_schema_test.go index 8f6df7220c08..9cb3d3c2c289 100644 --- a/internal/command/arguments/providers_schema_test.go +++ b/internal/command/arguments/providers_schema_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,17 +20,20 @@ func TestParseProvidersSchema_valid(t *testing.T) { []string{"-json"}, &ProvidersSchema{ JSON: true, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersSchema(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -44,7 +48,9 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }{ "missing json": { nil, - &ProvidersSchema{}, + &ProvidersSchema{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -57,6 +63,7 @@ func TestParseProvidersSchema_invalid(t *testing.T) { []string{"-json", "extra"}, &ProvidersSchema{ JSON: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -68,7 +75,9 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }, "unknown flag and missing json": { []string{"-wat"}, - &ProvidersSchema{}, + &ProvidersSchema{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -84,13 +93,60 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersSchema(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersSchema_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-json", "-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-json", "-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-json", + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersSchema(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/providers_schema.go b/internal/command/providers_schema.go index 6af9b75f3acc..33541806d514 100644 --- a/internal/command/providers_schema.go +++ b/internal/command/providers_schema.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/jsonprovider" + "github.com/hashicorp/terraform/internal/tfdiags" ) // ProvidersCommand is a Command implementation that prints out information @@ -27,7 +28,7 @@ func (c *ProvidersSchemaCommand) Synopsis() string { } func (c *ProvidersSchemaCommand) Run(args []string) int { - _, diags := arguments.ParseProvidersSchema(c.Meta.process(args)) + parsedArgs, diags := arguments.ParseProvidersSchema(c.Meta.process(args)) if diags.HasErrors() { c.showDiagnostics(diags) return 1 @@ -78,6 +79,16 @@ func (c *ProvidersSchemaCommand) Run(args []string) int { return 1 } + var varDiags tfdiags.Diagnostics + opReq.Variables, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + opReq.ConfigLoader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + // Get the context lr, _, ctxDiags := local.LocalRun(opReq) diags = diags.Append(ctxDiags) @@ -108,4 +119,15 @@ Usage: terraform [global options] providers schema -json Prints out a json representation of the schemas for all providers used in the current configuration. + +Options: + + -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. ` From 13e8ba33e3262e54794788f694c5db7dad267a60 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:03:14 +0100 Subject: [PATCH 08/20] command: Add vars to state mv command --- internal/command/arguments/state_mv.go | 9 ++- internal/command/arguments/state_mv_test.go | 64 ++++++++++++++++++++- internal/command/state_mv.go | 26 +++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/internal/command/arguments/state_mv.go b/internal/command/arguments/state_mv.go index 475c5cabcf58..e0ee44b6d74e 100644 --- a/internal/command/arguments/state_mv.go +++ b/internal/command/arguments/state_mv.go @@ -11,6 +11,9 @@ import ( // StateMv represents the command-line arguments for the state mv command. type StateMv struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // DryRun, if true, prints out what would be moved without actually // moving anything. DryRun bool @@ -51,9 +54,11 @@ type StateMv struct { // representing the best effort interpretation of the arguments. func ParseStateMv(args []string) (*StateMv, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - mv := &StateMv{} + mv := &StateMv{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state mv") + cmdFlags := extendedFlagSet("state mv", nil, nil, mv.Vars) cmdFlags.BoolVar(&mv.DryRun, "dry-run", false, "dry run") cmdFlags.StringVar(&mv.BackupPath, "backup", "-", "backup") cmdFlags.StringVar(&mv.BackupOutPath, "backup-out", "-", "backup") diff --git a/internal/command/arguments/state_mv_test.go b/internal/command/arguments/state_mv_test.go index be1d084dbfd6..716277374657 100644 --- a/internal/command/arguments/state_mv_test.go +++ b/internal/command/arguments/state_mv_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateMv_valid(t *testing.T) { "addresses only": { []string{"test_instance.foo", "test_instance.bar"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -28,6 +32,7 @@ func TestParseStateMv_valid(t *testing.T) { "dry run": { []string{"-dry-run", "test_instance.foo", "test_instance.bar"}, &StateMv{ + Vars: &Vars{}, DryRun: true, BackupPath: "-", BackupOutPath: "-", @@ -50,6 +55,7 @@ func TestParseStateMv_valid(t *testing.T) { "test_instance.bar", }, &StateMv{ + Vars: &Vars{}, DryRun: true, BackupPath: "backup.tfstate", BackupOutPath: "backup-out.tfstate", @@ -64,19 +70,67 @@ func TestParseStateMv_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateMv(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStateMv_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo", "test_instance.bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo", "test_instance.bar"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + "test_instance.bar", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateMv(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStateMv_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -86,6 +140,7 @@ func TestParseStateMv_invalid(t *testing.T) { "no arguments": { nil, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -101,6 +156,7 @@ func TestParseStateMv_invalid(t *testing.T) { "one argument": { []string{"test_instance.foo"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -117,6 +173,7 @@ func TestParseStateMv_invalid(t *testing.T) { "too many arguments": { []string{"a", "b", "c"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -134,6 +191,7 @@ func TestParseStateMv_invalid(t *testing.T) { "unknown flag": { []string{"-boop"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -153,10 +211,12 @@ func TestParseStateMv_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateMv(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/state_mv.go b/internal/command/state_mv.go index 64d47e6eba99..2548ad942efd 100644 --- a/internal/command/state_mv.go +++ b/internal/command/state_mv.go @@ -36,6 +36,23 @@ func (c *StateMvCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -541,6 +558,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -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. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. From ab67b5968ea3fe1444d8503b2521edec9105dc3f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:20:20 +0100 Subject: [PATCH 09/20] command: Add vars to state pull command --- internal/command/arguments/state_pull.go | 8 ++- internal/command/arguments/state_pull_test.go | 63 +++++++++++++++++-- internal/command/state_pull.go | 30 ++++++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/internal/command/arguments/state_pull.go b/internal/command/arguments/state_pull.go index 85bc653760e3..99673d971f84 100644 --- a/internal/command/arguments/state_pull.go +++ b/internal/command/arguments/state_pull.go @@ -9,6 +9,8 @@ import ( // StatePull represents the command-line arguments for the state pull command. type StatePull struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseStatePull processes CLI arguments, returning a StatePull value and @@ -16,9 +18,11 @@ type StatePull struct { // representing the best effort interpretation of the arguments. func ParseStatePull(args []string) (*StatePull, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - pull := &StatePull{} + pull := &StatePull{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state pull") + cmdFlags := extendedFlagSet("state pull", nil, nil, pull.Vars) if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( diff --git a/internal/command/arguments/state_pull_test.go b/internal/command/arguments/state_pull_test.go index 37b99228539c..7696bd94cb87 100644 --- a/internal/command/arguments/state_pull_test.go +++ b/internal/command/arguments/state_pull_test.go @@ -6,6 +6,9 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -16,23 +19,71 @@ func TestParseStatePull_valid(t *testing.T) { }{ "defaults": { nil, - &StatePull{}, + &StatePull{ + Vars: &Vars{}, + }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStatePull(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStatePull_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStatePull(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStatePull_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -41,7 +92,9 @@ func TestParseStatePull_invalid(t *testing.T) { }{ "unknown flag": { []string{"-boop"}, - &StatePull{}, + &StatePull{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -52,10 +105,12 @@ func TestParseStatePull_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStatePull(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/state_pull.go b/internal/command/state_pull.go index 6eb6da85b57e..f332f6b53af2 100644 --- a/internal/command/state_pull.go +++ b/internal/command/state_pull.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/tfdiags" ) // StatePullCommand is a Command implementation that allows downloading @@ -21,12 +22,28 @@ type StatePullCommand struct { } func (c *StatePullCommand) Run(args []string) int { - _, diags := arguments.ParseStatePull(c.Meta.process(args)) + parsedArgs, diags := arguments.ParseStatePull(c.Meta.process(args)) if diags.HasErrors() { c.showDiagnostics(diags) return 1 } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -90,6 +107,17 @@ Usage: terraform [global options] state pull [options] The primary use of this is for state stored remotely. This command will still work with local state but is less useful for this. +Options: + + -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. + ` return strings.TrimSpace(helpText) } From fbb4c5cf126533a8697ae19872101b7a2fb8cab8 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:24:01 +0100 Subject: [PATCH 10/20] command: Add vars to state push command --- internal/command/arguments/state_push.go | 9 ++- internal/command/arguments/state_push_test.go | 65 ++++++++++++++++++- internal/command/state_push.go | 26 ++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/internal/command/arguments/state_push.go b/internal/command/arguments/state_push.go index f12d0670c80b..58612840a855 100644 --- a/internal/command/arguments/state_push.go +++ b/internal/command/arguments/state_push.go @@ -11,6 +11,9 @@ import ( // StatePush represents the command-line arguments for the state push command. type StatePush struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // Force writes the state even if lineages don't match or the remote // serial is higher. Force bool @@ -35,9 +38,11 @@ type StatePush struct { // representing the best effort interpretation of the arguments. func ParseStatePush(args []string) (*StatePush, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - push := &StatePush{} + push := &StatePush{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state push") + cmdFlags := extendedFlagSet("state push", nil, nil, push.Vars) cmdFlags.BoolVar(&push.Force, "force", false, "") cmdFlags.BoolVar(&push.StateLock, "lock", true, "lock state") cmdFlags.DurationVar(&push.StateLockTimeout, "lock-timeout", 0, "lock timeout") diff --git a/internal/command/arguments/state_push_test.go b/internal/command/arguments/state_push_test.go index 70a0c8b112e4..e69b46443d46 100644 --- a/internal/command/arguments/state_push_test.go +++ b/internal/command/arguments/state_push_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStatePush_valid(t *testing.T) { "path only": { []string{"replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, Path: "replace.tfstate", }, @@ -25,6 +29,7 @@ func TestParseStatePush_valid(t *testing.T) { "stdin": { []string{"-"}, &StatePush{ + Vars: &Vars{}, StateLock: true, Path: "-", }, @@ -32,6 +37,7 @@ func TestParseStatePush_valid(t *testing.T) { "force": { []string{"-force", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, Force: true, StateLock: true, Path: "replace.tfstate", @@ -40,12 +46,14 @@ func TestParseStatePush_valid(t *testing.T) { "lock disabled": { []string{"-lock=false", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, Path: "replace.tfstate", }, }, "lock timeout": { []string{"-lock-timeout=5s", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, StateLockTimeout: 5 * time.Second, Path: "replace.tfstate", @@ -54,6 +62,7 @@ func TestParseStatePush_valid(t *testing.T) { "ignore remote version": { []string{"-ignore-remote-version", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, IgnoreRemoteVersion: true, Path: "replace.tfstate", @@ -61,19 +70,66 @@ func TestParseStatePush_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStatePush(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStatePush_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "replace.tfstate"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "replace.tfstate"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "replace.tfstate", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStatePush(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStatePush_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -83,6 +139,7 @@ func TestParseStatePush_invalid(t *testing.T) { "no arguments": { nil, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -96,6 +153,7 @@ func TestParseStatePush_invalid(t *testing.T) { "too many arguments": { []string{"foo.tfstate", "bar.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -109,6 +167,7 @@ func TestParseStatePush_invalid(t *testing.T) { "unknown flag": { []string{"-boop"}, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -126,10 +185,12 @@ func TestParseStatePush_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStatePush(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/state_push.go b/internal/command/state_push.go index b99ca05cf389..97ef857ecd5f 100644 --- a/internal/command/state_push.go +++ b/internal/command/state_push.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" ) // StatePushCommand is a Command implementation that allows @@ -35,6 +36,22 @@ func (c *StatePushCommand) Run(args []string) int { c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -173,6 +190,15 @@ Options: -lock-timeout=0s Duration to retry a state lock. + -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. + ` return strings.TrimSpace(helpText) } From b3e863c9ec4150aaee6c9acdabba3ce2ec9538eb Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:27:15 +0100 Subject: [PATCH 11/20] command: Add vars to state replace command --- .../arguments/state_replace_provider.go | 9 ++- .../arguments/state_replace_provider_test.go | 62 ++++++++++++++++++- internal/command/state_replace_provider.go | 26 ++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/internal/command/arguments/state_replace_provider.go b/internal/command/arguments/state_replace_provider.go index 57f36d2c85c9..678d0e5a2209 100644 --- a/internal/command/arguments/state_replace_provider.go +++ b/internal/command/arguments/state_replace_provider.go @@ -12,6 +12,9 @@ import ( // StateReplaceProvider represents the command-line arguments for the state // replace-provider command. type StateReplaceProvider struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // AutoApprove, if true, skips the interactive approval step. AutoApprove bool @@ -45,9 +48,11 @@ type StateReplaceProvider struct { // interpretation of the arguments. func ParseStateReplaceProvider(args []string) (*StateReplaceProvider, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - rp := &StateReplaceProvider{} + rp := &StateReplaceProvider{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state replace-provider") + cmdFlags := extendedFlagSet("state replace-provider", nil, nil, rp.Vars) cmdFlags.BoolVar(&rp.AutoApprove, "auto-approve", false, "skip interactive approval of replacements") cmdFlags.StringVar(&rp.BackupPath, "backup", "-", "backup") cmdFlags.BoolVar(&rp.StateLock, "lock", true, "lock states") diff --git a/internal/command/arguments/state_replace_provider_test.go b/internal/command/arguments/state_replace_provider_test.go index 2938cfe513cf..ce5061669353 100644 --- a/internal/command/arguments/state_replace_provider_test.go +++ b/internal/command/arguments/state_replace_provider_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "provider addresses only": { []string{"hashicorp/aws", "acmecorp/aws"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, FromProviderAddr: "hashicorp/aws", @@ -27,6 +31,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "auto approve": { []string{"-auto-approve", "hashicorp/aws", "acmecorp/aws"}, &StateReplaceProvider{ + Vars: &Vars{}, AutoApprove: true, BackupPath: "-", StateLock: true, @@ -46,6 +51,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "acmecorp/aws", }, &StateReplaceProvider{ + Vars: &Vars{}, AutoApprove: true, BackupPath: "backup.tfstate", StateLock: false, @@ -58,19 +64,66 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateReplaceProvider(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStateReplaceProvider_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "hashicorp/aws", "acmecorp/aws"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "hashicorp/aws", "acmecorp/aws"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "hashicorp/aws", "acmecorp/aws", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateReplaceProvider(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStateReplaceProvider_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -80,6 +133,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "no arguments": { nil, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, }, @@ -94,6 +148,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "too many arguments": { []string{"a", "b", "c", "d"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, }, @@ -108,6 +163,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "unknown flag": { []string{"-invalid", "hashicorp/google", "acmecorp/google"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, FromProviderAddr: "hashicorp/google", @@ -123,10 +179,12 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateReplaceProvider(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/state_replace_provider.go b/internal/command/state_replace_provider.go index 73967806c342..74279202aa76 100644 --- a/internal/command/state_replace_provider.go +++ b/internal/command/state_replace_provider.go @@ -38,6 +38,23 @@ func (c *StateReplaceProviderCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -207,6 +224,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -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. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. From ae1380f729114b923d96d11463aa37f9ba2b653e Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:31:40 +0100 Subject: [PATCH 12/20] command: Add vars to state rm command --- internal/command/arguments/state_rm.go | 9 ++- internal/command/arguments/state_rm_test.go | 90 +++++++++++++++++---- internal/command/state_rm.go | 25 ++++++ 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/internal/command/arguments/state_rm.go b/internal/command/arguments/state_rm.go index 2421903afd60..227173378688 100644 --- a/internal/command/arguments/state_rm.go +++ b/internal/command/arguments/state_rm.go @@ -11,6 +11,9 @@ import ( // StateRm represents the command-line arguments for the state rm command. type StateRm struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // DryRun, if true, prints out what would be removed without actually // removing anything. DryRun bool @@ -41,9 +44,11 @@ type StateRm struct { // representing the best effort interpretation of the arguments. func ParseStateRm(args []string) (*StateRm, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - rm := &StateRm{} + rm := &StateRm{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state rm") + cmdFlags := extendedFlagSet("state rm", nil, nil, rm.Vars) cmdFlags.BoolVar(&rm.DryRun, "dry-run", false, "dry run") cmdFlags.StringVar(&rm.BackupPath, "backup", "-", "backup") cmdFlags.BoolVar(&rm.StateLock, "lock", true, "lock state") diff --git a/internal/command/arguments/state_rm_test.go b/internal/command/arguments/state_rm_test.go index c23887406d49..6e11e6490c31 100644 --- a/internal/command/arguments/state_rm_test.go +++ b/internal/command/arguments/state_rm_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateRm_valid(t *testing.T) { "single address": { []string{"test_instance.foo"}, &StateRm{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, Addrs: []string{"test_instance.foo"}, @@ -26,6 +30,7 @@ func TestParseStateRm_valid(t *testing.T) { "multiple addresses": { []string{"test_instance.foo", "test_instance.bar"}, &StateRm{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, Addrs: []string{"test_instance.foo", "test_instance.bar"}, @@ -34,6 +39,7 @@ func TestParseStateRm_valid(t *testing.T) { "all options": { []string{"-dry-run", "-backup=backup.tfstate", "-lock=false", "-lock-timeout=5s", "-state=state.tfstate", "-ignore-remote-version", "test_instance.foo"}, &StateRm{ + Vars: &Vars{}, DryRun: true, BackupPath: "backup.tfstate", StateLock: false, @@ -45,27 +51,64 @@ func TestParseStateRm_valid(t *testing.T) { }, } + cmpOpts := cmp.Options{ + cmpopts.IgnoreUnexported(Vars{}), + cmpopts.EquateEmpty(), + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateRm(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if got.DryRun != tc.want.DryRun || - got.BackupPath != tc.want.BackupPath || - got.StateLock != tc.want.StateLock || - got.StateLockTimeout != tc.want.StateLockTimeout || - got.StatePath != tc.want.StatePath || - got.IgnoreRemoteVersion != tc.want.IgnoreRemoteVersion { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } - if len(got.Addrs) != len(tc.want.Addrs) { - t.Fatalf("unexpected Addrs length\n got: %d\nwant: %d", len(got.Addrs), len(tc.want.Addrs)) + }) + } +} + +func TestParseStateRm_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateRm(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) } - for i := range got.Addrs { - if got.Addrs[i] != tc.want.Addrs[i] { - t.Fatalf("unexpected Addrs[%d]\n got: %q\nwant: %q", i, got.Addrs[i], tc.want.Addrs[i]) - } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -74,12 +117,16 @@ func TestParseStateRm_valid(t *testing.T) { func TestParseStateRm_invalid(t *testing.T) { testCases := map[string]struct { args []string - wantAddrs int + want *StateRm wantDiags tfdiags.Diagnostics }{ "no arguments": { nil, - 0, + &StateRm{ + Vars: &Vars{}, + BackupPath: "-", + StateLock: true, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -90,7 +137,11 @@ func TestParseStateRm_invalid(t *testing.T) { }, "unknown flag": { []string{"-boop"}, - 0, + &StateRm{ + Vars: &Vars{}, + BackupPath: "-", + StateLock: true, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -106,11 +157,16 @@ func TestParseStateRm_invalid(t *testing.T) { }, } + cmpOpts := cmp.Options{ + cmpopts.IgnoreUnexported(Vars{}), + cmpopts.EquateEmpty(), + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateRm(tc.args) - if len(got.Addrs) != tc.wantAddrs { - t.Fatalf("unexpected Addrs length\n got: %d\nwant: %d", len(got.Addrs), tc.wantAddrs) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) diff --git a/internal/command/state_rm.go b/internal/command/state_rm.go index 1c555497defd..b45dff41af3f 100644 --- a/internal/command/state_rm.go +++ b/internal/command/state_rm.go @@ -34,6 +34,22 @@ func (c *StateRmCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -191,6 +207,15 @@ Options: are incompatible. This may result in an unusable workspace, and should be used with extreme caution. + -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. + ` return strings.TrimSpace(helpText) } From 757d8c2b4d9670fe0fbc74cea3b3dceb1ce57488 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:34:55 +0100 Subject: [PATCH 13/20] command: Add vars to taint command --- internal/command/arguments/taint.go | 9 +++- internal/command/arguments/taint_test.go | 68 +++++++++++++++++++++++- internal/command/taint.go | 26 +++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/internal/command/arguments/taint.go b/internal/command/arguments/taint.go index 3be7a20d2e74..bf7060af2612 100644 --- a/internal/command/arguments/taint.go +++ b/internal/command/arguments/taint.go @@ -11,6 +11,9 @@ import ( // Taint represents the command-line arguments for the taint command. type Taint struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // Address is the address of the resource instance to taint. Address string @@ -44,9 +47,11 @@ type Taint struct { // the best effort interpretation of the arguments. func ParseTaint(args []string) (*Taint, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - taint := &Taint{} + taint := &Taint{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("taint") + cmdFlags := extendedFlagSet("taint", nil, nil, taint.Vars) cmdFlags.BoolVar(&taint.AllowMissing, "allow-missing", false, "allow missing") cmdFlags.StringVar(&taint.BackupPath, "backup", "", "path") cmdFlags.BoolVar(&taint.StateLock, "lock", true, "lock state") diff --git a/internal/command/arguments/taint_test.go b/internal/command/arguments/taint_test.go index 7a6a3c07cd72..f79da4f6e250 100644 --- a/internal/command/arguments/taint_test.go +++ b/internal/command/arguments/taint_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseTaint_valid(t *testing.T) { "defaults with address": { []string{"test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, }, @@ -25,6 +29,7 @@ func TestParseTaint_valid(t *testing.T) { "allow-missing": { []string{"-allow-missing", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", AllowMissing: true, StateLock: true, @@ -33,6 +38,7 @@ func TestParseTaint_valid(t *testing.T) { "backup": { []string{"-backup", "backup.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", BackupPath: "backup.tfstate", StateLock: true, @@ -41,12 +47,14 @@ func TestParseTaint_valid(t *testing.T) { "lock disabled": { []string{"-lock=false", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", }, }, "lock-timeout": { []string{"-lock-timeout=10s", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StateLockTimeout: 10 * time.Second, @@ -55,6 +63,7 @@ func TestParseTaint_valid(t *testing.T) { "state": { []string{"-state=foo.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StatePath: "foo.tfstate", @@ -63,6 +72,7 @@ func TestParseTaint_valid(t *testing.T) { "state-out": { []string{"-state-out=foo.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StateOutPath: "foo.tfstate", @@ -71,6 +81,7 @@ func TestParseTaint_valid(t *testing.T) { "ignore-remote-version": { []string{"-ignore-remote-version", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, IgnoreRemoteVersion: true, @@ -88,6 +99,7 @@ func TestParseTaint_valid(t *testing.T) { "module.child.test_instance.foo", }, &Taint{ + Vars: &Vars{}, Address: "module.child.test_instance.foo", AllowMissing: true, BackupPath: "backup.tfstate", @@ -99,19 +111,66 @@ func TestParseTaint_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseTaint(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseTaint_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseTaint(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseTaint_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -121,6 +180,7 @@ func TestParseTaint_invalid(t *testing.T) { "unknown flag": { []string{"-unknown"}, &Taint{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -139,6 +199,7 @@ func TestParseTaint_invalid(t *testing.T) { "missing address": { nil, &Taint{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -152,6 +213,7 @@ func TestParseTaint_invalid(t *testing.T) { "too many arguments": { []string{"test_instance.foo", "test_instance.bar"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, }, @@ -165,10 +227,12 @@ func TestParseTaint_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseTaint(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/taint.go b/internal/command/taint.go index ba9ef4c82d90..9b50a0508f5d 100644 --- a/internal/command/taint.go +++ b/internal/command/taint.go @@ -37,6 +37,23 @@ func (c *TaintCommand) Run(rawArgs []string) int { c.Meta.stateOutPath = parsedArgs.StateOutPath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + var diags tfdiags.Diagnostics addr, addrDiags := addrs.ParseAbsResourceInstanceStr(parsedArgs.Address) @@ -224,6 +241,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -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. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. From 657be30790cb35d5c429227362fa07cd22441dd6 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:48:32 +0100 Subject: [PATCH 14/20] command: Add vars to get command (and refactor it) --- internal/command/arguments/get.go | 54 +++++++++ internal/command/arguments/get_test.go | 161 +++++++++++++++++++++++++ internal/command/get.go | 48 +++++--- 3 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 internal/command/arguments/get.go create mode 100644 internal/command/arguments/get_test.go diff --git a/internal/command/arguments/get.go b/internal/command/arguments/get.go new file mode 100644 index 000000000000..2e1d125a0808 --- /dev/null +++ b/internal/command/arguments/get.go @@ -0,0 +1,54 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Get represents the command-line arguments for the get command. +type Get struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + + // Update, if true, checks already-downloaded modules for available + // updates and installs the newest versions available. + Update bool + + // TestDirectory is the Terraform test directory. + TestDirectory string +} + +// ParseGet processes CLI arguments, returning a Get value and diagnostics. +// If errors are encountered, a Get value is still returned representing +// the best effort interpretation of the arguments. +func ParseGet(args []string) (*Get, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + get := &Get{ + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("get", nil, nil, get.Vars) + cmdFlags.BoolVar(&get.Update, "update", false, "update") + cmdFlags.StringVar(&get.TestDirectory, "test-directory", "tests", "test-directory") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + )) + } + + return get, diags +} diff --git a/internal/command/arguments/get_test.go b/internal/command/arguments/get_test.go new file mode 100644 index 000000000000..3aaf5b381ef4 --- /dev/null +++ b/internal/command/arguments/get_test.go @@ -0,0 +1,161 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseGet_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Get + }{ + "defaults": { + nil, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + }, + "update": { + []string{"-update"}, + &Get{ + Vars: &Vars{}, + Update: true, + TestDirectory: "tests", + }, + }, + "test-directory": { + []string{"-test-directory", "custom-tests"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "custom-tests", + }, + }, + "all options": { + []string{ + "-update", + "-test-directory", "custom-tests", + }, + &Get{ + Vars: &Vars{}, + Update: true, + TestDirectory: "custom-tests", + }, + }, + } + + cmpOpts := cmp.Options{cmpopts.IgnoreUnexported(Vars{})} + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGet(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseGet_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGet(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + +func TestParseGet_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Get + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-boop"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -boop", + ), + }, + }, + "too many arguments": { + []string{"foo", "bar"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + } + + cmpOpts := cmp.Options{cmpopts.IgnoreUnexported(Vars{})} + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseGet(tc.args) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/get.go b/internal/command/get.go index 7de3f96be616..98e1f7093a4b 100644 --- a/internal/command/get.go +++ b/internal/command/get.go @@ -5,9 +5,9 @@ package command import ( "context" - "fmt" "strings" + "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,16 +18,26 @@ type GetCommand struct { } func (c *GetCommand) Run(args []string) int { - var update bool - var testsDirectory string - - args = c.Meta.process(args) - cmdFlags := c.Meta.defaultFlagSet("get") - cmdFlags.BoolVar(&update, "update", false, "update") - cmdFlags.StringVar(&testsDirectory, "test-directory", "tests", "test-directory") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) + parsedArgs, diags := arguments.ParseGet(c.Meta.process(args)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } @@ -35,7 +45,7 @@ func (c *GetCommand) Run(args []string) int { ctx, done := c.InterruptibleContext(c.CommandContext()) defer done() - path, err := ModulePath(cmdFlags.Args()) + path, err := ModulePath(nil) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -43,7 +53,8 @@ func (c *GetCommand) Run(args []string) int { path = c.normalizePath(path) - abort, diags := getModules(ctx, &c.Meta, path, testsDirectory, update) + abort, moreDiags := getModules(ctx, &c.Meta, path, parsedArgs.TestDirectory, parsedArgs.Update) + diags = diags.Append(moreDiags) c.showDiagnostics(diags) if abort || diags.HasErrors() { return 1 @@ -75,7 +86,16 @@ Options: -no-color Disable text coloring in the output. - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -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. ` return strings.TrimSpace(helpText) From b4ecfc2644f5ee54883475875c86aaab96c1fee4 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 11:49:22 +0100 Subject: [PATCH 15/20] Improve ParseVariableValues signature with a helper Instead of passing a "magic" boolean as third parameter, we now have two functions `ParseConstVariableValues` and `ParseVariableValues`. --- internal/backend/backendrun/unparsed_value.go | 21 ++++++++++++------- .../backend/backendrun/unparsed_value_test.go | 4 ++-- internal/backend/local/backend_apply.go | 2 +- internal/backend/local/backend_local.go | 4 ++-- internal/backend/remote/backend_common.go | 2 +- internal/backend/remote/backend_context.go | 2 +- internal/cloud/backend_context.go | 2 +- internal/command/meta_config.go | 6 +++--- internal/command/show.go | 2 +- 9 files changed, 26 insertions(+), 19 deletions(-) diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index e3724574135a..695232b47c90 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -146,13 +146,20 @@ func isDefinedAny(name string, maps ...terraform.InputValues) bool { // InputValues may be incomplete but will include the subset of variables // that were successfully processed, allowing for careful analysis of the // partial result. -// -// constOnly will only raise a diagnostic error if a required variable is -// missing and is marked as const. Since configuration loading will always -// require values for constant variables, this allows us to use this -// function in both configuration loading and plan/apply contexts where all -// variables are required. -func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable, constOnly bool) (terraform.InputValues, tfdiags.Diagnostics) { +func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + return parseVariableValues(vv, decls, false) +} + +// ParseConstVariableValues is like ParseVariableValues but only produces +// errors for missing const variables. Non-const required variables that are +// missing will still receive placeholder values but won't produce errors. +// This is used during early configuration loading (e.g. module installation) +// where only const variables are needed for module source resolution. +func ParseConstVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + return parseVariableValues(vv, decls, true) +} + +func parseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable, constOnly bool) (terraform.InputValues, tfdiags.Diagnostics) { ret, diags := ParseDeclaredVariableValues(vv, decls) undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) diff --git a/internal/backend/backendrun/unparsed_value_test.go b/internal/backend/backendrun/unparsed_value_test.go index fd970cf2f424..b3172ee1de1a 100644 --- a/internal/backend/backendrun/unparsed_value_test.go +++ b/internal/backend/backendrun/unparsed_value_test.go @@ -162,7 +162,7 @@ func TestUnparsedValue(t *testing.T) { }) t.Run("ParseVariableValues", func(t *testing.T) { - gotVals, diags := ParseVariableValues(vv, decls, false) + gotVals, diags := ParseVariableValues(vv, decls) for _, diag := range diags { t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) } @@ -278,7 +278,7 @@ func TestUnparsedValue(t *testing.T) { }, } - gotVals, diags := ParseVariableValues(vv, decls, true) + gotVals, diags := ParseConstVariableValues(vv, decls) if got, want := len(diags), 1; got != want { t.Fatalf("wrong number of diagnostics %d; want %d", got, want) diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 1311229f04ee..3c250069397c 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -266,7 +266,7 @@ func (b *Local) opApply( // same parsing logic from the plan to generate the diagnostics. undeclaredVariables := map[string]arguments.UnparsedVariableValue{} - parsedVars, _ := backendrun.ParseVariableValues(op.Variables, lr.Config.Module.Variables, false) + parsedVars, _ := backendrun.ParseVariableValues(op.Variables, lr.Config.Module.Variables) for varName := range op.Variables { parsedVar, parsed := parsedVars[varName] diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 97c42fc45dfe..768b94355a28 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -167,7 +167,7 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, rootMod.Variables, op.UIIn) } - variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables, false) + variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -271,7 +271,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade return nil, nil, diags } - variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags diff --git a/internal/backend/remote/backend_common.go b/internal/backend/remote/backend_common.go index f76644ac1f82..137c01aa4154 100644 --- a/internal/backend/remote/backend_common.go +++ b/internal/backend/remote/backend_common.go @@ -259,7 +259,7 @@ func (b *Remote) hasExplicitVariableValues(op *backendrun.Operation) bool { // goal here is just to make a best effort count of how many variable // values are coming from -var or -var-file CLI arguments so that we can // hint the user that those are not supported for remote operations. - variables, _ := backendrun.ParseVariableValues(op.Variables, config.Variables, false) + variables, _ := backendrun.ParseVariableValues(op.Variables, config.Variables) // Check for explicitly-defined (-var and -var-file) variables, which the // remote backend does not support. All other source types are okay, diff --git a/internal/backend/remote/backend_context.go b/internal/backend/remote/backend_context.go index 47d68f2b6369..db72d709edac 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -135,7 +135,7 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state } if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index d4df1b9a7f24..e3fc587a33b6 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -135,7 +135,7 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem } if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 5dda59b46c22..5972a8f74c20 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -58,7 +58,7 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) cfg.Root = cfg // Root module is self-referential. return cfg, diags } - vars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) + vars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) diags = diags.Append(parseDiags) if parseDiags.HasErrors() { return nil, diags @@ -95,7 +95,7 @@ func (m *Meta) loadConfigWithTests(rootDir, testDir string) (*configs.Config, tf cfg.Root = cfg // Root module is self-referential. return cfg, diags } - vars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) + vars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) diags = diags.Append(parseDiags) if parseDiags.HasErrors() { return nil, diags @@ -243,7 +243,7 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg } initializer := func(rootMod *configs.Module, walker configs.ModuleWalker) (*configs.Config, tfdiags.Diagnostics) { - variables, diags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) + variables, diags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) ctx, ctxDiags := terraform.NewContext(&terraform.ContextOpts{ Parallelism: 1, }) diff --git a/internal/command/show.go b/internal/command/show.go index f4055eaacb7f..461343098bd6 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -354,7 +354,7 @@ func readConfig(r *planfile.Reader, allowLanguageExperiments bool, variableValue return nil, diags } - variables, varDiags := backendrun.ParseVariableValues(variableValues, rootMod.Variables, true) + variables, varDiags := backendrun.ParseConstVariableValues(variableValues, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, diags From 0e5546253f560505041f6e322813cae939adca9d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 12:59:14 +0100 Subject: [PATCH 16/20] Add check for missing const variables --- internal/backend/backendrun/unparsed_value.go | 15 ++++ .../backend/backendrun/unparsed_value_test.go | 74 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index 695232b47c90..298cc969fad9 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -219,3 +219,18 @@ func parseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls ma return ret, diags } + +// HasUnsatisfiedConstVariables checks whether any const variables declared in +// the given module are required but not yet present in the provided variable +// values map. This is used to determine whether we need to fetch additional +// variable values from a backend before loading the full configuration. +func HasUnsatisfiedConstVariables(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) bool { + for name, vc := range decls { + if vc.Const && vc.Required() { + if _, defined := vv[name]; !defined { + return true + } + } + } + return false +} diff --git a/internal/backend/backendrun/unparsed_value_test.go b/internal/backend/backendrun/unparsed_value_test.go index b3172ee1de1a..ce299016cb09 100644 --- a/internal/backend/backendrun/unparsed_value_test.go +++ b/internal/backend/backendrun/unparsed_value_test.go @@ -339,6 +339,80 @@ func TestUnparsedValue(t *testing.T) { }) } +func TestHasUnsatisfiedConstVariables(t *testing.T) { + testCases := map[string]struct { + vv map[string]arguments.UnparsedVariableValue + decls map[string]*configs.Variable + want bool + }{ + "no variables": { + vv: nil, + decls: map[string]*configs.Variable{}, + want: false, + }, + "no const variables": { + vv: nil, + decls: map[string]*configs.Variable{ + "regular": { + Name: "regular", + }, + }, + want: false, + }, + "const with default": { + vv: nil, + decls: map[string]*configs.Variable{ + "has_default": { + Name: "has_default", + Const: true, + Default: cty.StringVal("default"), + }, + }, + want: false, + }, + "const required and missing": { + vv: nil, + decls: map[string]*configs.Variable{ + "required_const": { + Name: "required_const", + Const: true, + }, + }, + want: true, + }, + "const required but provided": { + vv: map[string]arguments.UnparsedVariableValue{ + "required_const": testUnparsedVariableValue("value"), + }, + decls: map[string]*configs.Variable{ + "required_const": { + Name: "required_const", + Const: true, + }, + }, + want: false, + }, + "non-const required and missing": { + vv: nil, + decls: map[string]*configs.Variable{ + "regular_required": { + Name: "regular_required", + }, + }, + want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := HasUnsatisfiedConstVariables(tc.vv, tc.decls) + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + type testUnparsedVariableValue string func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { From e3bd9d717b8add0d02e4d6b7b4f3cf27349a47c5 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 15:47:18 +0100 Subject: [PATCH 17/20] Still require const variables when AllowUnsetVariables is set We never want to stub const variables, so we will always try to get values for them. The cloud backend now always fetches variable values so const vars can be satisfied. --- internal/backend/local/backend_local.go | 6 +- internal/cloud/backend_context.go | 138 ++++++++++++------------ 2 files changed, 69 insertions(+), 75 deletions(-) diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 768b94355a28..90f63a6f2559 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -496,8 +496,8 @@ func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[st func (b *Local) stubUnsetRequiredVariables(existing map[string]arguments.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]arguments.UnparsedVariableValue { var missing bool // Do we need to add anything? for name, vc := range vcs { - if !vc.Required() { - continue // We only stub required variables + if !vc.Required() || vc.Const { + continue // We only stub non-const required variables } if _, exists := existing[name]; !exists { missing = true @@ -512,7 +512,7 @@ func (b *Local) stubUnsetRequiredVariables(existing map[string]arguments.Unparse maps.Copy(ret, existing) // don't use clone here, so we can return a non-nil map for name, vc := range vcs { - if !vc.Required() { + if !vc.Required() || vc.Const { continue } if _, exists := existing[name]; !exists { diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index e3fc587a33b6..4b39db0a6a68 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -86,63 +86,41 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem return nil, nil, diags } - if op.AllowUnsetVariables { - // If we're not going to use the variables in an operation we'll be - // more lax about them, stubbing out any unset ones as unknown. - // This gives us enough information to produce a consistent context, - // but not enough information to run a real operation (plan, apply, etc) - ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, rootMod.Variables) - } else { - // The underlying API expects us to use the opaque workspace id to request - // variables, so we'll need to look that up using our organization name - // and workspace name. - remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) - if err != nil { - diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) - return nil, nil, diags - } - w, err := b.fetchWorkspace(context.Background(), b.Organization, op.Workspace) - if err != nil { - diags = diags.Append(fmt.Errorf("error loading workspace: %w", err)) - return nil, nil, diags - } - - if isLocalExecutionMode(w.ExecutionMode) { - log.Printf("[TRACE] skipping retrieving variables from workspace %s/%s (%s), workspace is in Local Execution mode", remoteWorkspaceName, b.Organization, remoteWorkspaceID) - } else { - log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.Organization, remoteWorkspaceID) - tfeVariables, err := b.client.Variables.ListAll(context.Background(), remoteWorkspaceID, nil) - if err != nil && err != tfe.ErrResourceNotFound { - diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) - return nil, nil, diags - } - - if tfeVariables != nil { - if op.Variables == nil { - op.Variables = make(map[string]arguments.UnparsedVariableValue) - } - - for _, v := range tfeVariables.Items { - if v.Category == tfe.CategoryTerraform { - if _, ok := op.Variables[v.Key]; !ok { - op.Variables[v.Key] = &remoteStoredVariableValue{ - definition: v, - } - } - } - } - } + // If we're not going to use the variables in an operation we'll be + // more lax about them, stubbing out any unset ones as unknown. + // This gives us enough information to produce a consistent context, + // but not enough information to run a real operation (plan, apply, etc). + // + // However, const variables must always be resolved since they're + // needed during early configuration loading (e.g. module sources). + // We fetch backend variables so const vars can be satisfied. + fetchedVars, fetchDiags := b.FetchVariables(context.Background(), op.Workspace) + diags = diags.Append(fetchDiags) + if fetchDiags.HasErrors() { + return nil, nil, diags + } + if len(fetchedVars) > 0 { + if op.Variables == nil { + op.Variables = make(map[string]arguments.UnparsedVariableValue) } - - if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) - diags = diags.Append(varDiags) - if diags.HasErrors() { - return nil, nil, diags + for k, v := range fetchedVars { + if _, ok := op.Variables[k]; !ok { + op.Variables[k] = v } - ret.PlanOpts.SetVariables = variables } } + var variables terraform.InputValues + var varDiags tfdiags.Diagnostics + if op.AllowUnsetVariables { + variables, varDiags = backendrun.ParseConstVariableValues(op.Variables, rootMod.Variables) + } else { + variables, varDiags = backendrun.ParseVariableValues(op.Variables, rootMod.Variables) + } + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags + } + ret.PlanOpts.SetVariables = variables tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) @@ -202,31 +180,47 @@ func (b *Cloud) getRemoteWorkspaceID(ctx context.Context, localWorkspaceName str return remoteWorkspace.ID, nil } -func stubAllVariables(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) terraform.InputValues { - ret := make(terraform.InputValues, len(decls)) +// FetchVariables implements backendrun.ConstVariableSupplier by retrieving +// Terraform variables from the HCP Terraform or Terraform Enterprise workspace. +func (b *Cloud) FetchVariables(ctx context.Context, workspace string) (map[string]arguments.UnparsedVariableValue, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics - for name, cfg := range decls { - raw, exists := vv[name] - if !exists { - ret[name] = &terraform.InputValue{ - Value: cty.UnknownVal(cfg.Type), - SourceType: terraform.ValueFromConfig, - } - continue - } + remoteWorkspaceID, err := b.getRemoteWorkspaceID(ctx, workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) + return nil, diags + } - val, diags := raw.ParseVariableValue(cfg.ParsingMode) - if diags.HasErrors() { - ret[name] = &terraform.InputValue{ - Value: cty.UnknownVal(cfg.Type), - SourceType: terraform.ValueFromConfig, + w, err := b.fetchWorkspace(ctx, b.Organization, workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error loading workspace: %w", err)) + return nil, diags + } + + if isLocalExecutionMode(w.ExecutionMode) { + log.Printf("[TRACE] cloud: skipping variable fetch for workspace %s/%s (%s), workspace is in Local Execution mode", b.getRemoteWorkspaceName(workspace), b.Organization, remoteWorkspaceID) + return nil, nil + } + + log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", b.getRemoteWorkspaceName(workspace), b.Organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.ListAll(ctx, remoteWorkspaceID, nil) + if err != nil && err != tfe.ErrResourceNotFound { + diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) + return nil, diags + } + + result := make(map[string]arguments.UnparsedVariableValue) + if tfeVariables != nil { + for _, v := range tfeVariables.Items { + if v.Category == tfe.CategoryTerraform { + result[v.Key] = &remoteStoredVariableValue{ + definition: v, + } } - continue } - ret[name] = val } - return ret + return result, nil } // remoteStoredVariableValue is a backendrun.UnparsedVariableValue implementation From a3c4bb2cfff51d47ea1c85f9fbd395708293761d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 15:50:18 +0100 Subject: [PATCH 18/20] Allow commands to fetch backend variables The new `resolveConstVariables` helper checks if const variables are present in the configuration, but don't have a value yet. In that case we try to fetch them via a backend. This will only work for backends that implement the ConstVariableSupplier interface. (Which the `cloud` backend does) The precedence is the same as for existing commands: a CLI supplied value will override a value from a workspace --- .../backend/backendrun/const_variables.go | 23 ++++++++ internal/cloud/backend.go | 1 + internal/command/meta_config.go | 55 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 internal/backend/backendrun/const_variables.go diff --git a/internal/backend/backendrun/const_variables.go b/internal/backend/backendrun/const_variables.go new file mode 100644 index 000000000000..6a85dfee25d5 --- /dev/null +++ b/internal/backend/backendrun/const_variables.go @@ -0,0 +1,23 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package backendrun + +import ( + "context" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// ConstVariableSupplier is an optional interface that backends can implement +// to supply variable values from their remote storage. This is used to fetch +// const variable values that are needed during early configuration loading +// (e.g., for module source resolution), before a full operation is started. +type ConstVariableSupplier interface { + // FetchVariables retrieves Terraform variable values stored in the + // backend for the given workspace. Only variables that are relevant to + // Terraform (as opposed to environment variables or other categories) + // should be returned. + FetchVariables(ctx context.Context, workspace string) (map[string]arguments.UnparsedVariableValue, tfdiags.Diagnostics) +} diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 59e8d8ca3780..5dae9abda868 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -123,6 +123,7 @@ type Cloud struct { var _ backend.Backend = (*Cloud)(nil) var _ backendrun.OperationsBackend = (*Cloud)(nil) var _ backendrun.Local = (*Cloud)(nil) +var _ backendrun.ConstVariableSupplier = (*Cloud)(nil) // New creates a new initialized cloud backend. func New(services *disco.Disco) *Cloud { diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 5972a8f74c20..c476378ff1b5 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -18,6 +18,7 @@ import ( "go.opentelemetry.io/otel/trace" "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -36,6 +37,60 @@ func (m *Meta) normalizePath(path string) string { return m.WorkingDir.NormalizePath(path) } +// resolveConstVariables checks whether the root module in rootDir declares any +// const variables that are required but not yet provided via CLI flags. If so, +// it attempts to fetch them from the configured backend (e.g. HCP Terraform +// workspace variables). This must be called before loadConfig or +// loadConfigWithTests so that const variable values are available during +// module source resolution. +// +// If no const variables are unsatisfied, or if the backend does not support +// supplying variables, this method is a no-op. +func (m *Meta) resolveConstVariables(rootDir string, viewType arguments.ViewType) tfdiags.Diagnostics { + rootMod, diags := m.loadSingleModule(rootDir) + if diags.HasErrors() { + return diags + } + + if !backendrun.HasUnsatisfiedConstVariables(m.VariableValues, rootMod.Variables) { + return nil + } + + b, backendDiags := m.backend(rootDir, viewType) + if backendDiags.HasErrors() { + // Don't report backend init errors here; they'll surface later. + return nil + } + + supplier, ok := b.(backendrun.ConstVariableSupplier) + if !ok { + return nil + } + + workspace, err := m.Workspace() + if err != nil { + diags = diags.Append(err) + return diags + } + + vars, fetchDiags := supplier.FetchVariables(context.Background(), workspace) + diags = diags.Append(fetchDiags) + if fetchDiags.HasErrors() { + return diags + } + + if m.VariableValues == nil { + m.VariableValues = make(map[string]arguments.UnparsedVariableValue) + } + for k, v := range vars { + if _, exists := m.VariableValues[k]; !exists { + m.VariableValues[k] = v + } + } + + return diags +} + // loadConfig reads a configuration from the given directory, which should // contain a root module and have already have any required descendant modules // installed. From ec5df8b29cc7f791ac5908930f124e7f2a6ea853 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 16:26:15 +0100 Subject: [PATCH 19/20] Add changelog --- .changes/v1.15/ENHANCEMENTS-20260313-162537.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/ENHANCEMENTS-20260313-162537.yaml diff --git a/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml b/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml new file mode 100644 index 000000000000..bf9807ddca57 --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: As part of supporting variables in module sources, most commands now accept variable values +time: 2026-03-13T16:25:37.792809+01:00 +custom: + Issue: "38276" From c25adc4ec94fc22e960a6336a4fe5013a0b886b7 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:57:44 +0100 Subject: [PATCH 20/20] Potentially fetch backend variables for most commands * modules * providers * providers lock * providers mirror * validate * state mv * state pull * state push * state replace provider * state rm * taint * get --- internal/command/get.go | 6 ++++++ internal/command/meta.go | 5 +++++ internal/command/modules.go | 6 ++++++ internal/command/providers.go | 6 ++++++ internal/command/providers_lock.go | 6 ++++++ internal/command/providers_mirror.go | 6 ++++++ internal/command/validate.go | 5 +++++ 7 files changed, 40 insertions(+) diff --git a/internal/command/get.go b/internal/command/get.go index 98e1f7093a4b..c3587aa62fa1 100644 --- a/internal/command/get.go +++ b/internal/command/get.go @@ -51,6 +51,12 @@ func (c *GetCommand) Run(args []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(path, arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + path = c.normalizePath(path) abort, moreDiags := getModules(ctx, &c.Meta, path, parsedArgs.TestDirectory, parsedArgs.Update) diff --git a/internal/command/meta.go b/internal/command/meta.go index bbdf4ed09f0d..7a9dee73083b 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -831,6 +831,11 @@ func (m *Meta) checkRequiredVersion() tfdiags.Diagnostics { return diags } + diags = diags.Append(m.resolveConstVariables(pwd, arguments.ViewHuman)) + if diags.HasErrors() { + return diags + } + config, configDiags := m.loadConfig(pwd) if configDiags.HasErrors() { diags = diags.Append(configDiags) diff --git a/internal/command/modules.go b/internal/command/modules.go index 95d8acb7ad7e..93584285eb2d 100644 --- a/internal/command/modules.go +++ b/internal/command/modules.go @@ -80,6 +80,12 @@ func (c *ModulesCommand) Run(rawArgs []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(rootModPath, args.ViewType)) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + config, confDiags := c.loadConfig(rootModPath) // Here we check if there are any uninstalled dependencies versionDiags := terraform.CheckCoreVersionRequirements(config) diff --git a/internal/command/providers.go b/internal/command/providers.go index 7e936dbee882..1e6c2714f550 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -84,6 +84,12 @@ func (c *ProvidersCommand) Run(args []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(configPath, arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, configDiags := c.loadConfigWithTests(configPath, parsedArgs.TestsDirectory) diags = diags.Append(configDiags) if configDiags.HasErrors() { diff --git a/internal/command/providers_lock.go b/internal/command/providers_lock.go index 8f9a65659d0d..09ee1a791d03 100644 --- a/internal/command/providers_lock.go +++ b/internal/command/providers_lock.go @@ -116,6 +116,12 @@ func (c *ProvidersLockCommand) Run(args []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(".", arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfigWithTests(".", parsedArgs.TestsDirectory) diags = diags.Append(confDiags) reqs, hclDiags := config.ProviderRequirements() diff --git a/internal/command/providers_mirror.go b/internal/command/providers_mirror.go index d17f5481893e..97881300bc9f 100644 --- a/internal/command/providers_mirror.go +++ b/internal/command/providers_mirror.go @@ -78,6 +78,12 @@ func (c *ProvidersMirrorCommand) Run(args []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(".", arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfig(".") diags = diags.Append(confDiags) reqs, moreDiags := config.ProviderRequirements() diff --git a/internal/command/validate.go b/internal/command/validate.go index 5ac75db90289..6a49ec2f55db 100644 --- a/internal/command/validate.go +++ b/internal/command/validate.go @@ -101,6 +101,11 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics var cfg *configs.Config + diags = diags.Append(c.resolveConstVariables(dir, c.ParsedArgs.ViewType)) + if diags.HasErrors() { + return diags + } + if c.ParsedArgs.NoTests { cfg, diags = c.loadConfig(dir) } else {