diff --git a/internal/backend/unparsed_value.go b/internal/backend/unparsed_value.go index abd16ef9ea..91c9825826 100644 --- a/internal/backend/unparsed_value.go +++ b/internal/backend/unparsed_value.go @@ -25,87 +25,65 @@ type UnparsedVariableValue interface { ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) } -// ParseVariableValues processes a map of unparsed variable values by -// correlating each one with the given variable declarations which should -// be from a root module. -// -// The map of unparsed variable values should include variables from all -// possible root module declarations sources such that it is as complete as -// it can possibly be for the current operation. If any declared variables -// are not included in the map, ParseVariableValues will either substitute -// a configured default value or produce an error. -// -// If this function returns without any errors in the diagnostics, the -// resulting input values map is guaranteed to be valid and ready to pass -// to terraform.NewContext. If the diagnostics contains errors, the returned -// InputValues may be incomplete but will include the subset of variables -// that were successfully processed, allowing for careful analysis of the -// partial result. -func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { +// ParseUndeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones not declared in the specified +// declaration map along with detailed diagnostics about values of undeclared +// variables being present, depending on the source of these values. If more +// than two undeclared values are present in file form (config, auto, -var-file) +// the remaining errors are summarized to avoid a massive list of errors. +func ParseUndeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics ret := make(terraform.InputValues, len(vv)) - - // Currently we're generating only warnings for undeclared variables - // defined in files (see below) but we only want to generate a few warnings - // at a time because existing deployments may have lots of these and - // the result can therefore be overwhelming. seenUndeclaredInFile := 0 for name, rv := range vv { - var mode configs.VariableParsingMode - config, declared := decls[name] - if declared { - mode = config.ParsingMode - } else { - mode = configs.VariableParseLiteral + if _, declared := decls[name]; declared { + // Only interested in parsing undeclared variables + continue } - val, valDiags := rv.ParseVariableValue(mode) - diags = diags.Append(valDiags) + val, valDiags := rv.ParseVariableValue(configs.VariableParseLiteral) if valDiags.HasErrors() { continue } - if !declared { - switch val.SourceType { - case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: - // We allow undeclared names for variable values from files and warn in case - // users have forgotten a variable {} declaration or have a typo in their var name. - // Some users will actively ignore this warning because they use a .tfvars file - // across multiple configurations. - if seenUndeclaredInFile < 2 { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Value for undeclared variable", - fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), - )) - } - seenUndeclaredInFile++ - - case terraform.ValueFromEnvVar: - // We allow and ignore undeclared names for environment - // variables, because users will often set these globally - // when they are used across many (but not necessarily all) - // configurations. - case terraform.ValueFromCLIArg: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), - )) - default: - // For all other source types we are more vague, but other situations - // don't generally crop up at this layer in practice. + ret[name] = val + + switch val.SourceType { + case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: + // We allow undeclared names for variable values from files and warn in case + // users have forgotten a variable {} declaration or have a typo in their var name. + // Some users will actively ignore this warning because they use a .tfvars file + // across multiple configurations. + if seenUndeclaredInFile < 2 { diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, + tfdiags.Warning, "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), )) } - continue - } + seenUndeclaredInFile++ - ret[name] = val + case terraform.ValueFromEnvVar: + // We allow and ignore undeclared names for environment + // variables, because users will often set these globally + // when they are used across many (but not necessarily all) + // configurations. + case terraform.ValueFromCLIArg: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + default: + // For all other source types we are more vague, but other situations + // don't generally crop up at this layer in practice. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + } } if seenUndeclaredInFile > 2 { @@ -117,12 +95,79 @@ func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]* }) } + return ret, diags +} + +// ParseDeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones declared in the specified +// variable declaration mapping. Diagnostics will be populating with +// any variable parsing errors encountered within this collection. +func ParseDeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := make(terraform.InputValues, len(vv)) + + for name, rv := range vv { + var mode configs.VariableParsingMode + config, declared := decls[name] + + if declared { + mode = config.ParsingMode + } else { + // Only interested in parsing declared variables + continue + } + + val, valDiags := rv.ParseVariableValue(mode) + diags = diags.Append(valDiags) + if valDiags.HasErrors() { + continue + } + + ret[name] = val + } + + return ret, diags +} + +// Checks all given terraform.InputValues variable maps for the existance of +// a named variable +func isDefinedAny(name string, maps ...terraform.InputValues) bool { + for _, m := range maps { + if _, defined := m[name]; defined { + return true + } + } + return false +} + +// ParseVariableValues processes a map of unparsed variable values by +// correlating each one with the given variable declarations which should +// be from a root module. +// +// The map of unparsed variable values should include variables from all +// possible root module declarations sources such that it is as complete as +// it can possibly be for the current operation. If any declared variables +// are not included in the map, ParseVariableValues will either substitute +// a configured default value or produce an error. +// +// If this function returns without any errors in the diagnostics, the +// resulting input values map is guaranteed to be valid and ready to pass +// to terraform.NewContext. If the diagnostics contains errors, the returned +// InputValues may be incomplete but will include the subset of variables +// that were successfully processed, allowing for careful analysis of the +// partial result. +func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + ret, diags := ParseDeclaredVariableValues(vv, decls) + undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) + + diags = diags.Append(diagsUndeclared) + // By this point we should've gathered all of the required root module // variables from one of the many possible sources. We'll now populate // any we haven't gathered as their defaults and fail if any of the // missing ones are required. for name, vc := range decls { - if _, defined := ret[name]; defined { + if isDefinedAny(name, ret, undeclared) { continue } diff --git a/internal/backend/unparsed_value_test.go b/internal/backend/unparsed_value_test.go index 6df7c226a4..981c84a43e 100644 --- a/internal/backend/unparsed_value_test.go +++ b/internal/backend/unparsed_value_test.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) -func TestParseVariableValuesUndeclared(t *testing.T) { +func TestUnparsedValue(t *testing.T) { vv := map[string]UnparsedVariableValue{ "undeclared0": testUnparsedVariableValue("0"), "undeclared1": testUnparsedVariableValue("1"), @@ -59,66 +59,164 @@ func TestParseVariableValuesUndeclared(t *testing.T) { }, } - gotVals, diags := ParseVariableValues(vv, decls) - for _, diag := range diags { - t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) - } - if got, want := len(diags), 4; got != want { - t.Fatalf("wrong number of diagnostics %d; want %d", got, want) - } - const undeclSingular = `Value for undeclared variable` const undeclPlural = `Values for undeclared variables` - const missingRequired = `No value for required variable` - if got, want := diags[0].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[1].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Summary, undeclPlural; got != want { - t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { - t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) - } - if got, want := diags[3].Description().Summary, missingRequired; got != want { - t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) - } + t.Run("ParseDeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseDeclaredVariableValues(vv, decls) - wantVals := terraform.InputValues{ - "declared1": { - Value: cty.StringVal("5"), - SourceType: terraform.ValueFromNamedFile, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tfvars", - Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + if got, want := len(diags), 0; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, }, - }, - "missing1": { - Value: cty.DynamicVal, - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + } + + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseUndeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseUndeclaredVariableValues(vv, decls) + + if got, want := len(diags), 3; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "undeclared0": { + Value: cty.StringVal("0"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - "missing2": { - Value: cty.StringVal("default for missing2"), - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + "undeclared1": { + Value: cty.StringVal("1"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - } - if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { - t.Errorf("wrong result\n%s", diff) - } + "undeclared2": { + Value: cty.StringVal("2"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared3": { + Value: cty.StringVal("3"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared4": { + Value: cty.StringVal("4"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseVariableValues", func(t *testing.T) { + gotVals, diags := ParseVariableValues(vv, decls) + for _, diag := range diags { + t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) + } + if got, want := len(diags), 4; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + const missingRequired = `No value for required variable` + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { + t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) + } + if got, want := diags[3].Description().Summary, missingRequired; got != want { + t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + "missing1": { + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing2": { + Value: cty.StringVal("default for missing2"), + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) } type testUnparsedVariableValue string