diff --git a/backend/backend.go b/backend/backend.go index 6f220b6b5..268b52f67 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -196,6 +196,16 @@ type Operation struct { Targets []addrs.Targetable Variables map[string]UnparsedVariableValue + // Some operations use root module variables only opportunistically or + // don't need them at all. If this flag is set, the backend must treat + // all variables as optional and provide an unknown value for any required + // variables that aren't set in order to allow partial evaluation against + // the resulting incomplete context. + // + // This flag is honored only if PlanFile isn't set. If PlanFile is set then + // the variables set in the plan are used instead, and they must be valid. + AllowUnsetVariables bool + // Input/output/control options. UIIn terraform.UIInput UIOut terraform.UIOutput diff --git a/backend/local/backend_local.go b/backend/local/backend_local.go index 433c628ea..3a308345d 100644 --- a/backend/local/backend_local.go +++ b/backend/local/backend_local.go @@ -136,11 +136,20 @@ func (b *Local) contextDirect(op *backend.Operation, opts terraform.ContextOpts) } opts.Config = config - // If interactive input is enabled, we might gather some more variable - // values through interactive prompts. - // TODO: Need to route the operation context through into here, so that - // the interactive prompts can be sensitive to its timeouts/etc. - rawVariables := b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, opts.UIInput) + var rawVariables map[string]backend.UnparsedVariableValue + if op.AllowUnsetVariables { + // Rather than prompting for input, we'll just stub out the required + // but unset variables with unknown values to represent that they are + // placeholders for values the user would need to provide for other + // operations. + rawVariables = b.stubUnsetRequiredVariables(op.Variables, config.Module.Variables) + } else { + // If interactive input is enabled, we might gather some more variable + // values through interactive prompts. + // TODO: Need to route the operation context through into here, so that + // the interactive prompts can be sensitive to its timeouts/etc. + rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, opts.UIInput) + } variables, varDiags := backend.ParseVariableValues(rawVariables, config.Module.Variables) diags = diags.Append(varDiags) @@ -315,6 +324,60 @@ func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[st return ret } +// stubUnsetVariables ensures that all required variables defined in the +// configuration exist in the resulting map, by adding new elements as necessary. +// +// The stubbed value of any additions will be an unknown variable conforming +// to the variable's configured type constraint, meaning that no particular +// value is known and that one must be provided by the user in order to get +// a complete result. +// +// Unset optional attributes (those with default values) will not be populated +// by this function, under the assumption that a later step will handle those. +// In this sense, stubUnsetRequiredVariables is essentially a non-interactive, +// non-error-producing variant of interactiveCollectVariables that creates +// placeholders for values the user would be prompted for interactively on +// other operations. +// +// This function should be used only in situations where variables values +// will not be directly used and the variables map is being constructed only +// to produce a complete Terraform context for some ancillary functionality +// like "terraform console", "terraform state ...", etc. +// +// This function is guaranteed not to modify the given map, but it may return +// the given map unchanged if no additions are required. If additions are +// required then the result will be a new map containing everything in the +// given map plus additional elements. +func (b *Local) stubUnsetRequiredVariables(existing map[string]backend.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]backend.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 _, exists := existing[name]; !exists { + missing = true + } + } + if !missing { + return existing + } + + // If we get down here then there's at least one variable value to add. + ret := make(map[string]backend.UnparsedVariableValue, len(vcs)) + for k, v := range existing { + ret[k] = v + } + for name, vc := range vcs { + if !vc.Required() { + continue + } + if _, exists := existing[name]; !exists { + ret[name] = unparsedUnknownVariableValue{Name: name, WantType: vc.Type} + } + } + return ret +} + type unparsedInteractiveVariableValue struct { Name, RawValue string } @@ -334,6 +397,20 @@ func (v unparsedInteractiveVariableValue) ParseVariableValue(mode configs.Variab }, diags } +type unparsedUnknownVariableValue struct { + Name string + WantType cty.Type +} + +var _ backend.UnparsedVariableValue = unparsedUnknownVariableValue{} + +func (v unparsedUnknownVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.UnknownVal(v.WantType), + SourceType: terraform.ValueFromInput, + }, nil +} + const validateWarnHeader = ` There are warnings related to your configuration. If no errors occurred, Terraform will continue despite these warnings. It is a good idea to resolve diff --git a/command/console.go b/command/console.go index ea596c783..4992496b3 100644 --- a/command/console.go +++ b/command/console.go @@ -77,6 +77,7 @@ func (c *ConsoleCommand) Run(args []string) int { opReq := c.Operation(b) opReq.ConfigDir = configPath opReq.ConfigLoader, err = c.initConfigLoader() + opReq.AllowUnsetVariables = true // we'll just evaluate them as unknown if err != nil { diags = diags.Append(err) c.showDiagnostics(diags) diff --git a/command/console_test.go b/command/console_test.go index c30ade9e3..d0b66701f 100644 --- a/command/console_test.go +++ b/command/console_test.go @@ -84,3 +84,52 @@ func TestConsole_tfvars(t *testing.T) { t.Fatalf("bad: %q", actual) } } + +func TestConsole_unsetRequiredVars(t *testing.T) { + // This test is verifying that it's possible to run "terraform console" + // without providing values for all required variables, without + // "terraform console" producing an interactive prompt for those variables + // or producing errors. Instead, it should allow evaluation in that + // partial context but see the unset variables values as being unknown. + + tmp, cwd := testCwd(t) + defer testFixCwd(t, tmp, cwd) + + p := testProvider() + ui := new(cli.MockUi) + c := &ConsoleCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: ui, + }, + } + + var output bytes.Buffer + defer testStdinPipe(t, strings.NewReader("var.foo\n"))() + outCloser := testStdoutCapture(t, &output) + + args := []string{ + // This test fixture includes variable "foo" {}, which we are + // intentionally not setting here. + testFixturePath("apply-vars"), + } + code := c.Run(args) + outCloser() + + // Because we're running "terraform console" in piped input mode, we're + // expecting it to return a nonzero exit status here but the message + // must be the one indicating that it did attempt to evaluate var.foo and + // got an unknown value in return, rather than an error about var.foo + // not being set or a failure to prompt for it. + if code == 0 { + t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + } + + // The error message should be the one console produces when it encounters + // an unknown value. + got := ui.ErrorWriter.String() + want := `Error: Result depends on values that cannot be determined` + if !strings.Contains(got, want) { + t.Fatalf("wrong output\ngot:\n%s\n\nwant string containing %q", got, want) + } +} diff --git a/command/graph.go b/command/graph.go index 113a41527..af8a2556f 100644 --- a/command/graph.go +++ b/command/graph.go @@ -96,6 +96,7 @@ func (c *GraphCommand) Run(args []string) int { opReq.ConfigDir = configPath opReq.ConfigLoader, err = c.initConfigLoader() opReq.PlanFile = planFile + opReq.AllowUnsetVariables = true if err != nil { diags = diags.Append(err) c.showDiagnostics(diags) diff --git a/command/providers_schema.go b/command/providers_schema.go index f26a1c96f..2f1c5f941 100644 --- a/command/providers_schema.go +++ b/command/providers_schema.go @@ -81,6 +81,7 @@ func (c *ProvidersSchemaCommand) Run(args []string) int { opReq := c.Operation(b) opReq.ConfigDir = cwd opReq.ConfigLoader, err = c.initConfigLoader() + opReq.AllowUnsetVariables = true if err != nil { diags = diags.Append(err) c.showDiagnostics(diags) diff --git a/command/show.go b/command/show.go index 20c918384..2c75f9301 100644 --- a/command/show.go +++ b/command/show.go @@ -91,6 +91,7 @@ func (c *ShowCommand) Run(args []string) int { opReq.ConfigDir = cwd opReq.PlanFile = planFile opReq.ConfigLoader, err = c.initConfigLoader() + opReq.AllowUnsetVariables = true if err != nil { diags = diags.Append(err) c.showDiagnostics(diags) diff --git a/command/state_show.go b/command/state_show.go index bd9d2c48b..c2d9abd85 100644 --- a/command/state_show.go +++ b/command/state_show.go @@ -72,6 +72,7 @@ func (c *StateShowCommand) Run(args []string) int { // Build the operation (required to get the schemas) opReq := c.Operation(b) + opReq.AllowUnsetVariables = true opReq.ConfigDir = cwd opReq.ConfigLoader, err = c.initConfigLoader()