diff --git a/.changes/v1.15/BUG FIXES-20260429-183507.yaml b/.changes/v1.15/BUG FIXES-20260429-183507.yaml new file mode 100644 index 0000000000..4595ba6db1 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260429-183507.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Fix non-const variable checks on `init` +time: 2026-04-29T18:35:07.99622+02:00 +custom: + Issue: "38470" diff --git a/.changes/v1.15/BUG FIXES-20260430-103820.yaml b/.changes/v1.15/BUG FIXES-20260430-103820.yaml new file mode 100644 index 0000000000..8b48b5dddc --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260430-103820.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Fix panic for types modules with no expanded instances +time: 2026-04-30T10:38:20.648469-04:00 +custom: + Issue: "38491" diff --git a/.changes/v1.15/BUG FIXES-20260430-121433.yaml b/.changes/v1.15/BUG FIXES-20260430-121433.yaml new file mode 100644 index 0000000000..763453bdbe --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260430-121433.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Avoid warnings in 'terraform output -raw' +time: 2026-04-30T12:14:33.373975+02:00 +custom: + Issue: "38487" diff --git a/.changes/v1.15/BUG FIXES-20260430-154241.yaml b/.changes/v1.15/BUG FIXES-20260430-154241.yaml new file mode 100644 index 0000000000..6ada0d5279 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260430-154241.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Ignore undeclared variable values from the cloud backend +time: 2026-04-30T15:42:41.89732+02:00 +custom: + Issue: "38490" diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dc6a9950ce..b8c02bf09f 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -54,7 +54,7 @@ jobs: .changie.yaml .changes/ sparse-checkout-cone-mode: false - ref: ${{ github.ref }} # Ref refers to the target branch of this PR + ref: ${{ github.base_ref }} # Base ref refers to the target branch of this PR - name: "Check for changelog entry" uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index 298cc969fa..68dab97da1 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -59,6 +59,11 @@ func ParseUndeclaredVariableValues(vv map[string]arguments.UnparsedVariableValue // variables, because users will often set these globally // when they are used across many (but not necessarily all) // configurations. + case terraform.ValueFromCloud: + // We allow and ignore undeclared names fetched from the cloud + // backend, because users will often set these globally or via + // varsets when they are used across many (but not necessarily all) + // workspaces. case terraform.ValueFromCLIArg: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 4b39db0a6a..89bf814152 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -303,6 +303,6 @@ func (v *remoteStoredVariableValue) ParseVariableValue(mode configs.VariablePars // roughly speaking, a similar idea to entering variable values at // the interactive CLI prompts. It's not a perfect correspondance, // but it's closer than the other options. - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, }, diags } diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 3cc8b0f330..3b7dc05aed 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -169,7 +169,8 @@ func TestRemoteContextWithVars(t *testing.T) { &tfe.VariableCreateOptions{ Category: &catTerraform, }, - `Value for undeclared variable: A variable named "key" 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.`, + // We don't expect an error for values of undeclared variables + ``, }, "environment variable": { &tfe.VariableCreateOptions{ @@ -279,7 +280,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { terraform.InputValues{ varName1: &terraform.InputValue{ Value: cty.StringVal(varValue1), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -288,7 +289,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { }, varName2: &terraform.InputValue{ Value: cty.StringVal(varValue2), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -297,7 +298,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { }, varName3: &terraform.InputValue{ Value: cty.StringVal(varValue3), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -328,7 +329,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { terraform.InputValues{ varName1: &terraform.InputValue{ Value: cty.StringVal(varValue1), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -337,7 +338,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { }, varName2: &terraform.InputValue{ Value: cty.StringVal(varValue2), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -373,7 +374,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { terraform.InputValues{ varName1: &terraform.InputValue{ Value: cty.StringVal(varValue1), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, @@ -382,7 +383,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { }, varName2: &terraform.InputValue{ Value: cty.StringVal(varValue2), - SourceType: terraform.ValueFromInput, + SourceType: terraform.ValueFromCloud, SourceRange: tfdiags.SourceRange{ Filename: "", Start: tfdiags.SourcePos{Line: 0, Column: 0, Byte: 0}, diff --git a/internal/command/output_test.go b/internal/command/output_test.go index 5cff3f4e6f..3b0666bbd8 100644 --- a/internal/command/output_test.go +++ b/internal/command/output_test.go @@ -13,9 +13,13 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/backend/remote-state/inmem" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" + "github.com/hashicorp/terraform/internal/tfdiags" ) func TestOutput(t *testing.T) { @@ -381,3 +385,81 @@ func TestOutput_stateDefault(t *testing.T) { t.Fatalf("bad: %#v", actual) } } + +// deprecatedInmemBackend wraps the inmem backend and injects a deprecation +// warning from PrepareConfig, simulating a backend with deprecated attributes +// (like the S3 backend's dynamodb_table). +type deprecatedInmemBackend struct { + backend.Backend +} + +func (b *deprecatedInmemBackend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { + newObj, diags := b.Backend.PrepareConfig(obj) + diags = diags.Append(tfdiags.SimpleWarning(`The attribute "deprecated_attr" is deprecated.`)) + return newObj, diags +} + +func TestOutputRaw_warningsSuppressed(t *testing.T) { + // Pre-populate the inmem backend with a state containing an output value + inmem.Reset() + originalState := states.BuildState(func(s *states.SyncState) { + s.SetOutputValue( + addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance), + cty.StringVal("bar"), + false, + ) + }) + + // Register a backend that wraps inmem with a deprecation warning, + // simulating a backend like S3 whose PrepareConfig warns about + // deprecated attributes (e.g. dynamodb_table). + backendInit.Set("inmem", func() backend.Backend { + return &deprecatedInmemBackend{Backend: inmem.New()} + }) + defer backendInit.Set("inmem", inmem.New) + + td := t.TempDir() + testCopyDir(t, testFixturePath("output-backend-with-deprecation"), td) + t.Chdir(td) + + // Write the state into the inmem backend's default workspace + b := inmem.New() + b.Configure(cty.ObjectVal(map[string]cty.Value{ + "lock_id": cty.NullVal(cty.String), + })) + sMgr, sDiags := b.StateMgr(backend.DefaultStateName) + if sDiags.HasErrors() { + t.Fatalf("unexpected error: %s", sDiags.Err()) + } + sMgr.WriteState(originalState) + if err := sMgr.PersistState(nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + view, done := testView(t) + c := &OutputCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + View: view, + }, + } + + args := []string{"-raw", "foo"} + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("unexpected exit code %d\nstderr:\n%s", code, output.Stderr()) + } + + // The key assertion: warnings must not appear in raw output + // as they would be indistinguishable from the value. + stderr := output.Stderr() + if strings.Contains(stderr, "deprecated") { + t.Fatalf("warnings should be suppressed, got:\n%s", stderr) + } + + actual := strings.TrimSpace(output.Stdout()) + if actual != `bar` { + t.Fatalf("expected output \"bar\", got: %#v", actual) + } +} diff --git a/internal/command/testdata/output-backend-with-deprecation/.terraform/terraform.tfstate b/internal/command/testdata/output-backend-with-deprecation/.terraform/terraform.tfstate new file mode 100644 index 0000000000..1c555fbae0 --- /dev/null +++ b/internal/command/testdata/output-backend-with-deprecation/.terraform/terraform.tfstate @@ -0,0 +1,12 @@ +{ + "version": 3, + "serial": 0, + "lineage": "test-output-deprecation", + "backend": { + "type": "inmem", + "config": { + "lock_id": null + }, + "hash": 3947750061 + } +} diff --git a/internal/command/testdata/output-backend-with-deprecation/main.tf b/internal/command/testdata/output-backend-with-deprecation/main.tf new file mode 100644 index 0000000000..cf5976f8d5 --- /dev/null +++ b/internal/command/testdata/output-backend-with-deprecation/main.tf @@ -0,0 +1,7 @@ +terraform { + backend "inmem" {} +} + +output "foo" { + value = "bar" +} diff --git a/internal/command/views/output.go b/internal/command/views/output.go index 1d2f5acfbf..9cc683547a 100644 --- a/internal/command/views/output.go +++ b/internal/command/views/output.go @@ -176,7 +176,11 @@ func (v *OutputRaw) Output(name string, outputs map[string]*states.OutputValue) } func (v *OutputRaw) Diagnostics(diags tfdiags.Diagnostics) { - v.view.Diagnostics(diags) + // filter out warnings as these wouldn't be expected in raw mode + // as they typically don't influence exit code so user cannot + // expect them in stdout + errsOnly := diags.ErrorsOnly() + v.view.Diagnostics(errsOnly) } // The OutputJSON implementation renders outputs as JSON values. When rendering diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index 1e9dc51360..eae41b8944 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -697,22 +697,35 @@ module "example" { "non-const variable validation does not run during init": { module: map[string]string{ "main.tf": ` +variable "some" { + type = string +} variable "name" { type = string default = "bad" validation { - condition = var.name != "bad" + condition = var.name != var.some error_message = "must not be bad" } } module "example" { - source = "./modules/fixed" + source = "./modules/example" + + name = var.name } `, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./modules/example": { + "main.tf": ` +variable "name" { + type = string +} +`}, }, expectLoadModuleCalls: []*configs.ModuleRequest{{ - SourceAddr: mustModuleSource(t, "./modules/fixed"), + SourceAddr: mustModuleSource(t, "./modules/example"), }}, }, } { diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 5e4b90c255..52f5b45f35 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -302,7 +302,7 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd // that are disabled, etc. Terraform's static validation leans towards // being liberal in what it accepts because the subsequent plan walk has // more information available and so can be more conservative. - if d.Operation == walkValidate { + if d.Operation == walkValidate || (d.Operation == walkInit && !config.Const) { // We should still capture the statically-configured marks during // the validate walk. ret := cty.UnknownVal(config.Type) @@ -438,23 +438,25 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc noDynamicTypes = noDynamicTypes && !out.ConstraintType.HasDynamicTypes() } - if d.Operation == walkValidate && typeDefined { - atys := make(map[string]cty.Type, len(outputConfigs)) - as := make(map[string]cty.Value, len(outputConfigs)) - for name, c := range outputConfigs { - // atys is used to create the module object type for expanded modules - atys[name] = c.ConstraintType - // the unknown val can be used when we return a single module - // instance with unknown outputs - val := cty.UnknownVal(c.ConstraintType) - - if c.DeprecatedSet { - val = val.Mark(marks.NewDeprecation(c.Deprecated, absAddr.Output(name).ConfigOutputValue().ForDisplay())) - } - as[name] = val + // build up the type of the configured module output object + atys := make(map[string]cty.Type, len(outputConfigs)) + // and create a single unknown instance value for validation + as := make(map[string]cty.Value, len(outputConfigs)) + for name, c := range outputConfigs { + // atys is used to create the module object type for expanded modules + atys[name] = c.ConstraintType + + // the unknown val can be used when we return a single module + // instance with unknown outputs + val := cty.UnknownVal(c.ConstraintType) + if c.DeprecatedSet { + val = val.Mark(marks.NewDeprecation(c.Deprecated, absAddr.Output(name).ConfigOutputValue().ForDisplay())) } - instTy := cty.Object(atys) + as[name] = val + } + instTy := cty.Object(atys) + if d.Operation == walkValidate && typeDefined { switch { case callConfig.Count != nil && noDynamicTypes: return cty.UnknownVal(cty.List(instTy)), diags @@ -580,9 +582,12 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc elems = append(elems, instVal) diags = diags.Append(moreDiags) } - if noDynamicTypes { + switch { + case noDynamicTypes && len(elems) == 0: + return cty.ListValEmpty(instTy), diags + case noDynamicTypes: return cty.ListVal(elems), diags - } else { + default: return cty.TupleVal(elems), diags } @@ -593,9 +598,13 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc attrs[string(instKey.(addrs.StringKey))] = instVal diags = diags.Append(moreDiags) } - if noDynamicTypes { + + switch { + case noDynamicTypes && len(attrs) == 0: + return cty.MapValEmpty(instTy), diags + case noDynamicTypes: return cty.MapVal(attrs), diags - } else { + default: return cty.ObjectVal(attrs), diags } diff --git a/internal/terraform/evaluate_test.go b/internal/terraform/evaluate_test.go index e2853fa70b..12ea22ebeb 100644 --- a/internal/terraform/evaluate_test.go +++ b/internal/terraform/evaluate_test.go @@ -612,6 +612,14 @@ func TestEvaluatorGetModule_validateTypedOutputs(t *testing.T) { "out": cty.String, }))), }, + "empty_count": { + configureModuleCall: func(call *configs.ModuleCall) { + call.Count = hcltest.MockExprLiteral(cty.NumberIntVal(0)) + }, + want: cty.UnknownVal(cty.List(cty.Object(map[string]cty.Type{ + "out": cty.String, + }))), + }, "for_each": { configureModuleCall: func(call *configs.ModuleCall) { call.ForEach = hcltest.MockExprLiteral(cty.MapVal(map[string]cty.Value{ @@ -729,6 +737,15 @@ func TestEvaluatorGetModule_planTypedOutputs(t *testing.T) { cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), }), }, + "empty_count": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleCount(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, 0) + }, + setupOutputs: func(namedValues *namedvals.State) { + // explicitly setting no values + }, + want: cty.ListValEmpty(cty.Object(map[string]cty.Type{"out": cty.String})), + }, "for_each": { setupInstances: func(expander *instances.Expander) { expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{ @@ -755,6 +772,15 @@ func TestEvaluatorGetModule_planTypedOutputs(t *testing.T) { "b": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), }), }, + "empty_for_each": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{}) + }, + setupOutputs: func(namedValues *namedvals.State) { + // no values for empty for_each + }, + want: cty.MapValEmpty(cty.Object(map[string]cty.Type{"out": cty.String})), + }, } for name, test := range tests { diff --git a/internal/terraform/valuesourcetype_string.go b/internal/terraform/valuesourcetype_string.go index 627593d762..bdc49e4c29 100644 --- a/internal/terraform/valuesourcetype_string.go +++ b/internal/terraform/valuesourcetype_string.go @@ -17,6 +17,7 @@ func _() { _ = x[ValueFromInput-73] _ = x[ValueFromPlan-80] _ = x[ValueFromCaller-83] + _ = x[ValueFromCloud-84] } const ( @@ -27,11 +28,12 @@ const ( _ValueSourceType_name_4 = "ValueFromInput" _ValueSourceType_name_5 = "ValueFromNamedFile" _ValueSourceType_name_6 = "ValueFromPlan" - _ValueSourceType_name_7 = "ValueFromCaller" + _ValueSourceType_name_7 = "ValueFromCallerValueFromCloud" ) var ( _ValueSourceType_index_3 = [...]uint8{0, 15, 32} + _ValueSourceType_index_7 = [...]uint8{0, 15, 29} ) func (i ValueSourceType) String() string { @@ -51,8 +53,9 @@ func (i ValueSourceType) String() string { return _ValueSourceType_name_5 case i == 80: return _ValueSourceType_name_6 - case i == 83: - return _ValueSourceType_name_7 + case 83 <= i && i <= 84: + i -= 83 + return _ValueSourceType_name_7[_ValueSourceType_index_7[i]:_ValueSourceType_index_7[i+1]] default: return "ValueSourceType(" + strconv.FormatInt(int64(i), 10) + ")" } diff --git a/internal/terraform/variables.go b/internal/terraform/variables.go index 1d6319c6eb..e37752d36c 100644 --- a/internal/terraform/variables.go +++ b/internal/terraform/variables.go @@ -105,6 +105,10 @@ const ( // ValueFromCaller indicates that the value was explicitly overridden by // a caller to Context.SetVariable after the context was constructed. ValueFromCaller ValueSourceType = 'S' + + // ValueFromCloud indicates that the value was retrieved from a remote source, + // such as HCP Terraform or Terraform Enterprise. + ValueFromCloud ValueSourceType = 'T' ) func (v *InputValue) GoString() string { @@ -153,6 +157,8 @@ func (v ValueSourceType) DiagnosticLabel() string { return "set by an interactive input" case ValueFromPlan: return "set by the plan" + case ValueFromCloud: + return "set by the cloud backend" default: return "unknown" } diff --git a/internal/tfdiags/diagnostics.go b/internal/tfdiags/diagnostics.go index d638d6e51f..352f1e6d90 100644 --- a/internal/tfdiags/diagnostics.go +++ b/internal/tfdiags/diagnostics.go @@ -193,6 +193,17 @@ func (diags Diagnostics) Warnings() Diagnostics { return warns } +// ErrorsOnly returns a Diagnostics list containing only diagnostics with a severity of Error. +func (diags Diagnostics) ErrorsOnly() Diagnostics { + var errorsOnly = Diagnostics{} + for _, diag := range diags { + if diag.Severity() == Error { + errorsOnly = append(errorsOnly, diag) + } + } + return errorsOnly +} + // HasErrors returns true if any of the diagnostics in the list have // a severity of Error. func (diags Diagnostics) HasErrors() bool {