From 8664749b591cf7559e8dd03b47337aafd52eeeb1 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 9 Oct 2019 14:29:40 -0700 Subject: [PATCH] backend: Allow certain commands to opt out of required variable checks Terraform Core expects all variables to be set, but for some ancillary commands it's fine for them to just be set to placeholders because the variable values themselves are not key to the command's functionality as long as the terraform.Context is still self-consistent. For such commands, rather than prompting for interactive input for required variables we'll just stub them out as unknowns to reflect that they are placeholders for values that a user would normally need to provide. This achieves a similar effect to how these commands behaved before, but without the tendency to produce a slightly invalid terraform.Context that would fail in strange ways when asked to run certain operations. --- backend/backend.go | 10 ++++ backend/local/backend_local.go | 87 ++++++++++++++++++++++++++++++++-- command/console.go | 1 + command/console_test.go | 49 +++++++++++++++++++ command/graph.go | 1 + command/providers_schema.go | 1 + command/show.go | 1 + command/state_show.go | 1 + 8 files changed, 146 insertions(+), 5 deletions(-) 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()