From a2a869755838520ba2c25cadfb7ba50f5821c7ae Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 11 Feb 2026 13:03:15 +0100 Subject: [PATCH 001/136] add test case for variable validation --- .../variable-validation.tfcomponent.hcl | 34 ++++++++ internal/stacks/stackruntime/validate_test.go | 82 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl new file mode 100644 index 0000000000..46c184417d --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl @@ -0,0 +1,34 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +variable "input" { + type = string + + validation { + condition = length(var.input) > 5 + error_message = "Input must be longer than 5 characters." + } + + validation { + condition = startswith(var.input, "H") + error_message = "Input must start with H." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + id = var.id + } +} diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index a88e0b58d7..67fea35d1c 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -318,6 +318,88 @@ var ( return diags }, }, + filepath.Join("with-single-input", "variable-validation"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("Hi"), // only one validation should fail + }, + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Invalid value for variable", + Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), + Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, + End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, + }, + }) + + return diags + }, + }, + filepath.Join("with-single-input", "variable-validation"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("Aha"), // Both validations should fail + }, + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid value for variable", + Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), + Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, + End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, + }, + }) + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid value for variable", + Detail: "Input must start with H.\n\n This was checked by the validation rule at ___", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), + Start: hcl.Pos{Line: 18, Column: 0, Byte: 0}, + End: hcl.Pos{Line: 21, Column: 0, Byte: 0}, + }, + }) + + return diags + }, + }, + filepath.Join("with-single-input", "variable-validation"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("Hi"), // only one validation should fail + }, + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid value for variable", + Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), + Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, + End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, + }, + }) + + return diags + }, + }, + filepath.Join("with-single-input", "variable-validation"): { + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("HelloThere"), // no validation should fail + }, + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + return diags + }, + }, } ) From 4333e71d22da01e5cf4ae507c73338da4e279a16 Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Fri, 27 Feb 2026 17:48:33 -0500 Subject: [PATCH 002/136] make the validation --- internal/configs/checks.go | 6 +- internal/configs/named_values.go | 4 +- internal/configs/resource.go | 6 +- internal/configs/test_file.go | 2 +- internal/stacks/stackconfig/input_variable.go | 37 ++- .../internal/stackeval/input_variable.go | 225 +++++++++++++- internal/stacks/stackruntime/plan_test.go | 277 ++++++++++++++++++ .../validation-complex.tfcomponent.hcl | 71 +++++ .../validation-sensitive.tfcomponent.hcl | 60 ++++ .../validation-types.tfcomponent.hcl | 57 ++++ .../variable-validation.tfcomponent.hcl | 12 +- internal/stacks/stackruntime/validate_test.go | 82 ------ 12 files changed, 733 insertions(+), 106 deletions(-) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl diff --git a/internal/configs/checks.go b/internal/configs/checks.go index 0cf31ab16e..34043bdd23 100644 --- a/internal/configs/checks.go +++ b/internal/configs/checks.go @@ -80,14 +80,14 @@ func (cr *CheckRule) validateSelfReferences(checkType string, addr addrs.Resourc return diags } -// decodeCheckRuleBlock decodes the contents of the given block as a check rule. +// DecodeCheckRuleBlock decodes the contents of the given block as a check rule. // // Unlike most of our "decode..." functions, this one can be applied to blocks // of various types as long as their body structures are "check-shaped". The // function takes the containing block only because some error messages will // refer to its location, and the returned object's DeclRange will be the // block's header. -func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { +func DecodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { var diags hcl.Diagnostics cr := &CheckRule{ DeclRange: block.DefRange, @@ -230,7 +230,7 @@ func decodeCheckBlock(block *hcl.Block, override bool) (*Check, hcl.Diagnostics) check.DataResource = data } case "assert": - assert, moreDiags := decodeCheckRuleBlock(block, override) + assert, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) if !moreDiags.HasErrors() { check.Asserts = append(check.Asserts, assert) diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index ef4d36b6be..7cdd06f036 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -201,7 +201,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno switch block.Type { case "validation": - vv, moreDiags := decodeCheckRuleBlock(block, override) + vv, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) diags = append(diags, checkVariableValidationBlock(v.Name, vv)...) @@ -432,7 +432,7 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic for _, block := range content.Blocks { switch block.Type { case "precondition": - cr, moreDiags := decodeCheckRuleBlock(block, override) + cr, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) o.Preconditions = append(o.Preconditions, cr) case "postcondition": diff --git a/internal/configs/resource.go b/internal/configs/resource.go index 76ba71fcda..a59e9f4dbe 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -285,7 +285,7 @@ func decodeResourceBlock(block *hcl.Block, override bool, allowExperiments bool) for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := decodeCheckRuleBlock(block, override) + cr, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -497,7 +497,7 @@ func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagn for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := decodeCheckRuleBlock(block, override) + cr, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -673,7 +673,7 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := decodeCheckRuleBlock(block, override) + cr, moreDiags := DecodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) diff --git a/internal/configs/test_file.go b/internal/configs/test_file.go index 36fcba8103..1d6bd1aeae 100644 --- a/internal/configs/test_file.go +++ b/internal/configs/test_file.go @@ -697,7 +697,7 @@ func decodeTestRunBlock(block *hcl.Block, file *TestFile, experimentsAllowed boo for _, block := range content.Blocks { switch block.Type { case "assert": - cr, crDiags := decodeCheckRuleBlock(block, false) + cr, crDiags := DecodeCheckRuleBlock(block, false) diags = append(diags, crDiags...) if !crDiags.HasErrors() { r.CheckRules = append(r.CheckRules, cr) diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index 134b09797c..7dcdbbbb49 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" @@ -21,8 +22,14 @@ type InputVariable struct { DefaultValue cty.Value Description string - Sensitive bool - Ephemeral bool + Sensitive bool + Ephemeral bool + // Validations contains custom validation rules for this variable. + // These rules are evaluated at runtime during the plan phase to ensure + // that provided values meet the specified constraints. + // Each CheckRule includes a condition expression that must evaluate to true, + // and an error message to display if the validation fails. + Validations []*configs.CheckRule DeclRange tfdiags.SourceRange } @@ -89,13 +96,25 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos diags = diags.Append(hclDiags) } + // Process any nested blocks (currently only validation blocks are supported) for _, block := range content.Blocks { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Custom variable validation not yet supported", - Detail: "Input variables for a stack configuration do not yet support custom variable validation.", - Subject: block.DefRange.Ptr(), - }) + switch block.Type { + case "validation": + // Decode the validation block into a CheckRule structure. + // This only validates the syntax and structure of the validation block itself, + // not the actual runtime validation of input values. + vv, hclDiags := configs.DecodeCheckRuleBlock(block, false) + diags = diags.Append(hclDiags) + // Only add the validation rule if it was successfully parsed. + // If there were errors (e.g., missing condition or error_message), + // those errors are already captured in diags above. + if !hclDiags.HasErrors() { + ret.Validations = append(ret.Validations, vv) + } + default: + // Should not get here as the schema defines what blocks are allowed + panic("unhandled block type " + block.Type) + } } return ret, diags @@ -109,6 +128,8 @@ var inputVariableBlockSchema = &hcl.BodySchema{ {Name: "sensitive", Required: false}, {Name: "ephemeral", Required: false}, }, + // Validation blocks allow custom validation rules for input variables. + // Multiple validation blocks are allowed per variable. Blocks: []hcl.BlockHeaderSchema{ {Type: "validation"}, }, diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 2dbab6d8c1..2f36322869 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -6,11 +6,14 @@ package stackeval import ( "context" "fmt" + "strings" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/objchange" @@ -186,8 +189,16 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Evaluate custom validation rules against the input value. + // Validation is skipped during ValidatePhase because: + // 1. Input variable values are not available during validate (only during plan/apply) + // 2. Validation conditions may reference resources or other runtime values + // This matches the behavior of core Terraform's variable validation. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } + return cfg.markValue(val), diags default: @@ -197,16 +208,24 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va allVals := definedByCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Evaluate custom validation rules for values from stack call instances. + // Skip during ValidatePhase as values are not yet available. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } return cfg.markValue(val), diags case definedByRemovedCallInst != nil: allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Evaluate validation rules even for removed stack instances. + // Skip during ValidatePhase as values are not yet available. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } return cfg.markValue(val), diags default: @@ -364,6 +383,200 @@ func (v *InputVariable) tracingName() string { return v.addr.String() } +// evalVariableValidations evaluates all custom validation rules for this input variable +// against the given value, returning diagnostics if any validations fail. +// +// This function implements runtime validation checking, which is distinct from the +// config-time parsing done in stackconfig. The validation rules were parsed and stored +// during config loading; this function evaluates those rules against actual input values. +// +// The validation process: +// 1. Creates an HCL evaluation context with the variable's value and available functions +// 2. Evaluates each validation rule's condition expression +// 3. If the condition returns false, evaluates the error_message and reports a diagnostic +// +// This follows the same approach as core Terraform's evalVariableValidations, including +// handling of sensitive values, unknown values, and error message evaluation. +func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Value, phase EvalPhase) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + rules := v.config.config.Validations + if len(rules) == 0 { + // No validation rules defined, nothing to check + return diags + } + + // Get the available functions from the stack scope. + // This allows validation conditions to use built-in functions like length(), regex(), etc. + functions, moreDiags := v.stack.ExternalFunctions(ctx) + diags = diags.Append(moreDiags) + + // Create a scope to get the function table. + // We don't need a full evaluation context, just the functions. + fakeScope := &lang.Scope{ + Data: nil, // not a real scope; can't actually make an evalcontext + BaseDir: ".", + PureOnly: phase != ApplyPhase, + ConsoleMode: false, + PlanTimestamp: v.stack.PlanTimestamp(), + ExternalFuncs: functions, + } + + // Create an HCL evaluation context with the variable value and functions. + // The variable is made available as var. within validation expressions. + // This mirrors how validation conditions are evaluated in core Terraform. + hclCtx := &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + v.addr.Item.Name: val, + }), + }, + Functions: fakeScope.Functions(), + } + + // Evaluate each validation rule independently. + // Multiple validation failures will all be reported. + for _, validation := range rules { + moreDiags := evalVariableValidation(validation, hclCtx, v.config.config.DeclRange.ToHCL()) + diags = diags.Append(moreDiags) + } + + return diags +} + +// evalVariableValidation evaluates a single validation rule against a variable value. +// +// This function handles the evaluation of one validation block's condition and error_message. +// It follows the same logic as core Terraform's variable validation: +// +// 1. Evaluates the condition expression +// 2. Handles unknown/null/invalid results appropriately +// 3. If condition is false, evaluates the error_message +// 4. Checks for sensitive/ephemeral values in error messages +// 5. Constructs a diagnostic with the error message and validation rule location +// +// Parameters: +// - validation: The validation rule to evaluate (contains condition and error_message expressions) +// - hclCtx: The HCL evaluation context with the variable value and functions +// - valueRng: The source range of the variable declaration (for diagnostic reporting) +func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { + const errInvalidCondition = "Invalid variable validation result" + const errInvalidValue = "Invalid value for variable" + var diags tfdiags.Diagnostics + + // Evaluate the validation condition expression + result, moreDiags := validation.Condition.Value(hclCtx) + diags = diags.Append(moreDiags) + + if moreDiags.HasErrors() { + // If we couldn't evaluate the condition at all (syntax error, etc.), + // return early. The error is already in diags. + return diags + } + + // If the condition result is unknown, we can't determine validity yet. + // This can happen when the condition references computed values. + // Skip validation for now - it will be checked during apply if needed. + if !result.IsKnown() { + return diags + } + + // Check if the result is null + if result.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: "Validation condition expression must return either true or false, not null.", + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Convert result to boolean + result, err := convert.Convert(result, cty.Bool) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)), + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Remove any marks (sensitive, ephemeral) before checking the boolean value. + // The marks don't affect the validation result, only how we handle the error message. + result, _ = result.Unmark() + + // If the condition evaluated to true, the validation passed. + if result.True() { + return diags + } + + // Validation failed - now evaluate the error_message to show to the user. + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) + diags = diags.Append(errorDiags) + + var errorMessage string + if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() { + errorValue, err := convert.Convert(errorValue, cty.String) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "Failed to evaluate condition error message." + } else { + // Check for sensitive/ephemeral marks + if marks.Has(errorValue, marks.Sensitive) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to sensitive values", + Detail: "The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.", + Subject: validation.ErrorMessage.Range().Ptr(), + }) + errorMessage = "The error message included a sensitive value, so it will not be displayed." + } else if marks.Has(errorValue, marks.Ephemeral) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to ephemeral values", + Detail: "The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.", + Subject: validation.ErrorMessage.Range().Ptr(), + }) + errorMessage = "The error message included an ephemeral value, so it will not be displayed." + } else { + errorMessage = strings.TrimSpace(errorValue.AsString()) + } + } + } else { + errorMessage = "Failed to evaluate condition error message." + } + + // Construct the validation failure diagnostic. + // The detail includes both the custom error message and a reference to where + // the validation rule is defined, helping users locate the validation in their config. + detail := fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", + errorMessage, + validation.DeclRange.String()) + + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidValue, + Detail: detail, + Subject: &valueRng, + }) + + return diags +} + // ExternalInputValue represents the value of an input variable provided // from outside the stack configuration. type ExternalInputValue struct { diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 164a38006b..69f93bfe5f 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6390,3 +6390,280 @@ func expectOutput(t *testing.T, name string, changes []stackplan.PlannedChange) t.Fatalf("expected output value %q", name) return nil } + +// TestPlan_variableValidationAdvanced tests advanced variable validation scenarios +func TestPlan_variableValidationAdvanced(t *testing.T) { + ctx := context.Background() + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + fakePlanTimestamp, _ := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + + testCases := map[string]struct { + configPath string + planInputVars map[string]cty.Value + wantErrorMessages []string // Just check for error message presence, not exact diagnostic structure + }{ + // Type validation tests + "types-number-pass": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: nil, + }, + "types-number-out-of-range": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(150), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Number must be between 0 and 100."}, + }, + "types-list-too-many": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("1"), cty.StringVal("2"), cty.StringVal("3"), cty.StringVal("4"), cty.StringVal("5"), cty.StringVal("6")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List must contain 1-5 items."}, + }, + "types-list-empty-string": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item"), cty.StringVal("")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List items cannot be empty strings."}, + }, + "types-map-missing-key": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"other_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Map must contain 'required_key'."}, + }, + + // Sensitive variable validation tests + "sensitive-password-pass": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: nil, + }, + "sensitive-password-too-short": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("Short1"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must be at least 8 characters long."}, + }, + "sensitive-password-no-uppercase": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("securepass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one uppercase letter."}, + }, + "sensitive-password-no-number": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one number."}, + }, + "sensitive-api-key-wrong-length": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abc123"), + }, + wantErrorMessages: []string{"API key must be exactly 32 characters."}, + }, + "sensitive-api-key-invalid-chars": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("ABCDEF0123456789ABCDEF0123456789"), + }, + wantErrorMessages: []string{"API key must only contain lowercase hex characters."}, + }, + + // Complex validation tests + "complex-email-pass": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: nil, + }, + "complex-email-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("not-an-email"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid email address."}, + }, + "complex-ip-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("999.999.999.999"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid IPv4 address."}, + }, + "complex-environment-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("test"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Environment must be dev, staging, or prod."}, + }, + "complex-tags-invalid-key": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"Owner": cty.StringVal("team"), "owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens."}, + }, + "complex-tags-missing-owner": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tags must include 'owner' key."}, + }, + "complex-tags-empty-value": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal(""), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tag values must be 1-256 characters."}, + }, + "complex-tags-value-too-long": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "description": cty.StringVal(strings.Repeat("x", 257))}), + }, + wantErrorMessages: []string{"Tag values must be 1-256 characters."}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + cfg := loadMainBundleConfigForTest(t, tc.configPath) + + req := PlanRequest{ + Config: cfg, + InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { + inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(tc.planInputVars)) + for k, v := range tc.planInputVars { + inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} + } + return inputs + }(), + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + ForcePlanTimestamp: &fakePlanTimestamp, + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + _, diags := collectPlanOutput(changesCh, diagsCh) + + // Check that we get the expected error messages + if tc.wantErrorMessages == nil { + if len(diags) > 0 { + t.Errorf("expected no diagnostics, got: %s", diags.ErrWithWarnings()) + } + } else { + if len(diags) == 0 { + t.Fatalf("expected diagnostics with messages %v, got none", tc.wantErrorMessages) + } + // Check that all expected error messages are present + for _, wantMsg := range tc.wantErrorMessages { + found := false + for _, diag := range diags { + if strings.Contains(diag.Description().Detail, wantMsg) { + found = true + break + } + } + if !found { + t.Errorf("expected error message %q not found in diagnostics: %s", wantMsg, diags.ErrWithWarnings()) + } + } + } + }) + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl new file mode 100644 index 0000000000..eb8d61bf4e --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl @@ -0,0 +1,71 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "email" { + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.email)) + error_message = "Must be a valid email address." + } +} + +variable "ip_address" { + type = string + + validation { + condition = can(regex("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", var.ip_address)) + error_message = "Must be a valid IPv4 address." + } +} + +variable "environment" { + type = string + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "tags" { + type = map(string) + + validation { + condition = alltrue([for k, v in var.tags : can(regex("^[a-z][a-z0-9-]*$", k))]) + error_message = "Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens." + } + + validation { + condition = alltrue([for k, v in var.tags : length(v) > 0 && length(v) <= 256]) + error_message = "Tag values must be 1-256 characters." + } + + validation { + condition = contains(keys(var.tags), "owner") + error_message = "Tags must include 'owner' key." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl new file mode 100644 index 0000000000..7b17506956 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl @@ -0,0 +1,60 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password must be at least 8 characters long." + } + + validation { + condition = can(regex("[A-Z]", var.password)) + error_message = "Password must contain at least one uppercase letter." + } + + validation { + condition = can(regex("[0-9]", var.password)) + error_message = "Password must contain at least one number." + } +} + +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) == 32 + error_message = "API key must be exactly 32 characters." + } + + validation { + condition = can(regex("^[a-f0-9]+$", var.api_key)) + error_message = "API key must only contain lowercase hex characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl new file mode 100644 index 0000000000..80945407ad --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl @@ -0,0 +1,57 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "number_input" { + type = number + + validation { + condition = var.number_input > 0 && var.number_input < 100 + error_message = "Number must be between 0 and 100." + } +} + +variable "list_input" { + type = list(string) + + validation { + condition = length(var.list_input) > 0 && length(var.list_input) <= 5 + error_message = "List must contain 1-5 items." + } + + validation { + condition = alltrue([for s in var.list_input : length(s) > 0]) + error_message = "List items cannot be empty strings." + } +} + +variable "map_input" { + type = map(string) + + validation { + condition = contains(keys(var.map_input), "required_key") + error_message = "Map must contain 'required_key'." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl index 46c184417d..b4068e1b6c 100644 --- a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl @@ -19,6 +19,16 @@ variable "input" { condition = startswith(var.input, "H") error_message = "Input must start with H." } + + validation { + condition = !contains(["bad", "invalid", "nope"], var.input) + error_message = "Input cannot be 'bad', 'invalid', or 'nope'." + } + + validation { + condition = can(regex("^[A-Z]", var.input)) + error_message = "Input must start with an uppercase letter." + } } component "self" { @@ -29,6 +39,6 @@ component "self" { } inputs = { - id = var.id + input = var.input } } diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index 67fea35d1c..a88e0b58d7 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -318,88 +318,6 @@ var ( return diags }, }, - filepath.Join("with-single-input", "variable-validation"): { - planInputVars: map[string]cty.Value{ - "input": cty.StringVal("Hi"), // only one validation should fail - }, - diags: func() tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Invalid value for variable", - Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", - Subject: &hcl.Range{ - Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), - Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, - End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, - }, - }) - - return diags - }, - }, - filepath.Join("with-single-input", "variable-validation"): { - planInputVars: map[string]cty.Value{ - "input": cty.StringVal("Aha"), // Both validations should fail - }, - diags: func() tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid value for variable", - Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", - Subject: &hcl.Range{ - Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), - Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, - End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, - }, - }) - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid value for variable", - Detail: "Input must start with H.\n\n This was checked by the validation rule at ___", - Subject: &hcl.Range{ - Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), - Start: hcl.Pos{Line: 18, Column: 0, Byte: 0}, - End: hcl.Pos{Line: 21, Column: 0, Byte: 0}, - }, - }) - - return diags - }, - }, - filepath.Join("with-single-input", "variable-validation"): { - planInputVars: map[string]cty.Value{ - "input": cty.StringVal("Hi"), // only one validation should fail - }, - diags: func() tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - // FIXME: adjust ___ and 0s in the Subject once we have a stable location to point to for the validation rule. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid value for variable", - Detail: "Input must be longer than 5 characters.\n\n This was checked by the validation rule at ___", - Subject: &hcl.Range{ - Filename: mainBundleSourceAddrStr("with-single-input/variable-validation/variable-validation.tfcomponent.hcl"), - Start: hcl.Pos{Line: 13, Column: 0, Byte: 0}, - End: hcl.Pos{Line: 16, Column: 0, Byte: 0}, - }, - }) - - return diags - }, - }, - filepath.Join("with-single-input", "variable-validation"): { - planInputVars: map[string]cty.Value{ - "input": cty.StringVal("HelloThere"), // no validation should fail - }, - diags: func() tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - return diags - }, - }, } ) From e883bdf3a4db32b946fa1970d54cf042badaeb0f Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Fri, 27 Feb 2026 17:55:04 -0500 Subject: [PATCH 003/136] Update input_variable.go --- internal/stacks/stackconfig/input_variable.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index 7dcdbbbb49..2de31332c1 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -22,8 +22,8 @@ type InputVariable struct { DefaultValue cty.Value Description string - Sensitive bool - Ephemeral bool + Sensitive bool + Ephemeral bool // Validations contains custom validation rules for this variable. // These rules are evaluated at runtime during the plan phase to ensure // that provided values meet the specified constraints. From 07b3e3b8006d157438110f0bce429f8a09ffc574 Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Wed, 4 Mar 2026 08:40:42 +0000 Subject: [PATCH 004/136] command/init: Merge duplicated PSS logic back into existing codepath (#38227) --- .changes/v1.15/NOTES-20260303-115443.yaml | 5 + internal/backend/init/init.go | 7 + internal/command/init.go | 680 +++++------------- internal/command/init_run.go | 182 +++-- internal/command/init_run_experiment.go | 527 -------------- .../command/testdata/init-get/output.jsonlog | 4 +- internal/command/views/init.go | 43 +- internal/command/views/init_test.go | 16 +- 8 files changed, 317 insertions(+), 1147 deletions(-) create mode 100644 .changes/v1.15/NOTES-20260303-115443.yaml delete mode 100644 internal/command/init_run_experiment.go diff --git a/.changes/v1.15/NOTES-20260303-115443.yaml b/.changes/v1.15/NOTES-20260303-115443.yaml new file mode 100644 index 0000000000..2b8be19f29 --- /dev/null +++ b/.changes/v1.15/NOTES-20260303-115443.yaml @@ -0,0 +1,5 @@ +kind: NOTES +body: 'command/init: Provider installation was refactored to enable future enhancements in the area. This results in different order of operations during init and 2 new log messages replacing one (`initializing_provider_plugin_message`). The change should not have any end-user impact aside from the `init` command output.' +time: 2026-03-03T11:54:43.732353Z +custom: + Issue: "38227" diff --git a/internal/backend/init/init.go b/internal/backend/init/init.go index 24f4c5787b..5b1549edea 100644 --- a/internal/backend/init/init.go +++ b/internal/backend/init/init.go @@ -93,6 +93,13 @@ func Backend(name string) backend.InitFn { return backends[name] } +func BackendExists(name string) bool { + backendsLock.Lock() + defer backendsLock.Unlock() + _, ok := backends[name] + return ok +} + // Set sets a new backend in the list of backends. If f is nil then the // backend will be removed from the map. If this backend already exists // then it will be overwritten. diff --git a/internal/command/init.go b/internal/command/init.go index 6bf73c9cf1..f8a56dc403 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -7,7 +7,9 @@ import ( "context" "fmt" "log" + "maps" "reflect" + "slices" "sort" "strings" @@ -26,6 +28,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/didyoumean" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/providercache" @@ -55,17 +58,7 @@ func (c *InitCommand) Run(args []string) int { return 1 } - // The else condition below invokes the original logic of the init command. - // An experimental version of the init code will be used if: - // > The user uses an experimental version of TF (alpha or built from source) - // > Either the flag -enable-pluggable-state-storage-experiment is passed to the init command. - // > Or, the environment variable TF_ENABLE_PLUGGABLE_STATE_STORAGE is set to any value. - if c.Meta.AllowExperimentalFeatures && initArgs.EnablePssExperiment { - // TODO(SarahFrench/radeksimko): Remove forked init logic once feature is no longer experimental - return c.runPssInit(initArgs, view) - } else { - return c.run(initArgs, view) - } + return c.run(initArgs, view) } func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init) (output bool, abort bool, diags tfdiags.Diagnostics) { @@ -152,16 +145,145 @@ func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extra return back, true, diags } -func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, extraConfig arguments.FlagNameValueSlice, viewType arguments.ViewType, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, initArgs *arguments.Init, configLocks *depsfile.Locks, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "initialize backend") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() - view.Output(views.InitializingBackendMessage) + if root.StateStore != nil { + view.Output(views.InitializingStateStoreMessage) + } else { + view.Output(views.InitializingBackendMessage) + } - var backendConfig *configs.Backend - var backendConfigOverride hcl.Body - if root.Backend != nil { + var opts *BackendOpts + switch { + case root.StateStore != nil: + // state_store config present + factory, fDiags := c.Meta.StateStoreProviderFactoryFromConfig(root.StateStore, configLocks) + diags = diags.Append(fDiags) + if fDiags.HasErrors() { + return nil, true, diags + } + + // If overrides supplied by -backend-config CLI flag, process them + var configOverride hcl.Body + if !initArgs.BackendConfig.Empty() { + // We need to launch an instance of the provider to get the config of the state store for processing any overrides. + provider, err := factory() + defer provider.Close() // Stop the child process once we're done with it here. + if err != nil { + diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) + return nil, true, diags + } + + resp := provider.GetProviderSchema() + + if len(resp.StateStores) == 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider does not support pluggable state storage", + Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", + root.StateStore.Provider.Name, + root.StateStore.ProviderAddr), + Subject: &root.StateStore.DeclRange, + }) + return nil, true, diags + } + + stateStoreSchema, exists := resp.StateStores[root.StateStore.Type] + if !exists { + suggestions := slices.Sorted(maps.Keys(resp.StateStores)) + suggestion := didyoumean.NameSuggestion(root.StateStore.Type, suggestions) + if suggestion != "" { + suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "State store not implemented by the provider", + Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", + root.StateStore.Type, root.StateStore.Provider.Name, + root.StateStore.ProviderAddr, suggestion), + Subject: &root.StateStore.DeclRange, + }) + return nil, true, diags + } + + // Handle any overrides supplied via -backend-config CLI flags + var overrideDiags tfdiags.Diagnostics + configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, stateStoreSchema.Body) + diags = diags.Append(overrideDiags) + if overrideDiags.HasErrors() { + return nil, true, diags + } + } + + opts = &BackendOpts{ + StateStoreConfig: root.StateStore, + Locks: configLocks, + CreateDefaultWorkspace: initArgs.CreateDefaultWorkspace, + ConfigOverride: configOverride, + Init: true, + ViewType: initArgs.ViewType, + } + + case root.Backend != nil: + // backend config present + backendType := root.Backend.Type + bf := backendInit.Backend(backendType) + b := bf() + backendSchema := b.ConfigSchema() + backendConfig := root.Backend + + // If overrides supplied by -backend-config CLI flag, process them + var configOverride hcl.Body + if !initArgs.BackendConfig.Empty() { + var overrideDiags tfdiags.Diagnostics + configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, backendSchema) + diags = diags.Append(overrideDiags) + if overrideDiags.HasErrors() { + return nil, true, diags + } + } + + opts = &BackendOpts{ + BackendConfig: backendConfig, + Locks: configLocks, + ConfigOverride: configOverride, + Init: true, + ViewType: initArgs.ViewType, + } + + default: + // No config; defaults to local state storage + opts = &BackendOpts{ + Init: true, + Locks: configLocks, + ViewType: initArgs.ViewType, + } + } + + back, backDiags := c.Backend(opts) + diags = diags.Append(backDiags) + return back, true, diags +} + +func (c *InitCommand) earlyValidateBackend(root *configs.Module, initArgs *arguments.Init) (diags tfdiags.Diagnostics) { + switch { + case root.StateStore != nil && root.Backend != nil: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Conflicting backend and state_store configurations present during init", + Detail: fmt.Sprintf("When initializing the backend there was configuration data present for both backend %q and state store %q. This is a bug in Terraform and should be reported.", + root.Backend.Type, + root.StateStore.Type, + ), + Subject: &root.Backend.TypeRange, + }) + return diags + case root.StateStore != nil: + // validation requires the provider to be installed so cannot be done early + case root.Backend != nil: backendType := root.Backend.Type if backendType == "cloud" { diags = diags.Append(&hcl.Diagnostic{ @@ -170,11 +292,10 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure HCP Terraform, declare a 'cloud' block instead.", backendType), Subject: &root.Backend.TypeRange, }) - return nil, true, diags + return diags } - bf := backendInit.Backend(backendType) - if bf == nil { + if !backendInit.BackendExists(backendType) { detail := fmt.Sprintf("There is no backend type named %q.", backendType) if msg, removed := backendInit.RemovedBackends[backendType]; removed { detail = msg @@ -186,24 +307,15 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext Detail: detail, Subject: &root.Backend.TypeRange, }) - return nil, true, diags + return diags } + default: + // No config; defaults to local state storage - b := bf() - backendSchema := b.ConfigSchema() - backendConfig = root.Backend - - var overrideDiags tfdiags.Diagnostics - backendConfigOverride, overrideDiags = c.backendConfigOverrideBody(extraConfig, backendSchema) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } else { // If the user supplied a -backend-config on the CLI but no backend // block was found in the configuration, it's likely - but not // necessarily - a mistake. Return a warning. - if !extraConfig.Empty() { + if !initArgs.BackendConfig.Empty() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Missing backend configuration", @@ -223,471 +335,7 @@ the backend configuration is present and valid. )) } } - - opts := &BackendOpts{ - BackendConfig: backendConfig, - ConfigOverride: backendConfigOverride, - Init: true, - ViewType: viewType, - } - - back, backDiags := c.Backend(opts) - diags = diags.Append(backDiags) - return back, true, diags -} - -// getProviders determines what providers are required given configuration and state data. The method downloads any missing providers -// and replaces the contents of the dependency lock file if any changes happen. -// The calling code is expected to have loaded the complete module tree and read the state file, and passes that data into this method. -// -// This method outputs to the provided view. The returned `output` boolean lets calling code know if anything has been rendered via the view. -func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, state *states.State, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output, abort bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "install providers") - defer span.End() - - // Dev overrides cause the result of "terraform init" to be irrelevant for - // any overridden providers, so we'll warn about it to avoid later - // confusion when Terraform ends up using a different provider than the - // lock file called for. - diags = diags.Append(c.providerDevOverrideInitWarnings()) - - // First we'll collect all the provider dependencies we can see in the - // configuration and the state. - reqs, hclDiags := config.ProviderRequirements() - diags = diags.Append(hclDiags) - if hclDiags.HasErrors() { - return false, true, diags - } - - reqs = c.removeDevOverrides(reqs) - if state != nil { - stateReqs := state.ProviderRequirements() - reqs = reqs.Merge(stateReqs) - } - for providerAddr := range reqs { - if providerAddr.IsLegacy() { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid legacy provider address", - fmt.Sprintf( - "This configuration or its associated state refers to the unqualified provider %q.\n\nYou must complete the Terraform 0.13 upgrade process before upgrading to later versions.", - providerAddr.Type, - ), - )) - } - } - - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - - if diags.HasErrors() { - return false, true, diags - } - - var inst *providercache.Installer - if len(pluginDirs) == 0 { - // By default we use a source that looks for providers in all of the - // standard locations, possibly customized by the user in CLI config. - inst = c.providerInstaller() - } else { - // If the user passes at least one -plugin-dir then that circumvents - // the usual sources and forces Terraform to consult only the given - // directories. Anything not available in one of those directories - // is not available for installation. - source := c.providerCustomLocalDirectorySource(pluginDirs) - inst = c.providerInstallerCustomSource(source) - - // The default (or configured) search paths are logged earlier, in provider_source.go - // Log that those are being overridden by the `-plugin-dir` command line options - log.Println("[DEBUG] init: overriding provider plugin search paths") - log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) - } - - // We want to print out a nice warning if we don't manage to pull - // checksums for all our providers. This is tracked via callbacks - // and incomplete providers are stored here for later analysis. - var incompleteProviders []string - - // Because we're currently just streaming a series of events sequentially - // into the terminal, we're showing only a subset of the events to keep - // things relatively concise. Later it'd be nice to have a progress UI - // where statuses update in-place, but we can't do that as long as we - // are shimming our vt100 output to the legacy console API on Windows. - evts := &providercache.InstallerEvents{ - PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(views.InitializingProviderPluginMessage) - }, - ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version) { - view.LogInitMessage(views.ProviderAlreadyInstalledMessage, provider.ForDisplay(), selectedVersion) - }, - BuiltInProviderAvailable: func(provider addrs.Provider) { - view.LogInitMessage(views.BuiltInProviderAvailableMessage, provider.ForDisplay()) - }, - BuiltInProviderFailure: func(provider addrs.Provider, err error) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid dependency on built-in provider", - fmt.Sprintf("Cannot use %s: %s.", provider.ForDisplay(), err), - )) - }, - QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { - if locked { - view.LogInitMessage(views.ReusingPreviousVersionInfo, provider.ForDisplay()) - } else { - if len(versionConstraints) > 0 { - view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) - } else { - view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) - } - } - }, - LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) - }, - FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { - view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) - }, - QueryPackagesFailure: func(provider addrs.Provider, err error) { - switch errorTy := err.(type) { - case getproviders.ErrProviderNotFound: - sources := errorTy.Sources - displaySources := make([]string, len(sources)) - for i, source := range sources { - displaySources[i] = fmt.Sprintf(" - %s", source) - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", - provider.ForDisplay(), err, strings.Join(displaySources, "\n"), - ), - )) - case getproviders.ErrRegistryProviderNotKnown: - // We might be able to suggest an alternative provider to use - // instead of this one. - suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) - alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) - if alternative != provider { - suggestion = fmt.Sprintf( - "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", - alternative.ForDisplay(), provider.ForDisplay(), - ) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - case getproviders.ErrHostNoProviders: - switch { - case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: - // If a user copies the URL of a GitHub repository into - // the source argument and removes the schema to make it - // provider-address-shaped then that's one way we can end up - // here. We'll use a specialized error message in anticipation - // of that mistake. We only do this if github.com isn't a - // provider registry, to allow for the (admittedly currently - // rather unlikely) possibility that github.com starts being - // a real Terraform provider registry in the future. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", - provider.String(), - ), - )) - - case errorTy.HasOtherVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", - errorTy.Hostname, provider.String(), - ), - )) - - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", - errorTy.Hostname, provider.String(), - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - } - - }, - QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { - displayWarnings := make([]string, len(warnings)) - for i, warning := range warnings { - displayWarnings[i] = fmt.Sprintf("- %s", warning) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Additional provider information from registry", - fmt.Sprintf("The remote registry returned warnings for %s:\n%s", - provider.String(), - strings.Join(displayWarnings, "\n"), - ), - )) - }, - LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider from shared cache", - fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), - )) - }, - FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - const summaryIncompatible = "Incompatible provider version" - switch err := err.(type) { - case getproviders.ErrProtocolNotSupported: - closestAvailable := err.Suggestion - switch { - case closestAvailable == getproviders.UnspecifiedVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(errProviderVersionIncompatible, provider.String()), - )) - case version.GreaterThan(closestAvailable): - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - default: // version is less than closestAvailable - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - } - case getproviders.ErrPlatformNotSupported: - switch { - case err.MirrorURL != nil: - // If we're installing from a mirror then it may just be - // the mirror lacking the package, rather than it being - // unavailable from upstream. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Your chosen provider mirror at %s does not have a %s v%s package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so this provider might not support your current platform. Alternatively, the mirror itself might have only a subset of the plugin packages available in the origin registry, at %s.", - err.MirrorURL, err.Provider, err.Version, err.Platform, - err.Provider.Hostname, - ), - )) - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Provider %s v%s does not have a package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.", - err.Provider, err.Version, err.Platform, - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - // We can potentially end up in here under cancellation too, - // in spite of our getproviders.ErrRequestCanceled case above, - // because not all of the outgoing requests we do under the - // "fetch package" banner are source metadata requests. - // In that case we will emit a redundant error here about - // the request being cancelled, but we'll still detect it - // as a cancellation after the installer returns and do the - // normal cancellation handling. - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider", - fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), - )) - } - }, - FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { - var keyID string - if authResult != nil && authResult.ThirdPartySigned() { - keyID = authResult.KeyID - } - if keyID != "" { - keyID = view.PrepareMessage(views.KeyID, keyID) - } - - view.LogInitMessage(views.InstalledProviderVersionInfo, provider.ForDisplay(), version, authResult, keyID) - }, - ProvidersLockUpdated: func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { - // We're going to use this opportunity to track if we have any - // "incomplete" installs of providers. An incomplete install is - // when we are only going to write the local hashes into our lock - // file which means a `terraform init` command will fail in future - // when used on machines of a different architecture. - // - // We want to print a warning about this. - - if len(signedHashes) > 0 { - // If we have any signedHashes hashes then we don't worry - as - // we know we retrieved all available hashes for this version - // anyway. - return - } - - // If local hashes and prior hashes are exactly the same then - // it means we didn't record any signed hashes previously, and - // we know we're not adding any extra in now (because we already - // checked the signedHashes), so that's a problem. - // - // In the actual check here, if we have any priorHashes and those - // hashes are not the same as the local hashes then we're going to - // accept that this provider has been configured correctly. - if len(priorHashes) > 0 && !reflect.DeepEqual(localHashes, priorHashes) { - return - } - - // Now, either signedHashes is empty, or priorHashes is exactly the - // same as our localHashes which means we never retrieved the - // signedHashes previously. - // - // Either way, this is bad. Let's complain/warn. - incompleteProviders = append(incompleteProviders, provider.ForDisplay()) - }, - ProvidersFetched: func(authResults map[addrs.Provider]*getproviders.PackageAuthenticationResult) { - thirdPartySigned := false - for _, authResult := range authResults { - if authResult.ThirdPartySigned() { - thirdPartySigned = true - break - } - } - if thirdPartySigned { - view.LogInitMessage(views.PartnerAndCommunityProvidersMessage) - } - }, - } - ctx = evts.OnContext(ctx) - - mode := providercache.InstallNewProvidersOnly - if upgrade { - if flagLockfile == "readonly" { - diags = diags.Append(fmt.Errorf("The -upgrade flag conflicts with -lockfile=readonly.")) - view.Diagnostics(diags) - return true, true, diags - } - - mode = providercache.InstallUpgrades - } - newLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) - if ctx.Err() == context.Canceled { - diags = diags.Append(fmt.Errorf("Provider installation was canceled by an interrupt signal.")) - view.Diagnostics(diags) - return true, true, diags - } - if err != nil { - // The errors captured in "err" should be redundant with what we - // received via the InstallerEvents callbacks above, so we'll - // just return those as long as we have some. - if !diags.HasErrors() { - diags = diags.Append(err) - } - - return true, true, diags - } - - // If the provider dependencies have changed since the last run then we'll - // say a little about that in case the reader wasn't expecting a change. - // (When we later integrate module dependencies into the lock file we'll - // probably want to refactor this so that we produce one lock-file related - // message for all changes together, but this is here for now just because - // it's the smallest change relative to what came before it, which was - // a hidden JSON file specifically for tracking providers.) - if !newLocks.Equal(previousLocks) { - // if readonly mode - if flagLockfile == "readonly" { - // check if required provider dependencies change - if !newLocks.EqualProviderAddress(previousLocks) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - `Provider dependency changes detected`, - `Changes to the required provider dependencies were detected, but the lock file is read-only. To use and record these requirements, run "terraform init" without the "-lockfile=readonly" flag.`, - )) - return true, true, diags - } - - // suppress updating the file to record any new information it learned, - // such as a hash using a new scheme. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - `Provider lock file not updated`, - `Changes to the provider selections were detected, but not saved in the .terraform.lock.hcl file. To record these selections, run "terraform init" without the "-lockfile=readonly" flag.`, - )) - return true, false, diags - } - - // Jump in here and add a warning if any of the providers are incomplete. - if len(incompleteProviders) > 0 { - // We don't really care about the order here, we just want the - // output to be deterministic. - sort.Slice(incompleteProviders, func(i, j int) bool { - return incompleteProviders[i] < incompleteProviders[j] - }) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - incompleteLockFileInformationHeader, - fmt.Sprintf( - incompleteLockFileInformationBody, - strings.Join(incompleteProviders, "\n - "), - getproviders.CurrentPlatform.String()))) - } - - if previousLocks.Empty() { - // A change from empty to non-empty is special because it suggests - // we're running "terraform init" for the first time against a - // new configuration. In that case we'll take the opportunity to - // say a little about what the dependency lock file is, for new - // users or those who are upgrading from a previous Terraform - // version that didn't have dependency lock files. - view.Output(views.LockInfo) - } else { - view.Output(views.DependenciesLockChangesInfo) - } - - moreDiags = c.replaceLockedDependencies(newLocks) - diags = diags.Append(moreDiags) - } - - return true, false, diags + return diags } // getProvidersFromConfig determines what providers are required by the given configuration data. @@ -711,6 +359,8 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config return false, nil, diags } + reqs = c.removeDevOverrides(reqs) + for providerAddr := range reqs { if providerAddr.IsLegacy() { diags = diags.Append(tfdiags.Sourceless( @@ -746,7 +396,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) } - evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) + evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -863,7 +513,7 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // things relatively concise. Later it'd be nice to have a progress UI // where statuses update in-place, but we can't do that as long as we // are shimming our vt100 output to the legacy console API on Windows. - evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) + evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -967,7 +617,7 @@ func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLo // prepareInstallerEvents returns an instance of *providercache.InstallerEvents. This struct defines callback functions that will be executed // when a specific type of event occurs during provider installation. // The calling code needs to provide a tfdiags.Diagnostics collection, so that provider installation code returns diags to the calling code using closures -func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { +func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags *tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { // Because we're currently just streaming a series of events sequentially // into the terminal, we're showing only a subset of the events to keep @@ -985,7 +635,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr view.LogInitMessage(views.BuiltInProviderAvailableMessage, provider.ForDisplay()) }, BuiltInProviderFailure: func(provider addrs.Provider, err error) { - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid dependency on built-in provider", fmt.Sprintf("Cannot use %s: %s.", provider.ForDisplay(), err), @@ -1016,7 +666,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr for i, source := range sources { displaySources[i] = fmt.Sprintf(" - %s", source) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", @@ -1035,7 +685,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", @@ -1053,7 +703,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // provider registry, to allow for the (admittedly currently // rather unlikely) possibility that github.com starts being // a real Terraform provider registry in the future. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", @@ -1062,7 +712,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) case errorTy.HasOtherVersion: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", @@ -1071,7 +721,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) default: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", @@ -1087,7 +737,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr default: suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", @@ -1103,7 +753,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr displayWarnings[i] = fmt.Sprintf("- %s", warning) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Additional provider information from registry", fmt.Sprintf("The remote registry returned warnings for %s:\n%s", @@ -1113,7 +763,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) }, LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to install provider from shared cache", fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), @@ -1126,13 +776,13 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr closestAvailable := err.Suggestion switch { case closestAvailable == getproviders.UnspecifiedVersion: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(errProviderVersionIncompatible, provider.String()), )) case version.GreaterThan(closestAvailable): - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), @@ -1141,7 +791,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) default: // version is less than closestAvailable - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), @@ -1156,7 +806,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // If we're installing from a mirror then it may just be // the mirror lacking the package, rather than it being // unavailable from upstream. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf( @@ -1166,7 +816,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) default: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf( @@ -1191,7 +841,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // as a cancellation after the installer returns and do the // normal cancellation handling. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to install provider", fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 880b4b6027..d63013f94e 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -163,18 +163,78 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { return 1 } + if initArgs.Get { + modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) + diags = diags.Append(modsDiags) + if modsAbort || modsDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + if modsOutput { + header = true + } + } + + // With all of the modules (hopefully) installed, we can now try to load the + // whole configuration tree. + config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) + // configDiags will be handled after: + // - the version constraint check has happened + // - and, the backend/state_store is initialised + + // Before we go further, we'll check to make sure none of the modules in + // the configuration declare that they don't support this Terraform + // version, so we can produce a version-related error message rather than + // potentially-confusing downstream errors. + versionDiags := terraform.CheckCoreVersionRequirements(config) + if versionDiags.HasErrors() { + view.Diagnostics(versionDiags) + return 1 + } + + earlyBdiags := c.earlyValidateBackend(rootModEarly, initArgs) + diags = diags.Append(earlyBdiags) + + // We've passed the core version check, now we can show errors from the early configuration. + // This prevents trying to initialise the backend with faulty configuration. + if earlyConfDiags.HasErrors() || earlyBdiags.HasErrors() { + diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) + view.Diagnostics(diags) + return 1 + } + + // Now the full configuration is loaded, we can download the providers specified in the configuration. + // This is step one of a two-step provider download process + // Providers may be downloaded by this code, but the dependency lock file is only updated later in `init` + // after step two of provider download is complete. + previousLocks, moreDiags := c.lockedDependencies() + diags = diags.Append(moreDiags) + + configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + diags = diags.Append(configProviderDiags) + if configProviderDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + if configProvidersOutput { + header = true + } + + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + if header { + view.Output(views.EmptyMessage) + } + var back backend.Backend - // There may be config errors or backend init errors but these will be shown later _after_ - // checking for core version requirement errors. var backDiags tfdiags.Diagnostics var backendOutput bool - switch { case initArgs.Cloud && rootModEarly.CloudConfig != nil: back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) case initArgs.Backend: - back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) + back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, initArgs, configLocks, view) default: // load the previously-stored backend config back, backDiags = c.Meta.backendFromState(ctx) @@ -182,6 +242,29 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { if backendOutput { header = true } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) + } + + // Show any errors from initializing the backend. + // No preamble using `InitConfigError` is present, as we expect + // any errors to from configuring the backend itself. + diags = diags.Append(backDiags) + if backDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // If everything is ok with the core version check and backend/state_store initialization, + // show other errors from loading the full configuration tree. + diags = diags.Append(confDiags) + if confDiags.HasErrors() { + diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) + view.Diagnostics(diags) + return 1 + } var state *states.State @@ -212,63 +295,37 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { state = sMgr.State() } - if initArgs.Get { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) - diags = diags.Append(modsDiags) - if modsAbort || modsDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if modsOutput { - header = true - } - } - - // With all of the modules (hopefully) installed, we can now try to load the - // whole configuration tree. - config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) - // configDiags will be handled after the version constraint check, since an - // incorrect version of terraform may be producing errors for configuration - // constructs added in later versions. - - // Before we go further, we'll check to make sure none of the modules in - // the configuration declare that they don't support this Terraform - // version, so we can produce a version-related error message rather than - // potentially-confusing downstream errors. - versionDiags := terraform.CheckCoreVersionRequirements(config) - if versionDiags.HasErrors() { - view.Diagnostics(versionDiags) - return 1 - } - - // We've passed the core version check, now we can show errors from the - // configuration and backend initialisation. - - // Now, we can check the diagnostics from the early configuration and the - // backend. - diags = diags.Append(earlyConfDiags) - diags = diags.Append(backDiags) - if earlyConfDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) + // Now the resource state is loaded, we can download the providers specified in the state but not the configuration. + // This is step two of a two-step provider download process + stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + diags = diags.Append(stateProvidersDiags) + if stateProvidersDiags.HasErrors() { view.Diagnostics(diags) return 1 } + if stateProvidersOutput { + header = true + } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) + } - // Now, we can show any errors from initializing the backend, but we won't - // show the InitConfigError preamble as we didn't detect problems with - // the early configuration. - if backDiags.HasErrors() { + // Now the two steps of provider download have happened, update the dependency lock file if it has changed. + lockFileOutput, lockFileDiags := c.saveDependencyLockFile(previousLocks, configLocks, stateLocks, initArgs.Lockfile, view) + diags = diags.Append(lockFileDiags) + if lockFileDiags.HasErrors() { view.Diagnostics(diags) return 1 } - - // If everything is ok with the core version check and backend initialization, - // show other errors from loading the full configuration tree. - diags = diags.Append(confDiags) - if confDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) - view.Diagnostics(diags) - return 1 + if lockFileOutput { + header = true + } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) } if cb, ok := back.(*cloud.Cloud); ok { @@ -281,23 +338,6 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { } } - // Now that we have loaded all modules, check the module tree for missing providers. - providersOutput, providersAbort, providerDiags := c.getProviders(ctx, config, state, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(providerDiags) - if providersAbort || providerDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if providersOutput { - header = true - } - - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - if header { - view.Output(views.EmptyMessage) - } - // If we accumulated any warnings along the way that weren't accompanied // by errors then we'll output them here so that the success message is // still the final thing shown. diff --git a/internal/command/init_run_experiment.go b/internal/command/init_run_experiment.go deleted file mode 100644 index da6b2e6419..0000000000 --- a/internal/command/init_run_experiment.go +++ /dev/null @@ -1,527 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package command - -import ( - "context" - "errors" - "fmt" - "maps" - "slices" - "strings" - - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform/internal/backend" - backendInit "github.com/hashicorp/terraform/internal/backend/init" - "github.com/hashicorp/terraform/internal/cloud" - "github.com/hashicorp/terraform/internal/command/arguments" - "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/didyoumean" - "github.com/hashicorp/terraform/internal/states" - "github.com/hashicorp/terraform/internal/terraform" - "github.com/hashicorp/terraform/internal/tfdiags" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -// `runPssInit` is an altered version of the logic in `run` that contains changes -// related to the PSS project. This is used by the (InitCommand.Run method only if Terraform has -// experimental features enabled. -func (c *InitCommand) runPssInit(initArgs *arguments.Init, view views.Init) int { - var diags tfdiags.Diagnostics - - c.forceInitCopy = initArgs.ForceInitCopy - c.Meta.stateLock = initArgs.StateLock - c.Meta.stateLockTimeout = initArgs.StateLockTimeout - c.reconfigure = initArgs.Reconfigure - c.migrateState = initArgs.MigrateState - c.Meta.ignoreRemoteVersion = initArgs.IgnoreRemoteVersion - c.Meta.input = initArgs.InputEnabled - c.Meta.targetFlags = initArgs.TargetFlags - c.Meta.compactWarnings = initArgs.CompactWarnings - - // Copying the state only happens during backend migration, so setting - // -force-copy implies -migrate-state - if c.forceInitCopy { - c.migrateState = true - } - - if len(initArgs.PluginPath) > 0 { - c.pluginPath = initArgs.PluginPath - } - - // Validate the arg count and get the working directory - path, err := ModulePath(initArgs.Args) - if err != nil { - diags = diags.Append(err) - view.Diagnostics(diags) - return 1 - } - - if err := c.storePluginPath(c.pluginPath); err != nil { - diags = diags.Append(fmt.Errorf("Error saving -plugin-dir to workspace directory: %s", err)) - view.Diagnostics(diags) - return 1 - } - - // Initialization can be aborted by interruption signals - ctx, done := c.InterruptibleContext(c.CommandContext()) - defer done() - - // This will track whether we outputted anything so that we know whether - // to output a newline before the success message - var header bool - - if initArgs.FromModule != "" { - src := initArgs.FromModule - - empty, err := configs.IsEmptyDir(path, initArgs.TestsDirectory) - if err != nil { - diags = diags.Append(fmt.Errorf("Error validating destination directory: %s", err)) - view.Diagnostics(diags) - return 1 - } - if !empty { - diags = diags.Append(errors.New(strings.TrimSpace(errInitCopyNotEmpty))) - view.Diagnostics(diags) - return 1 - } - - view.Output(views.CopyingConfigurationMessage, src) - header = true - - hooks := uiModuleInstallHooks{ - Ui: c.Ui, - ShowLocalPaths: false, // since they are in a weird location for init - View: view, - } - - ctx, span := tracer.Start(ctx, "-from-module=...", trace.WithAttributes( - attribute.String("module_source", src), - )) - - initDirFromModuleAbort, initDirFromModuleDiags := c.initDirFromModule(ctx, path, src, hooks) - diags = diags.Append(initDirFromModuleDiags) - if initDirFromModuleAbort || initDirFromModuleDiags.HasErrors() { - view.Diagnostics(diags) - span.SetStatus(codes.Error, "module installation failed") - span.End() - return 1 - } - span.End() - - view.Output(views.EmptyMessage) - } - - // If our directory is empty, then we're done. We can't get or set up - // the backend with an empty directory. - empty, err := configs.IsEmptyDir(path, initArgs.TestsDirectory) - if err != nil { - diags = diags.Append(fmt.Errorf("Error checking configuration: %s", err)) - view.Diagnostics(diags) - return 1 - } - if empty { - view.Output(views.OutputInitEmptyMessage) - return 0 - } - - // Load just the root module to begin backend and module initialization - rootModEarly, earlyConfDiags := c.loadSingleModuleWithTests(path, initArgs.TestsDirectory) - - // There may be parsing errors in config loading but these will be shown later _after_ - // checking for core version requirement errors. Not meeting the version requirement should - // be the first error displayed if that is an issue, but other operations are required - // before being able to check core version requirements. - if rootModEarly == nil { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) - view.Diagnostics(diags) - - return 1 - } - - if initArgs.Get { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) - diags = diags.Append(modsDiags) - if modsAbort || modsDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if modsOutput { - header = true - } - } - - // With all of the modules (hopefully) installed, we can now try to load the - // whole configuration tree. - config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) - // configDiags will be handled after: - // - the version constraint check has happened - // - and, the backend/state_store is initialised - - // Before we go further, we'll check to make sure none of the modules in - // the configuration declare that they don't support this Terraform - // version, so we can produce a version-related error message rather than - // potentially-confusing downstream errors. - versionDiags := terraform.CheckCoreVersionRequirements(config) - if versionDiags.HasErrors() { - view.Diagnostics(versionDiags) - return 1 - } - - // We've passed the core version check, now we can show errors from the early configuration. - // This prevents trying to initialise the backend with faulty configuration. - if earlyConfDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) - view.Diagnostics(diags) - return 1 - } - - // Now the full configuration is loaded, we can download the providers specified in the configuration. - // This is step one of a two-step provider download process - // Providers may be downloaded by this code, but the dependency lock file is only updated later in `init` - // after step two of provider download is complete. - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - - configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(configProviderDiags) - if configProviderDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if configProvidersOutput { - header = true - } - - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - if header { - view.Output(views.EmptyMessage) - } - - var back backend.Backend - - var backDiags tfdiags.Diagnostics - var backendOutput bool - switch { - case initArgs.Cloud && rootModEarly.CloudConfig != nil: - back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) - case initArgs.Backend: - back, backendOutput, backDiags = c.initPssBackend(ctx, rootModEarly, initArgs, configLocks, view) - default: - // load the previously-stored backend config - back, backDiags = c.Meta.backendFromState(ctx) - } - if backendOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - // Show any errors from initializing the backend. - // No preamble using `InitConfigError` is present, as we expect - // any errors to from configuring the backend itself. - diags = diags.Append(backDiags) - if backDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - - // If everything is ok with the core version check and backend/state_store initialization, - // show other errors from loading the full configuration tree. - diags = diags.Append(confDiags) - if confDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) - view.Diagnostics(diags) - return 1 - } - - var state *states.State - - // If we have a functional backend (either just initialized or initialized - // on a previous run) we'll use the current state as a potential source - // of provider dependencies. - if back != nil { - c.ignoreRemoteVersionConflict(back) - workspace, err := c.Workspace() - if err != nil { - diags = diags.Append(fmt.Errorf("Error selecting workspace: %s", err)) - view.Diagnostics(diags) - return 1 - } - sMgr, sDiags := back.StateMgr(workspace) - if sDiags.HasErrors() { - diags = diags.Append(fmt.Errorf("Error loading state: %s", sDiags.Err())) - view.Diagnostics(diags) - return 1 - } - - if err := sMgr.RefreshState(); err != nil { - diags = diags.Append(fmt.Errorf("Error refreshing state: %s", err)) - view.Diagnostics(diags) - return 1 - } - - state = sMgr.State() - } - - // Now the resource state is loaded, we can download the providers specified in the state but not the configuration. - // This is step two of a two-step provider download process - stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(configProviderDiags) - if stateProvidersDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if stateProvidersOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - // Now the two steps of provider download have happened, update the dependency lock file if it has changed. - lockFileOutput, lockFileDiags := c.saveDependencyLockFile(previousLocks, configLocks, stateLocks, initArgs.Lockfile, view) - diags = diags.Append(lockFileDiags) - if lockFileDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if lockFileOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - if cb, ok := back.(*cloud.Cloud); ok { - if c.RunningInAutomation { - if err := cb.AssertImportCompatible(config); err != nil { - diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "Compatibility error", err.Error())) - view.Diagnostics(diags) - return 1 - } - } - } - - // If we accumulated any warnings along the way that weren't accompanied - // by errors then we'll output them here so that the success message is - // still the final thing shown. - view.Diagnostics(diags) - _, cloud := back.(*cloud.Cloud) - output := views.OutputInitSuccessMessage - if cloud { - output = views.OutputInitSuccessCloudMessage - } - - view.Output(output) - - if !c.RunningInAutomation { - // If we're not running in an automation wrapper, give the user - // some more detailed next steps that are appropriate for interactive - // shell usage. - output = views.OutputInitSuccessCLIMessage - if cloud { - output = views.OutputInitSuccessCLICloudMessage - } - view.Output(output) - } - return 0 -} - -func (c *InitCommand) initPssBackend(ctx context.Context, root *configs.Module, initArgs *arguments.Init, configLocks *depsfile.Locks, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "initialize backend") - _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here - defer span.End() - - if root.StateStore != nil { - view.Output(views.InitializingStateStoreMessage) - } else { - view.Output(views.InitializingBackendMessage) - } - - var opts *BackendOpts - switch { - case root.StateStore != nil && root.Backend != nil: - // We expect validation during config parsing to prevent mutually exclusive backend and state_store blocks, - // but checking here just in case. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Conflicting backend and state_store configurations present during init", - Detail: fmt.Sprintf("When initializing the backend there was configuration data present for both backend %q and state store %q. This is a bug in Terraform and should be reported.", - root.Backend.Type, - root.StateStore.Type, - ), - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - case root.StateStore != nil: - // state_store config present - factory, fDiags := c.Meta.StateStoreProviderFactoryFromConfig(root.StateStore, configLocks) - diags = diags.Append(fDiags) - if fDiags.HasErrors() { - return nil, true, diags - } - - // If overrides supplied by -backend-config CLI flag, process them - var configOverride hcl.Body - if !initArgs.BackendConfig.Empty() { - // We need to launch an instance of the provider to get the config of the state store for processing any overrides. - provider, err := factory() - defer provider.Close() // Stop the child process once we're done with it here. - if err != nil { - diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) - return nil, true, diags - } - - resp := provider.GetProviderSchema() - - if len(resp.StateStores) == 0 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Provider does not support pluggable state storage", - Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", - root.StateStore.Provider.Name, - root.StateStore.ProviderAddr), - Subject: &root.StateStore.DeclRange, - }) - return nil, true, diags - } - - stateStoreSchema, exists := resp.StateStores[root.StateStore.Type] - if !exists { - suggestions := slices.Sorted(maps.Keys(resp.StateStores)) - suggestion := didyoumean.NameSuggestion(root.StateStore.Type, suggestions) - if suggestion != "" { - suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) - } - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "State store not implemented by the provider", - Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", - root.StateStore.Type, root.StateStore.Provider.Name, - root.StateStore.ProviderAddr, suggestion), - Subject: &root.StateStore.DeclRange, - }) - return nil, true, diags - } - - // Handle any overrides supplied via -backend-config CLI flags - var overrideDiags tfdiags.Diagnostics - configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, stateStoreSchema.Body) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } - - opts = &BackendOpts{ - StateStoreConfig: root.StateStore, - Locks: configLocks, - CreateDefaultWorkspace: initArgs.CreateDefaultWorkspace, - ConfigOverride: configOverride, - Init: true, - ViewType: initArgs.ViewType, - } - - case root.Backend != nil: - // backend config present - backendType := root.Backend.Type - if backendType == "cloud" { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Unsupported backend type", - Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure HCP Terraform, declare a 'cloud' block instead.", backendType), - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - } - - bf := backendInit.Backend(backendType) - if bf == nil { - detail := fmt.Sprintf("There is no backend type named %q.", backendType) - if msg, removed := backendInit.RemovedBackends[backendType]; removed { - detail = msg - } - - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Unsupported backend type", - Detail: detail, - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - } - - b := bf() - backendSchema := b.ConfigSchema() - backendConfig := root.Backend - - // If overrides supplied by -backend-config CLI flag, process them - var configOverride hcl.Body - if !initArgs.BackendConfig.Empty() { - var overrideDiags tfdiags.Diagnostics - configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, backendSchema) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } - - opts = &BackendOpts{ - BackendConfig: backendConfig, - Locks: configLocks, - ConfigOverride: configOverride, - Init: true, - ViewType: initArgs.ViewType, - } - - default: - // No config; defaults to local state storage - - // If the user supplied a -backend-config on the CLI but no backend - // block was found in the configuration, it's likely - but not - // necessarily - a mistake. Return a warning. - if !initArgs.BackendConfig.Empty() { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Missing backend configuration", - `-backend-config was used without a "backend" block in the configuration. - -If you intended to override the default local backend configuration, -no action is required, but you may add an explicit backend block to your -configuration to clear this warning: - -terraform { - backend "local" {} -} - -However, if you intended to override a defined backend, please verify that -the backend configuration is present and valid. -`, - )) - } - - opts = &BackendOpts{ - Init: true, - Locks: configLocks, - ViewType: initArgs.ViewType, - } - } - - back, backDiags := c.Backend(opts) - diags = diags.Append(backDiags) - return back, true, diags -} diff --git a/internal/command/testdata/init-get/output.jsonlog b/internal/command/testdata/init-get/output.jsonlog index 88acf532fd..1fe0bb396e 100644 --- a/internal/command/testdata/init-get/output.jsonlog +++ b/internal/command/testdata/init-get/output.jsonlog @@ -1,7 +1,7 @@ {"@level":"info","@message":"Terraform 1.9.0-dev","@module":"terraform.ui","terraform":"1.9.0-dev","type":"version","ui":"1.2"} -{"@level":"info","@message":"Initializing the backend...","@module":"terraform.ui","message_code": "initializing_backend_message","type":"init_output"} {"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","message_code": "initializing_modules_message","type":"init_output"} {"@level":"info","@message":"- foo in foo","@module":"terraform.ui","type":"log"} -{"@level":"info","@message":"Initializing provider plugins...","@module":"terraform.ui","message_code": "initializing_provider_plugin_message","type":"init_output"} +{"@level":"info","@message":"Initializing provider plugins found in the configuration...","@module":"terraform.ui","message_code": "initializing_provider_plugin_from_config_message","type":"init_output"} +{"@level":"info","@message":"Initializing the backend...","@module":"terraform.ui","message_code": "initializing_backend_message","type":"init_output"} {"@level":"info","@message":"Terraform has been successfully initialized!","@module":"terraform.ui","message_code": "output_init_success_message","type":"init_output"} {"@level":"info","@message":"You may now begin working with Terraform. Try running \"terraform plan\" to see\nany changes that are required for your infrastructure. All Terraform commands\nshould now work.\n\nIf you ever set or change modules or backend configuration for Terraform,\nrerun this command to reinitialize your working directory. If you forget, other\ncommands will detect it and remind you to do so if necessary.","@module":"terraform.ui","message_code": "output_init_success_cli_message","type":"init_output"} diff --git a/internal/command/views/init.go b/internal/command/views/init.go index f051425d48..1789c9b645 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -225,7 +225,7 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe }, "reusing_version_during_state_provider_init": { HumanValue: "- Reusing previous version of %s", - JSONValue: "%s: Reusing previous version of %s", + JSONValue: "Reusing previous version of %s", }, "finding_matching_version_message": { HumanValue: "- Finding %s versions matching %q...", @@ -331,32 +331,27 @@ const ( // Following message codes are used and documented EXTERNALLY // Keep docs/internals/machine-readable-ui.mdx up to date with // this list when making changes here. - CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" - EmptyMessage InitMessageCode = "empty_message" - OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" - OutputInitSuccessMessage InitMessageCode = "output_init_success_message" - OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" - OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" - OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" - UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" - InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" - InitializingModulesMessage InitMessageCode = "initializing_modules_message" - InitializingBackendMessage InitMessageCode = "initializing_backend_message" - InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" - DefaultWorkspaceCreatedMessage InitMessageCode = "default_workspace_created_message" - InitializingProviderPluginMessage InitMessageCode = "initializing_provider_plugin_message" - LockInfo InitMessageCode = "lock_info" - DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" + CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" + EmptyMessage InitMessageCode = "empty_message" + OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" + OutputInitSuccessMessage InitMessageCode = "output_init_success_message" + OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" + OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" + OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" + UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" + InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" + InitializingModulesMessage InitMessageCode = "initializing_modules_message" + InitializingBackendMessage InitMessageCode = "initializing_backend_message" + InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" + InitializingProviderPluginFromConfigMessage InitMessageCode = "initializing_provider_plugin_from_config_message" + InitializingProviderPluginFromStateMessage InitMessageCode = "initializing_provider_plugin_from_state_message" + ReusingVersionIdentifiedFromConfig InitMessageCode = "reusing_version_during_state_provider_init" + DefaultWorkspaceCreatedMessage InitMessageCode = "default_workspace_created_message" + LockInfo InitMessageCode = "lock_info" + DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" //// Message codes below are ONLY used INTERNALLY (for now) - // InitializingProviderPluginFromConfigMessage indicates the beginning of installing of providers described in configuration - InitializingProviderPluginFromConfigMessage InitMessageCode = "initializing_provider_plugin_from_config_message" - // InitializingProviderPluginFromStateMessage indicates the beginning of installing of providers described in state - InitializingProviderPluginFromStateMessage InitMessageCode = "initializing_provider_plugin_from_state_message" - // DependenciesLockPendingChangesInfo indicates when a provider installation step will reuse a provider from a previous installation step in the current operation - ReusingVersionIdentifiedFromConfig InitMessageCode = "reusing_version_during_state_provider_init" - // InitConfigError indicates problems encountered during initialisation InitConfigError InitMessageCode = "init_config_error" // BackendConfiguredSuccessMessage indicates successful backend configuration diff --git a/internal/command/views/init_test.go b/internal/command/views/init_test.go index 4382564f8a..13847a5068 100644 --- a/internal/command/views/init_test.go +++ b/internal/command/views/init_test.go @@ -129,10 +129,10 @@ func TestNewInit_jsonViewOutput(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.Output(InitializingProviderPluginMessage) + newInit.Output(InitializingProviderPluginFromConfigMessage) version := tfversion.String() - want := []map[string]interface{}{ + want := []map[string]any{ { "@level": "info", "@message": fmt.Sprintf("Terraform %s", version), @@ -143,8 +143,8 @@ func TestNewInit_jsonViewOutput(t *testing.T) { }, { "@level": "info", - "@message": "Initializing provider plugins...", - "message_code": "initializing_provider_plugin_message", + "@message": "Initializing provider plugins found in the configuration...", + "message_code": "initializing_provider_plugin_from_config_message", "@module": "terraform.ui", "type": "init_output", }, @@ -231,7 +231,7 @@ func TestNewInit_jsonViewLog(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.LogInitMessage(InitializingProviderPluginMessage) + newInit.LogInitMessage(InitializingProviderPluginFromConfigMessage) version := tfversion.String() want := []map[string]interface{}{ @@ -245,7 +245,7 @@ func TestNewInit_jsonViewLog(t *testing.T) { }, { "@level": "info", - "@message": "Initializing provider plugins...", + "@message": "Initializing provider plugins found in the configuration...", "@module": "terraform.ui", "type": "log", }, @@ -282,10 +282,10 @@ func TestNewInit_humanViewOutput(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.Output(InitializingProviderPluginMessage) + newInit.Output(InitializingProviderPluginFromConfigMessage) actual := done(t).All() - expected := "Initializing provider plugins..." + expected := "Initializing provider plugins found in the configuration..." if !strings.Contains(actual, expected) { t.Fatalf("expected output to contain: %s, but got %s", expected, actual) } From 7127c76b042e4bb0083d1401b38ef0d1c03866db Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Wed, 4 Mar 2026 10:09:21 +0000 Subject: [PATCH 005/136] command/views: Bump UI version to v1.3 (#38231) --- internal/command/query_test.go | 5 +++-- internal/command/views/json_view.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/command/query_test.go b/internal/command/query_test.go index 58359e940c..eee626c4b6 100644 --- a/internal/command/query_test.go +++ b/internal/command/query_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" @@ -848,7 +849,7 @@ func TestQuery_JSON_Raw(t *testing.T) { { name: "basic query", directory: "basic", - expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} + expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"` + views.JSON_UI_VERSION + `"} {"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} @@ -858,7 +859,7 @@ func TestQuery_JSON_Raw(t *testing.T) { { name: "empty result", directory: "empty-result", - expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} + expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"` + views.JSON_UI_VERSION + `"} {"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index f7d1a2bba2..7bb878fc83 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -17,7 +17,7 @@ import ( // This version describes the schema of JSON UI messages. This version must be // updated after making any changes to this view, the jsonHook, or any of the // command/views/json package. -const JSON_UI_VERSION = "1.2" +const JSON_UI_VERSION = "1.3" func NewJSONView(view *View) *JSONView { log := hclog.New(&hclog.LoggerOptions{ From 28b76c110569789cbff77a0e3207929bad7daeb3 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 16:20:42 +0100 Subject: [PATCH 006/136] Introduce a new init graph The new init graph builder creates a small graph that can be used to install or load module configuration. It reuses different walkers to either install modules or validate the manifest during configuration loading. The new module install node dynamically expands the graph after module installation with a subgraph for the installed module. --- internal/terraform/config_graph_build.go | 42 ++ internal/terraform/context_init.go | 60 ++ internal/terraform/context_init_test.go | 712 ++++++++++++++++++ internal/terraform/graph_builder_init.go | 83 ++ internal/terraform/graph_walk_operation.go | 1 + internal/terraform/node_module_install.go | 392 ++++++++++ internal/terraform/node_module_variable.go | 31 +- internal/terraform/node_root_variable.go | 48 +- internal/terraform/transform_filter.go | 42 ++ internal/terraform/transform_filter_test.go | 386 ++++++++++ .../terraform/transform_module_install.go | 48 ++ .../terraform/transform_module_variable.go | 10 +- internal/terraform/walkoperation_string.go | 5 +- 13 files changed, 1840 insertions(+), 20 deletions(-) create mode 100644 internal/terraform/config_graph_build.go create mode 100644 internal/terraform/context_init.go create mode 100644 internal/terraform/context_init_test.go create mode 100644 internal/terraform/graph_builder_init.go create mode 100644 internal/terraform/node_module_install.go create mode 100644 internal/terraform/transform_filter.go create mode 100644 internal/terraform/transform_filter_test.go create mode 100644 internal/terraform/transform_module_install.go diff --git a/internal/terraform/config_graph_build.go b/internal/terraform/config_graph_build.go new file mode 100644 index 0000000000..e8451f238f --- /dev/null +++ b/internal/terraform/config_graph_build.go @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// BuildConfigWithGraph builds a configuration tree using the init graph so +// that module sources and versions can be resolved with full expression +// evaluation before loading descendant modules. +func BuildConfigWithGraph(rootMod *configs.Module, walker configs.ModuleWalker, vars InputValues, loader configs.MockDataLoader) (*configs.Config, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + ctx, ctxDiags := NewContext(&ContextOpts{ + Parallelism: 1, + }) + diags = diags.Append(ctxDiags) + if ctxDiags.HasErrors() { + return nil, diags + } + + cfg, initDiags := ctx.Init(rootMod, InitOpts{ + Walker: walker, + SetVariables: vars, + }) + diags = diags.Append(initDiags) + if diags.HasErrors() { + if cfg == nil && rootMod != nil { + cfg = &configs.Config{Module: rootMod} + cfg.Root = cfg + } + return cfg, diags + } + + finalDiags := configs.FinalizeConfig(cfg, walker, loader) + diags = diags.Append(finalDiags) + + return cfg, diags +} diff --git a/internal/terraform/context_init.go b/internal/terraform/context_init.go new file mode 100644 index 0000000000..e84a4b2f5e --- /dev/null +++ b/internal/terraform/context_init.go @@ -0,0 +1,60 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type InitOpts struct { + Walker configs.ModuleWalker + + // SetVariables are the raw values for root module variables as provided + // by the user who is requesting the run, prior to any normalization or + // substitution of defaults. See the documentation for the InputValue + // type for more information on how to correctly populate this. + SetVariables InputValues +} + +func (c *Context) Init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) { + return c.init(rootMod, initOpts) +} + +func (c *Context) init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) { + defer c.acquireRun("init")() + var diags tfdiags.Diagnostics + + config := &configs.Config{ + Module: rootMod, + Path: addrs.RootModule, + Children: map[string]*configs.Config{}, + } + config.Root = config + + graph, moreDiags := c.initGraph(config, initOpts) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return nil, diags + } + + walker, walkDiags := c.walk(graph, walkInit, &graphWalkOpts{ + Config: config, + }) + diags = diags.Append(walker.NonFatalDiagnostics) + diags = diags.Append(walkDiags) + + return config, diags +} + +func (c *Context) initGraph(config *configs.Config, initOpts InitOpts) (*Graph, tfdiags.Diagnostics) { + graph, diags := (&InitGraphBuilder{ + Config: config, + RootVariableValues: initOpts.SetVariables, + Walker: initOpts.Walker, + }).Build(addrs.RootModuleInstance) + + return graph, diags +} diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go new file mode 100644 index 0000000000..c9f7f59fd7 --- /dev/null +++ b/internal/terraform/context_init_test.go @@ -0,0 +1,712 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "path/filepath" + "strings" + "testing" + + version "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +var _ configs.ModuleWalker = (*MockModuleWalker)(nil) + +type MockModuleWalker struct { + Calls []*configs.ModuleRequest + DefaultModule *configs.Module + // the string key refers to ModuleSource.String() + MockedCalls map[string]*configs.Module +} + +func (m *MockModuleWalker) LoadModule(req *configs.ModuleRequest) (*configs.Module, *version.Version, hcl.Diagnostics) { + m.Calls = append(m.Calls, req) + + if mod, ok := m.MockedCalls[req.SourceAddr.String()]; ok { + return mod, nil, nil + } + + return m.DefaultModule, nil, nil +} + +func (m *MockModuleWalker) MockModuleCalls(t *testing.T, calls map[string]*configs.Module) { + t.Helper() + if m.MockedCalls == nil { + m.MockedCalls = make(map[string]*configs.Module) + } + for k, v := range calls { + // Make sure we can parse the module source + ms := mustModuleSource(t, k) + m.MockedCalls[ms.String()] = v + } +} + +func TestInit(t *testing.T) { + for name, tc := range map[string]struct { + module map[string]string + vars InputValues + mockedLoadModuleCalls map[string]map[string]string + // m -> root module + // mc -> module calls + expectDiags func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics + expectLoadModuleCalls []*configs.ModuleRequest + }{ + "empty config": { + module: map[string]string{"main.tf": ``}, + }, + "local - no variables": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "./modules/example" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "remote - no variables": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "terraform-aws-modules/vpc/aws" + version = "6.6.0" +} + +module "example2" { + source = "terraform-iaac/cert-manager/kubernetes" +} + `, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }, { + SourceAddr: mustModuleSource(t, "terraform-aws-modules/vpc/aws"), + VersionConstraint: mustVersionContraint(t, "= 6.6.0"), + }}, + }, + + "local - with variables": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "local with non-static variables": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + // TODO: We should try to somehow add an "extra" into the diagnostics to indicate + // that this may be caused by a non-static variable used during init. + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: `The value of a reference in the module source is unknown.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 27, Byte: 82}, + End: hcl.Pos{Line: 6, Column: 35, Byte: 90}, + }, + }) + }, + }, + + "remote - with variable in source": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/${var.name}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }}, + }, + "remote - with variable in constraint": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/cert-manager/kubernetes" + version = ">= ${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("1.2.3"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + VersionConstraint: mustVersionContraint(t, ">= 1.2.3"), + }}, + }, + + "locals in module sources": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} + +locals { + org_and_repo = "terraform-iaac/${var.name}" +} + +module "example2" { + source = "${local.org_and_repo}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + VersionConstraint: mustVersionContraint(t, ">= 1.2.3"), + }}, + }, + + "each in module sources": { + module: map[string]string{ + "main.tf": ` +module "example" { + for_each = toset(["cert-manager", "helm"]) + source = "terraform-iaac/${each.key}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: `The module source can only reference input variables and local values.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 4, Column: 31, Byte: 95}, + End: hcl.Pos{Line: 4, Column: 39, Byte: 103}, + }, + }) + }, + }, + + "module variables in source": { + module: map[string]string{ + "main.tf": ` +module "mod" { + source = "./mod" + name = "cert-manager" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./mod": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "terraform-iaac/${var.name}/kubernetes" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./mod"), + }, { + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }}, + }, + + "undefined variable in module source": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/${var.name}/kubernetes" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Required variable not set", + Detail: `The variable "name" is required, but is not set.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 16}, + }, + }) + }, + }, + + "resource reference in module source": { + module: map[string]string{ + "main.tf": ` +resource "null_resource" "example" {} + +module "example" { + source = "terraform-iaac/${null_resource.example.id}/kubernetes" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference input variables and local values.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 33, Byte: 91}, + End: hcl.Pos{Line: 5, Column: 54, Byte: 112}, + }, + }) + }, + }, + "resource reference in module call": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + default = "aws" + const = true +} +resource "null_resource" "example" {} + +module "example" { + source = "./${var.name}" + + name = var.name + this_should_be_unknown_and_not_cause_error = null_resource.example.id +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} + +variable "this_should_be_unknown_and_not_cause_error" { + type = string +} + +module "example" { + source = "terraform-iaac/${var.name}/kubernetes" +} + + +output "foo" { + value = var.this_should_be_unknown_and_not_cause_error +} + `, + }, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/aws/kubernetes"), + }, { + SourceAddr: mustModuleSource(t, "./aws"), + }}, + }, + + "module output reference in module source": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "./module/example" +} + +module "example2" { + source = "terraform-iaac/${module.example.id}/kubernetes" +} + `, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./module/example": { + "main.tf": ` +output "id" { + value = "example-id" +} + `, + }}, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./module/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference input variables and local values.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 33, Byte: 107}, + End: hcl.Pos{Line: 7, Column: 50, Byte: 124}, + }, + }) + }, + }, + + "nested module loading - no variables": { + module: map[string]string{ + "main.tf": ` +module "parent" { + source = "hashicorp/parent/aws" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "hashicorp/parent/aws": { + "main.tf": ` +module "child" { + source = "hashicorp/child/aws" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/child/aws"), + }}, + }, + + "nested module loading - with variables": { + module: map[string]string{ + "main.tf": ` +module "parent" { + source = "hashicorp/parent/aws" + name = "child" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "hashicorp/parent/aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "child" { + source = "hashicorp/${var.name}/aws" + name = "grand${var.name}" +} + `, + }, + "hashicorp/child/aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "grandchild" { + source = "hashicorp/${var.name}/aws" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/child/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/grandchild/aws"), + }}, + }, + "module nested expansion": { + module: map[string]string{ + "main.tf": ` +module "fromdisk" { + source = "./mod" + namespace = "terraform-iaac" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./mod": { + "main.tf": ` +locals { + source = var.namespace +} +variable "namespace" { + type = string + const = true +} +module "terraform" { + source = "${var.namespace}/helm/kubernetes" +} +output "name" { + value = "fooo" +} +`, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./mod"), + }, { + SourceAddr: mustModuleSource(t, "terraform-iaac/helm/kubernetes"), + }}, + }, + + "static variable with no value and no default": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Required variable not set`, + Detail: `The variable "name" is required, but is not set.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 16}, + }, + }) + }, + }, + + "static variable with default": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + const = true + default = "example" +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "non-static variable passed into static module variable": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + default = "example" +} +module "example" { + source = "./modules/example" + name = "./modules/${var.name}2" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./modules/example": { + "main.tf": ` +variable "name" { + type = string + const = true +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Static variables must be known`, + Detail: `Only a static value can be passed into a static module variable.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 8, Column: 10, Byte: 118}, + End: hcl.Pos{Line: 8, Column: 34, Byte: 142}, + }, + }) + }, + }, + + "non-static module variable used as static": { + module: map[string]string{"main.tf": ` +module "example" { + source = "./modules/example" + + name = "foo" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./modules/example": { + "main.tf": ` +variable "name" { + type = string +} + +module "nested" { + source = "./modules/${var.name}" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + // TODO: We should try to somehow add an "extra" into the diagnostics to indicate + // that this may be caused by a non-static variable used during init. + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: `The value of a reference in the module source is unknown.`, + Subject: &hcl.Range{ + Filename: filepath.Join(mc["./modules/example"].SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 27, Byte: 82}, + End: hcl.Pos{Line: 7, Column: 35, Byte: 90}, + }, + }) + }, + }, + } { + t.Run(name, func(t *testing.T) { + m := testRootModuleInline(t, tc.module) + + ctx := testContext2(t, &ContextOpts{ + Parallelism: 1, + }) + moduleWalker := MockModuleWalker{ + DefaultModule: testRootModuleInline(t, map[string]string{"main.tf": `// empty`}), + } + mockedModules := make(map[string]*configs.Module) + if tc.mockedLoadModuleCalls != nil { + for k, v := range tc.mockedLoadModuleCalls { + mockedModules[k] = testRootModuleInline(t, v) + } + moduleWalker.MockModuleCalls(t, mockedModules) + } + _, diags := ctx.Init(m, InitOpts{ + SetVariables: tc.vars, + Walker: &moduleWalker, + }) + if tc.expectDiags != nil { + tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectDiags(m, mockedModules)) + } else { + tfdiags.AssertNoDiagnostics(t, diags) + } + + if len(moduleWalker.Calls) != len(tc.expectLoadModuleCalls) { + t.Fatalf("expected %d LoadModule calls, got %d", len(tc.expectLoadModuleCalls), len(moduleWalker.Calls)) + } + + // Create a map of expected sources for easier comparison + expectedSources := make(map[string]bool) + foundSources := []string{} + for _, expected := range tc.expectLoadModuleCalls { + expectedSources[expected.SourceAddr.String()] = false + } + + // Mark sources as found + for _, call := range moduleWalker.Calls { + source := call.SourceAddr.String() + foundSources = append(foundSources, source) + if _, exists := expectedSources[source]; !exists { + t.Errorf("unexpected LoadModule call for source %q", source) + } else { + expectedSources[source] = true + } + } + + // Check all expected sources were called + for source, found := range expectedSources { + if !found { + t.Errorf("expected LoadModule call for source %q but it was not called. Calls that were made: \n %s", source, strings.Join(foundSources, ", ")) + } + } + }) + } +} + +func mustModuleSource(t *testing.T, rawStr string) addrs.ModuleSource { + src, err := moduleaddrs.ParseModuleSource(rawStr) + if err != nil { + t.Fatalf("failed to parse module source %q: %s", rawStr, err) + } + return src +} + +func mustVersionContraint(t *testing.T, rawStr string) configs.VersionConstraint { + constraints, err := version.NewConstraint(rawStr) + if err != nil { + t.Fatalf("failed to parse version constraint %q: %s", rawStr, err) + } + return configs.VersionConstraint{ + Required: constraints, + } +} diff --git a/internal/terraform/graph_builder_init.go b/internal/terraform/graph_builder_init.go new file mode 100644 index 0000000000..da985bae72 --- /dev/null +++ b/internal/terraform/graph_builder_init.go @@ -0,0 +1,83 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/dag" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type InitGraphBuilder struct { + // A config derived from the root module + Config *configs.Config + + RootVariableValues InputValues + + Walker configs.ModuleWalker +} + +// See GraphBuilder +func (b *InitGraphBuilder) Build(path addrs.ModuleInstance) (*Graph, tfdiags.Diagnostics) { + log.Printf("[TRACE] building graph for terraform dependencies") + return (&BasicGraphBuilder{ + Steps: b.Steps(), + Name: "InitGraphBuilder", + }).Build(path) +} + +// See GraphBuilder +func (b *InitGraphBuilder) Steps() []GraphTransformer { + steps := []GraphTransformer{} + + if b.Config.Parent == nil { + steps = append(steps, &RootVariableTransformer{ + Config: b.Config, + RawValues: b.RootVariableValues, + }) + } else { + steps = append(steps, &ModuleVariableTransformer{ + Config: b.Config, + ModuleOnly: true, + }) + } + + steps = append(steps, []GraphTransformer{ + &ModuleTransformer{ + Config: b.Config, + Walker: b.Walker, + }, + + &LocalTransformer{ + Config: b.Config, + }, + + &ReferenceTransformer{}, + + // Filters out any vertices that aren't relevant to the init graph + &TransformFilter{ + Keep: func(v dag.Vertex) bool { + switch n := v.(type) { + case *nodeInstallModule: + return true + case *NodeRootVariable: + return n.Config.Const + case *nodeExpandModuleVariable: + return n.Config.Const + default: + return false + } + }, + }, + + &RootTransformer{}, + + &TransitiveReductionTransformer{}, + }...) + + return steps +} diff --git a/internal/terraform/graph_walk_operation.go b/internal/terraform/graph_walk_operation.go index a408f78465..fcfc23bfae 100644 --- a/internal/terraform/graph_walk_operation.go +++ b/internal/terraform/graph_walk_operation.go @@ -17,4 +17,5 @@ const ( walkDestroy walkImport walkEval // used just to prepare EvalContext for expression evaluation, with no other actions + walkInit ) diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go new file mode 100644 index 0000000000..9692c8cb56 --- /dev/null +++ b/internal/terraform/node_module_install.go @@ -0,0 +1,392 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/hashicorp/terraform/internal/lang/langrefs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type nodeInstallModule struct { + // We're using a ModuleInstance here, + // because the downstream graph builder requires it. + // But it was constructed with addrs.NoKey + Addr addrs.ModuleInstance + ModuleCall *configs.ModuleCall + Parent *configs.Config + Walker configs.ModuleWalker + + // Stores the configuration of the installed module + Config *configs.Config + // Stores the version of the installed module + Version *version.Version +} + +var ( + _ GraphNodeExecutable = (*nodeInstallModule)(nil) + _ GraphNodeReferencer = (*nodeInstallModule)(nil) + _ GraphNodeDynamicExpandable = (*nodeInstallModule)(nil) + _ GraphNodeModuleInstance = (*nodeInstallModule)(nil) +) + +func (n *nodeInstallModule) Path() addrs.ModuleInstance { + return n.Addr.Parent() +} + +func (n *nodeInstallModule) Name() string { + return n.Addr.String() +} + +func (n *nodeInstallModule) ModulePath() addrs.Module { + return n.Addr.Module().Parent() +} + +func (n *nodeInstallModule) References() []*addrs.Reference { + var refs []*addrs.Reference + + sourceRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.SourceExpr) + refs = append(refs, sourceRefs...) + versionRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.VersionExpr) + refs = append(refs, versionRefs...) + + // We need to resolve all module inputs as well, because some might be used + // in the module as a constant variable to build a nested module source + attrs, _ := n.ModuleCall.Config.JustAttributes() + for _, attr := range attrs { + inputRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, attr.Expr) + refs = append(refs, inputRefs...) + } + + return refs +} + +func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + var version configs.VersionConstraint + if n.ModuleCall.VersionExpr != nil { + var versionDiags tfdiags.Diagnostics + version, versionDiags = decodeVersionConstraint(n.ModuleCall.VersionExpr, ctx) + diags = diags.Append(versionDiags) + if diags.HasErrors() { + return diags + } + } + + hasVersion := n.ModuleCall.VersionExpr != nil + source, sourceDiags := decodeSource(n.ModuleCall.SourceExpr, hasVersion, ctx) + diags = diags.Append(sourceDiags) + if diags.HasErrors() { + return diags + } + + req := &configs.ModuleRequest{ + Name: n.ModuleCall.Name, + Path: n.Addr.Module(), + SourceAddr: source, + SourceAddrRange: n.ModuleCall.SourceExpr.Range(), + VersionConstraint: version, + Parent: n.Parent, + CallRange: n.ModuleCall.DeclRange, + } + + cfg, v, modDiags := n.Walker.LoadModule(req) + diags = diags.Append(modDiags) + if diags.HasErrors() { + return diags + } + + config := &configs.Config{ + Module: cfg, + Parent: n.Parent, + Path: n.Addr.Module(), + Root: n.Parent.Root, + Children: map[string]*configs.Config{}, + CallRange: n.ModuleCall.DeclRange, + SourceAddr: source, + SourceAddrRange: n.ModuleCall.SourceExpr.Range(), + Version: v, + VersionConstraint: version, + } + + // Insert the installed module into the children of the current module + currentModuleKey := n.Addr[len(n.Addr)-1].Name + n.Parent.Children[currentModuleKey] = config + + n.Config = config + n.Version = v + + return nil +} + +func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diagnostics) { + var g Graph + var diags tfdiags.Diagnostics + + expander := ctx.InstanceExpander() + _, call := n.Addr.Call() + expander.SetModuleSingle(n.Path(), call) + + graph, graphDiags := (&InitGraphBuilder{ + Config: n.Config, + Walker: n.Walker, + }).Build(n.Addr) + diags = diags.Append(graphDiags) + if graphDiags.HasErrors() { + return nil, diags + } + g.Subsume(&graph.AcyclicGraph.Graph) + + addRootNodeToGraph(&g) + + return &g, nil +} + +func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var addr addrs.ModuleSource + var err error + + refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, sourceExpr) + diags = diags.Append(refsDiags) + if diags.HasErrors() { + return nil, diags + } + + for _, ref := range refs { + switch ref.Subject.(type) { + case addrs.InputVariable, addrs.LocalValue: + // These are allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference input variables and local values.", + Subject: ref.SourceRange.ToHCL().Ptr(), + }) + return nil, diags + } + } + + value, valueDiags := ctx.EvaluateExpr(sourceExpr, cty.String, nil) + diags = diags.Append(valueDiags) + if diags.HasErrors() { + return nil, diags + } + + if !value.IsWhollyKnown() { + tExpr, ok := sourceExpr.(*hclsyntax.TemplateExpr) + if !ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source contains a reference that is unknown during init.", + Subject: sourceExpr.Range().Ptr(), + }) + return nil, diags + } + for _, part := range tExpr.Parts { + partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil) + diags = diags.Append(partDiags) + if diags.HasErrors() { + return nil, diags + } + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + hclCtx, evalDiags := scope.EvalContext(refs) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return nil, diags + } + if !partVal.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The value of a reference in the module source is unknown.", + Subject: part.Range().Ptr(), + Expression: part, + EvalContext: hclCtx, + Extra: diagnosticCausedByUnknown(true), + }) + return nil, diags + } + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source contains a reference that is unknown.", + Subject: sourceExpr.Range().Ptr(), + }) + return nil, diags + } + + if hasVersion { + addr, err = moduleaddrs.ParseModuleSourceRegistry(value.AsString()) + } else { + addr, err = moduleaddrs.ParseModuleSource(value.AsString()) + } + if err != nil { + // NOTE: We leave add as nil for any situation where the + // source attribute is invalid, so any code which tries to carefully + // use the partial result of a failed config decode must be + // resilient to that. + addr = nil + + // NOTE: In practice it's actually very unlikely to end up here, + // because our source address parser can turn just about any string + // into some sort of remote package address, and so for most errors + // we'll detect them only during module installation. There are + // still a _few_ purely-syntax errors we can catch at parsing time, + // though, mostly related to remote package sub-paths and local + // paths. + switch err := err.(type) { + case *moduleaddrs.MaybeRelativePathErr: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf( + "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", + err.Addr, err.Addr, + ), + Subject: sourceExpr.Range().Ptr(), + }) + default: + if hasVersion { + // In this case we'll include some extra context that + // we assumed a registry source address due to the + // version argument. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid registry module source address", + Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), + Subject: sourceExpr.Range().Ptr(), + }) + } else { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), + Subject: sourceExpr.Range().Ptr(), + }) + } + } + } + + return addr, diags +} + +func decodeVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs.VersionConstraint, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + rng := versionExpr.Range() + + ret := configs.VersionConstraint{ + DeclRange: rng, + } + + refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, versionExpr) + diags = diags.Append(refsDiags) + if diags.HasErrors() { + return ret, diags + } + + for _, ref := range refs { + switch ref.Subject.(type) { + case addrs.InputVariable, addrs.LocalValue: + // These are allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version can only reference input variables and local values.", + Subject: ref.SourceRange.ToHCL().Ptr(), + }) + return ret, diags + } + } + + value, valueDiags := ctx.EvaluateExpr(versionExpr, cty.String, nil) + diags = diags.Append(valueDiags) + if diags.HasErrors() { + return ret, diags + } + + if value.IsNull() { + // A null version constraint is strange, but we'll just treat it + // like an empty constraint set. + return ret, diags + } + + if !value.IsWhollyKnown() { + tExpr, ok := versionExpr.(*hclsyntax.TemplateExpr) + if !ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version contains a reference that is unknown during init.", + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + for _, part := range tExpr.Parts { + partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil) + diags = diags.Append(partDiags) + if diags.HasErrors() { + return ret, diags + } + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + hclCtx, evalDiags := scope.EvalContext(refs) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return ret, diags + } + if !partVal.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The value of a reference in the module version is unknown.", + Subject: part.Range().Ptr(), + Expression: part, + EvalContext: hclCtx, + Extra: diagnosticCausedByUnknown(true), + }) + return ret, diags + } + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version contains a reference that is unknown.", + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + + constraintStr := value.AsString() + constraints, err := version.NewConstraint(constraintStr) + if err != nil { + // NewConstraint doesn't return user-friendly errors, so we'll just + // ignore the provided error and produce our own generic one. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + Detail: "This string does not use correct version constraint syntax.", // Not very actionable :( + Subject: rng.Ptr(), + }) + return ret, diags + } + + ret.Required = constraints + return ret, diags +} diff --git a/internal/terraform/node_module_variable.go b/internal/terraform/node_module_variable.go index a458af0990..7672906851 100644 --- a/internal/terraform/node_module_variable.go +++ b/internal/terraform/node_module_variable.go @@ -217,14 +217,26 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t log.Printf("[TRACE] nodeModuleVariable: evaluating %s", n.Addr) var val cty.Value + var errSourceRange tfdiags.SourceRange var err error switch op { case walkValidate: - val, err = n.evalModuleVariable(ctx, true) + val, errSourceRange, err = n.evalModuleVariable(ctx, true) diags = diags.Append(err) + case walkInit: + // During init we only want to record the value if it's static; + // otherwise we record it as dynamic to prevent its use in + // static contexts. + // We still evaluate it fully here to catch any errors early. + if n.Config.Const { + val, errSourceRange, err = n.evalModuleVariable(ctx, false) + diags = diags.Append(err) + } else { + val = cty.DynamicVal + } default: - val, err = n.evalModuleVariable(ctx, false) + val, errSourceRange, err = n.evalModuleVariable(ctx, false) diags = diags.Append(err) } if diags.HasErrors() { @@ -236,6 +248,15 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t diags = diags.Append(deprecationDiags) } + if op == walkInit && n.Config.Const && !val.IsWhollyKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Static variables must be known", + Detail: "Only a static value can be passed into a static module variable.", + Subject: errSourceRange.ToHCL().Ptr(), + }) + } + // Set values for arguments of a child module call, for later retrieval // during expression evaluation. ctx.NamedValues().SetInputVariableValue(n.Addr, val) @@ -263,7 +284,7 @@ func (n *nodeModuleVariable) DotNode(name string, opts *dag.DotOpts) *dag.DotNod // validateOnly indicates that this evaluation is only for config // validation, and we will not have any expansion module instance // repetition data. -func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, error) { +func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, tfdiags.SourceRange, error) { var diags tfdiags.Diagnostics var givenVal cty.Value var errSourceRange tfdiags.SourceRange @@ -289,7 +310,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo val, moreDiags := scope.EvalExpr(expr, cty.DynamicPseudoType) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { - return cty.DynamicVal, diags.ErrWithWarnings() + return cty.DynamicVal, errSourceRange, diags.ErrWithWarnings() } givenVal = val errSourceRange = tfdiags.SourceRangeFromHCL(expr.Range()) @@ -320,7 +341,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo }) } - return finalVal, diags.ErrWithWarnings() + return finalVal, errSourceRange, diags.ErrWithWarnings() } // nodeModuleVariableInPartialModule represents an infinite set of possible diff --git a/internal/terraform/node_root_variable.go b/internal/terraform/node_root_variable.go index c05c0c9ba0..694a65a0a7 100644 --- a/internal/terraform/node_root_variable.go +++ b/internal/terraform/node_root_variable.go @@ -110,19 +110,43 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di } } - finalVal, moreDiags := PrepareFinalInputVariableValue( - addr, - givenVal, - n.Config, - ) - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { - // No point in proceeding to validations then, because they'll - // probably fail trying to work with a value of the wrong type. - return diags - } + // During init we only want to prepare the final value for static variables. + if op == walkInit { + var finalVal cty.Value + if n.Config.Const { + var moreDiags tfdiags.Diagnostics + finalVal, moreDiags = PrepareFinalInputVariableValue( + addr, + givenVal, + n.Config, + ) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // No point in proceeding to validations then, because they'll + // probably fail trying to work with a value of the wrong type. + return diags + } + } else { + // All non-static variables are unknown during init. + finalVal = cty.UnknownVal(n.Config.Type) + } + ctx.NamedValues().SetInputVariableValue(addr, finalVal) - ctx.NamedValues().SetInputVariableValue(addr, finalVal) + } else { + finalVal, moreDiags := PrepareFinalInputVariableValue( + addr, + givenVal, + n.Config, + ) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // No point in proceeding to validations then, because they'll + // probably fail trying to work with a value of the wrong type. + return diags + } + + ctx.NamedValues().SetInputVariableValue(addr, finalVal) + } // Custom validation rules are handled by a separate graph node of type // nodeVariableValidation, added by variableValidationTransformer. diff --git a/internal/terraform/transform_filter.go b/internal/terraform/transform_filter.go new file mode 100644 index 0000000000..2feb115ac2 --- /dev/null +++ b/internal/terraform/transform_filter.go @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/dag" +) + +// TransformFilter is a GraphTransformer that filters out nodes from the graph based on a provided function. The Keep function should return true for nodes that should be kept in the graph, and false for nodes that should be removed. The transformer will mark all nodes that the node to keep depends on as well, ensuring that the resulting graph is still valid. +type TransformFilter struct { + Keep func(node dag.Vertex) bool +} + +var _ GraphTransformer = (*TransformFilter)(nil) + +func (t *TransformFilter) Transform(g *Graph) error { + // Partition vertices into kept and candidates for removal. + var kept []dag.Vertex + var removalCandidates []dag.Vertex + for _, v := range g.Vertices() { + if t.Keep(v) { + kept = append(kept, v) + } else { + removalCandidates = append(removalCandidates, v) + } + } + + // Also keep all ancestors (transitive dependencies) of the kept + // nodes so the resulting graph stays valid. + ancestors := g.Ancestors(kept...) + + // Remove every vertex that isn't explicitly kept and isn't an + // ancestor of a kept node. + for _, v := range removalCandidates { + if !ancestors.Include(v) { + g.Remove(v) + } + } + + return nil +} diff --git a/internal/terraform/transform_filter_test.go b/internal/terraform/transform_filter_test.go new file mode 100644 index 0000000000..6fff7182aa --- /dev/null +++ b/internal/terraform/transform_filter_test.go @@ -0,0 +1,386 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/dag" +) + +func TestTransformFilter(t *testing.T) { + t.Run("empty graph", func(t *testing.T) { + var g Graph + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) + + t.Run("keep all", func(t *testing.T) { + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("remove all", func(t *testing.T) { + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return false + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) + + t.Run("keep node preserves its dependencies", func(t *testing.T) { + // a -> b -> c + // Keep only "a"; "b" and "c" should be preserved as ancestors. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("keep leaf removes dependents", func(t *testing.T) { + // a -> b -> c + // Keep only "c"; "a" and "b" are not ancestors of "c" so they + // should be removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "c" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := "c" + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("keep middle preserves dependencies and removes dependents", func(t *testing.T) { + // a -> b -> c + // Keep "b"; "c" is an ancestor and stays, "a" depends on "b" + // but is not an ancestor so it is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "b" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("diamond keep root preserves all", func(t *testing.T) { + // a -> b -> d + // a -> c -> d + // Keep "a"; everything is an ancestor of "a" so nothing is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b + c +b + d +c + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("diamond keep one branch", func(t *testing.T) { + // a -> b -> d + // a -> c -> d + // Keep "b"; "d" is an ancestor of "b" so it stays. "a" and "c" + // are not ancestors of "b" so they are removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "b" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +b + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("disconnected nodes are removed", func(t *testing.T) { + // a -> b, c (standalone) + // Keep "a"; "b" is preserved as ancestor, "c" has no connection + // and is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("multiple kept nodes merge their ancestors", func(t *testing.T) { + // a -> b -> d + // c -> d + // Keep "a" and "c"; their combined ancestors are "b" and "d", + // so the entire graph is preserved. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + s := v.(string) + return s == "a" || s == "c" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + d +c + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("shared dependency kept through one branch", func(t *testing.T) { + // a -> c + // b -> c + // Keep "a"; "c" is an ancestor and stays, "b" is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("single node kept", func(t *testing.T) { + var g Graph + g.Add("a") + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := "a" + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("single node removed", func(t *testing.T) { + var g Graph + g.Add("a") + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return false + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) +} diff --git a/internal/terraform/transform_module_install.go b/internal/terraform/transform_module_install.go new file mode 100644 index 0000000000..175ebc8574 --- /dev/null +++ b/internal/terraform/transform_module_install.go @@ -0,0 +1,48 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/dag" +) + +type ModuleTransformer struct { + Config *configs.Config + Walker configs.ModuleWalker +} + +func (t *ModuleTransformer) Transform(graph *Graph) error { + if t.Config == nil { + return nil + } + + for _, call := range t.Config.Module.ModuleCalls { + instancePath := graph.Path.Child(call.Name, addrs.NoKey) + + err := t.transform(graph, t.Config, instancePath, call) + if err != nil { + return err + } + } + + return nil +} + +func (t *ModuleTransformer) transform(graph *Graph, cfg *configs.Config, path addrs.ModuleInstance, modCall *configs.ModuleCall) error { + n := &nodeInstallModule{ + Addr: path, + ModuleCall: modCall, + Parent: cfg, + Walker: t.Walker, + } + var installNode dag.Vertex = n + graph.Add(installNode) + log.Printf("[TRACE] ModuleTransformer: Added %s as %T", path, installNode) + + return nil +} diff --git a/internal/terraform/transform_module_variable.go b/internal/terraform/transform_module_variable.go index 0dc19eb122..e09b9d6baf 100644 --- a/internal/terraform/transform_module_variable.go +++ b/internal/terraform/transform_module_variable.go @@ -29,6 +29,10 @@ import ( type ModuleVariableTransformer struct { Config *configs.Config + // ModuleOnly, if true, makes the transformer only process the + // variables in the current module, skipping any child modules. + ModuleOnly bool + // Planning must be set to true when building a planning graph, and must be // false when building an apply graph. Planning bool @@ -39,7 +43,11 @@ type ModuleVariableTransformer struct { } func (t *ModuleVariableTransformer) Transform(g *Graph) error { - return t.transform(g, nil, t.Config) + if t.ModuleOnly && t.Config.Parent != nil { + return t.transformSingle(g, t.Config.Parent, t.Config) + } else { + return t.transform(g, nil, t.Config) + } } func (t *ModuleVariableTransformer) transform(g *Graph, parent, c *configs.Config) error { diff --git a/internal/terraform/walkoperation_string.go b/internal/terraform/walkoperation_string.go index 20a8220844..5500ba0817 100644 --- a/internal/terraform/walkoperation_string.go +++ b/internal/terraform/walkoperation_string.go @@ -16,11 +16,12 @@ func _() { _ = x[walkDestroy-5] _ = x[walkImport-6] _ = x[walkEval-7] + _ = x[walkInit-8] } -const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEval" +const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEvalwalkInit" -var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84} +var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84, 92} func (i walkOperation) String() string { idx := int(i) - 0 From 11d819048af8290cced683027d383bf410143dbd Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 16:31:32 +0100 Subject: [PATCH 007/136] Store module source and version expressions Instead of evaluating and parsing a module source and version on configuration loading, we now simply store the expression. Decoding is now done during the graph-based configuration loading in the module install node. --- internal/configs/module_call.go | 78 ++----------------------- internal/configs/module_call_test.go | 49 +++++----------- internal/configs/module_merge.go | 11 ++-- internal/configs/module_merge_test.go | 23 ++------ internal/configs/provider_validation.go | 15 ++--- 5 files changed, 36 insertions(+), 140 deletions(-) diff --git a/internal/configs/module_call.go b/internal/configs/module_call.go index f1064b359c..e01b584cd0 100644 --- a/internal/configs/module_call.go +++ b/internal/configs/module_call.go @@ -7,26 +7,19 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" ) // ModuleCall represents a "module" block in a module or file. type ModuleCall struct { Name string - SourceAddr addrs.ModuleSource - SourceAddrRaw string - SourceAddrRange hcl.Range - SourceSet bool + SourceExpr hcl.Expression Config hcl.Body - Version VersionConstraint + VersionExpr hcl.Expression Count hcl.Expression ForEach hcl.Expression @@ -66,75 +59,12 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno }) } - haveVersionArg := false if attr, exists := content.Attributes["version"]; exists { - var versionDiags hcl.Diagnostics - mc.Version, versionDiags = decodeVersionConstraint(attr) - diags = append(diags, versionDiags...) - haveVersionArg = true + mc.VersionExpr = attr.Expr } if attr, exists := content.Attributes["source"]; exists { - mc.SourceSet = true - mc.SourceAddrRange = attr.Expr.Range() - valDiags := gohcl.DecodeExpression(attr.Expr, nil, &mc.SourceAddrRaw) - diags = append(diags, valDiags...) - if !valDiags.HasErrors() { - var addr addrs.ModuleSource - var err error - if haveVersionArg { - addr, err = moduleaddrs.ParseModuleSourceRegistry(mc.SourceAddrRaw) - } else { - addr, err = moduleaddrs.ParseModuleSource(mc.SourceAddrRaw) - } - mc.SourceAddr = addr - if err != nil { - // NOTE: We leave mc.SourceAddr as nil for any situation where the - // source attribute is invalid, so any code which tries to carefully - // use the partial result of a failed config decode must be - // resilient to that. - mc.SourceAddr = nil - - // NOTE: In practice it's actually very unlikely to end up here, - // because our source address parser can turn just about any string - // into some sort of remote package address, and so for most errors - // we'll detect them only during module installation. There are - // still a _few_ purely-syntax errors we can catch at parsing time, - // though, mostly related to remote package sub-paths and local - // paths. - switch err := err.(type) { - case *moduleaddrs.MaybeRelativePathErr: - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid module source address", - Detail: fmt.Sprintf( - "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", - err.Addr, err.Addr, - ), - Subject: mc.SourceAddrRange.Ptr(), - }) - default: - if haveVersionArg { - // In this case we'll include some extra context that - // we assumed a registry source address due to the - // version argument. - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid registry module source address", - Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), - Subject: mc.SourceAddrRange.Ptr(), - }) - } else { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid module source address", - Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), - Subject: mc.SourceAddrRange.Ptr(), - }) - } - } - } - } + mc.SourceExpr = attr.Expr } if attr, exists := content.Attributes["count"]; exists { diff --git a/internal/configs/module_call_test.go b/internal/configs/module_call_test.go index 4e94d00c44..4c865c3622 100644 --- a/internal/configs/module_call_test.go +++ b/internal/configs/module_call_test.go @@ -10,7 +10,7 @@ import ( "github.com/go-test/deep" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/hcl/v2/hclsyntax" ) func TestLoadModuleCall(t *testing.T) { @@ -31,15 +31,11 @@ func TestLoadModuleCall(t *testing.T) { gotModules := file.ModuleCalls wantModules := []*ModuleCall{ { - Name: "foo", - SourceAddr: addrs.ModuleSourceLocal("./foo"), - SourceAddrRaw: "./foo", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 3, Column: 12, Byte: 27}, - End: hcl.Pos{Line: 3, Column: 19, Byte: 34}, - }, + Name: "foo", + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"./foo\""), "module-calls.tf", + hcl.Pos{Line: 3, Column: 12, Byte: 27}, + )), DeclRange: hcl.Range{ Filename: "module-calls.tf", Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, @@ -48,21 +44,10 @@ func TestLoadModuleCall(t *testing.T) { }, { Name: "bar", - SourceAddr: addrs.ModuleSourceRegistry{ - Package: addrs.ModuleRegistryPackage{ - Host: addrs.DefaultModuleRegistryHost, - Namespace: "hashicorp", - Name: "bar", - TargetSystem: "aws", - }, - }, - SourceAddrRaw: "hashicorp/bar/aws", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 8, Column: 12, Byte: 113}, - End: hcl.Pos{Line: 8, Column: 31, Byte: 132}, - }, + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"hashicorp/bar/aws\""), "module-calls.tf", + hcl.Pos{Line: 8, Column: 12, Byte: 113}, + )), DeclRange: hcl.Range{ Filename: "module-calls.tf", Start: hcl.Pos{Line: 7, Column: 1, Byte: 87}, @@ -71,16 +56,10 @@ func TestLoadModuleCall(t *testing.T) { }, { Name: "baz", - SourceAddr: addrs.ModuleSourceRemote{ - Package: addrs.ModulePackage("git::https://example.com/"), - }, - SourceAddrRaw: "git::https://example.com/", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 15, Column: 12, Byte: 193}, - End: hcl.Pos{Line: 15, Column: 39, Byte: 220}, - }, + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"git::https://example.com/\""), "module-calls.tf", + hcl.Pos{Line: 15, Column: 12, Byte: 193}, + )), DependsOn: []hcl.Traversal{ { hcl.TraverseRoot{ diff --git a/internal/configs/module_merge.go b/internal/configs/module_merge.go index ff27d502e3..d9bc2abcc7 100644 --- a/internal/configs/module_merge.go +++ b/internal/configs/module_merge.go @@ -178,11 +178,8 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics { func (mc *ModuleCall) merge(omc *ModuleCall) hcl.Diagnostics { var diags hcl.Diagnostics - if omc.SourceSet { - mc.SourceAddr = omc.SourceAddr - mc.SourceAddrRaw = omc.SourceAddrRaw - mc.SourceAddrRange = omc.SourceAddrRange - mc.SourceSet = omc.SourceSet + if omc.SourceExpr != nil { + mc.SourceExpr = omc.SourceExpr } if omc.Count != nil { @@ -193,8 +190,8 @@ func (mc *ModuleCall) merge(omc *ModuleCall) hcl.Diagnostics { mc.ForEach = omc.ForEach } - if len(omc.Version.Required) != 0 { - mc.Version = omc.Version + if omc.VersionExpr != nil { + mc.VersionExpr = omc.VersionExpr } mc.Config = MergeBodies(mc.Config, omc.Config) diff --git a/internal/configs/module_merge_test.go b/internal/configs/module_merge_test.go index 78b2825e21..dabed28a8f 100644 --- a/internal/configs/module_merge_test.go +++ b/internal/configs/module_merge_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" @@ -91,23 +92,11 @@ func TestModuleOverrideModule(t *testing.T) { got := mod.ModuleCalls["example"] want := &ModuleCall{ - Name: "example", - SourceAddr: addrs.ModuleSourceLocal("./example2-a_override"), - SourceAddrRaw: "./example2-a_override", - SourceAddrRange: hcl.Range{ - Filename: "testdata/valid-modules/override-module/a_override.tf", - Start: hcl.Pos{ - Line: 3, - Column: 12, - Byte: 31, - }, - End: hcl.Pos{ - Line: 3, - Column: 35, - Byte: 54, - }, - }, - SourceSet: true, + Name: "example", + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"./example2-a_override\""), "testdata/valid-modules/override-module/a_override.tf", + hcl.Pos{Line: 3, Column: 12, Byte: 31}, + )), DeclRange: hcl.Range{ Filename: "testdata/valid-modules/override-module/primary.tf", Start: hcl.Pos{ diff --git a/internal/configs/provider_validation.go b/internal/configs/provider_validation.go index 78adb8cc9a..009f33ed4c 100644 --- a/internal/configs/provider_validation.go +++ b/internal/configs/provider_validation.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" ) @@ -253,14 +254,14 @@ func validateProviderConfigsForTests(cfg *Config) (diags hcl.Diagnostics) { // Let's make a little fake module call that we can use to call // into validateProviderConfigs. + sourceExpr := hcl.StaticExpr(cty.StringVal(run.Module.Source.String()), run.Module.SourceDeclRange) + versionExpr := hcl.StaticExpr(cty.StringVal(run.Module.Version.Required.String()), run.Module.Version.DeclRange) mc := &ModuleCall{ - Name: run.Name, - SourceAddr: run.Module.Source, - SourceAddrRange: run.Module.SourceDeclRange, - SourceSet: true, - Version: run.Module.Version, - Providers: providers, - DeclRange: run.Module.DeclRange, + Name: run.Name, + SourceExpr: sourceExpr, + VersionExpr: versionExpr, + Providers: providers, + DeclRange: run.Module.DeclRange, } diags = append(diags, validateProviderConfigs(mc, run.ConfigUnderTest, nil)...) From fb60c1567085ffed9ec4d7069d8ac10eb5cce5a0 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 16:38:40 +0100 Subject: [PATCH 008/136] Update init to use new graph-based workflow The init command and the module installer are now using the new graph-based workflow to install modules instead of using the recursive BuildConfig. --- internal/command/init.go | 17 ++++++++-- internal/command/meta.go | 4 ++- internal/command/meta_config.go | 16 ++++++++- internal/initwd/from_module.go | 43 +++++++++++++----------- internal/initwd/from_module_test.go | 6 ++-- internal/initwd/module_install.go | 45 +++++++++++++++++-------- internal/initwd/module_install_test.go | 46 +++++++++++++------------- internal/initwd/testing.go | 4 +-- 8 files changed, 117 insertions(+), 64 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index f8a56dc403..d5796e92e8 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -49,11 +49,24 @@ func (c *InitCommand) Run(args []string) int { var diags tfdiags.Diagnostics args = c.Meta.process(args) initArgs, initDiags := arguments.ParseInit(args, c.Meta.AllowExperimentalFeatures) + diags = diags.Append(initDiags) view := views.NewInit(initArgs.ViewType, c.View) - if initDiags.HasErrors() { - diags = diags.Append(initDiags) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = initArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + + if diags.HasErrors() { view.Diagnostics(diags) return 1 } diff --git a/internal/command/meta.go b/internal/command/meta.go index 6bba97540c..cebd76f7f8 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -186,6 +186,8 @@ type Meta struct { // flag is set, to reinforce that experiments are not for production use. AllowExperimentalFeatures bool + VariableValues map[string]arguments.UnparsedVariableValue + //---------------------------------------------------------- // Protected: commands can set these //---------------------------------------------------------- @@ -835,7 +837,7 @@ func (m *Meta) checkRequiredVersion() tfdiags.Diagnostics { return diags } - config, configDiags := loader.LoadConfig(pwd) + config, configDiags := m.loadConfig(pwd) if configDiags.HasErrors() { diags = diags.Append(configDiags) return diags diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 6c6e80dbb2..c99898d74f 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -201,7 +201,21 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg return true, diags } - inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient()) + initializer := func(rootMod *configs.Module, walker configs.ModuleWalker) (*configs.Config, tfdiags.Diagnostics) { + variables, diags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + ctx, ctxDiags := terraform.NewContext(&terraform.ContextOpts{ + Parallelism: 1, + }) + diags = diags.Append(ctxDiags) + if diags.HasErrors() { + return nil, diags + } + return ctx.Init(rootMod, terraform.InitOpts{ + Walker: walker, + SetVariables: variables, + }) + } + inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer) _, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly, hooks) diags = diags.Append(moreDiags) diff --git a/internal/initwd/from_module.go b/internal/initwd/from_module.go index aed383d035..aeb6799664 100644 --- a/internal/initwd/from_module.go +++ b/internal/initwd/from_module.go @@ -6,7 +6,6 @@ package initwd import ( "context" "fmt" - "io/ioutil" "log" "os" "path/filepath" @@ -19,6 +18,7 @@ import ( "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/getmodules" "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/zclconf/go-cty/cty" version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/modsdir" @@ -59,7 +59,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu // The target directory must exist but be empty. { - entries, err := ioutil.ReadDir(rootDir) + entries, err := os.ReadDir(rootDir) if err != nil { if os.IsNotExist(err) { diags = diags.Append(tfdiags.Sourceless( @@ -94,7 +94,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu } instDir := filepath.Join(rootDir, ".terraform/init-from-module") - inst := NewModuleInstaller(instDir, loader, reg) + inst := NewModuleInstaller(instDir, loader, reg, nil) log.Printf("[DEBUG] installing modules in %s to initialize working directory from %q", instDir, sourceAddrStr) os.RemoveAll(instDir) // if this fails then we'll fail on MkdirAll below too err := os.MkdirAll(instDir, os.ModePerm) @@ -129,24 +129,17 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu // Now we need to create an artificial root module that will seed our // installation process. - sourceAddr, err := moduleaddrs.ParseModuleSource(sourceAddrStr) - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid module source address", - fmt.Sprintf("Failed to parse module source address: %s", err), - )) + fakeRange := hcl.Range{ + Filename: initFromModuleRootFilename, + Start: hcl.InitialPos, + End: hcl.InitialPos, } fakeRootModule := &configs.Module{ ModuleCalls: map[string]*configs.ModuleCall{ initFromModuleRootCallName: { Name: initFromModuleRootCallName, - SourceAddr: sourceAddr, - DeclRange: hcl.Range{ - Filename: initFromModuleRootFilename, - Start: hcl.InitialPos, - End: hcl.InitialPos, - }, + SourceExpr: hcl.StaticExpr(cty.StringVal(sourceAddrStr), fakeRange), + DeclRange: fakeRange, }, }, ProviderRequirements: &configs.RequiredProviders{}, @@ -167,11 +160,20 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu fetcher := getmodules.NewPackageFetcher() walker := inst.moduleInstallWalker(ctx, instManifest, true, wrapHooks, fetcher) - _, cDiags := inst.installDescendantModules(fakeRootModule, instManifest, walker, true) + _, cDiags := inst.installDescendantModules(fakeRootModule, walker, true) if cDiags.HasErrors() { return diags.Append(cDiags) } + err = instManifest.WriteSnapshotToDir(instDir) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to update module manifest", + fmt.Sprintf("Unable to write the module manifest file: %s", err), + )) + } + // If all of that succeeded then we'll now migrate what was installed // into the final directory structure. err = os.MkdirAll(modulesDir, os.ModePerm) @@ -215,9 +217,12 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu mod, _ := loader.Parser().LoadConfigDir(rootDir) // ignore diagnostics since we're just doing value-add here anyway if mod != nil { for _, mc := range mod.ModuleCalls { - if pathTraversesUp(mc.SourceAddrRaw) { + // TODO improve this + sourceVal, _ := mc.SourceExpr.Value(nil) + sourceRaw := sourceVal.AsString() + if pathTraversesUp(sourceRaw) { packageAddr, givenSubdir := moduleaddrs.SplitPackageSubdir(sourceAddrStr) - newSubdir := filepath.Join(givenSubdir, mc.SourceAddrRaw) + newSubdir := filepath.Join(givenSubdir, sourceRaw) if pathTraversesUp(newSubdir) { // This should never happen in any reasonable // configuration since this suggests a path that diff --git a/internal/initwd/from_module_test.go b/internal/initwd/from_module_test.go index 1b7bd9ce9b..9f57be4de7 100644 --- a/internal/initwd/from_module_test.go +++ b/internal/initwd/from_module_test.go @@ -107,7 +107,7 @@ func TestDirFromModule_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -187,7 +187,7 @@ func TestDirFromModule_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -312,7 +312,7 @@ func TestDirFromModule_rel_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ diff --git a/internal/initwd/module_install.go b/internal/initwd/module_install.go index a3547e915d..1a4992d42c 100644 --- a/internal/initwd/module_install.go +++ b/internal/initwd/module_install.go @@ -30,6 +30,8 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +type Initializer func(rootMod *configs.Module, walker configs.ModuleWalker) (*configs.Config, tfdiags.Diagnostics) + type ModuleInstaller struct { modsDir string loader *configload.Loader @@ -42,6 +44,8 @@ type ModuleInstaller struct { // The keys in moduleVersionsUrl are the moduleVersion struct below and // addresses and the values are underlying remote source addresses. registryPackageSources map[moduleVersion]addrs.ModuleSourceRemote + + initializer Initializer } type moduleVersion struct { @@ -49,13 +53,14 @@ type moduleVersion struct { version string } -func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client) *ModuleInstaller { +func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client, initializer Initializer) *ModuleInstaller { return &ModuleInstaller{ modsDir: modsDir, loader: loader, reg: reg, registryPackageVersions: make(map[addrs.ModuleRegistryPackage]*response.ModuleVersions), registryPackageSources: make(map[moduleVersion]addrs.ModuleSourceRemote), + initializer: initializer, } } @@ -137,8 +142,31 @@ func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir } walker := i.moduleInstallWalker(ctx, manifest, upgrade, hooks, fetcher) - cfg, instDiags := i.installDescendantModules(rootMod, manifest, walker, installErrsOnly) - diags = append(diags, instDiags...) + var cfg *configs.Config + var instDiags tfdiags.Diagnostics + if i.initializer != nil { + cfg, instDiags = i.initializer(rootMod, walker) + diags = diags.Append(instDiags) + } else { + cfg, instDiags = i.installDescendantModules(rootMod, walker, installErrsOnly) + diags = diags.Append(instDiags) + } + + finalDiags := configs.FinalizeConfig(cfg, walker, configs.MockDataLoaderFunc(i.loader.LoadExternalMockData)) + diags = diags.Append(finalDiags) + + if diags.HasErrors() { + return nil, diags + } + + err = manifest.WriteSnapshotToDir(i.modsDir) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to update module manifest", + fmt.Sprintf("Unable to write the module manifest file: %s", err), + )) + } return cfg, diags } @@ -292,7 +320,7 @@ func (i *ModuleInstaller) moduleInstallWalker(ctx context.Context, manifest mods ) } -func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, manifest modsdir.Manifest, installWalker configs.ModuleWalker, installErrsOnly bool) (*configs.Config, tfdiags.Diagnostics) { +func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, installWalker configs.ModuleWalker, installErrsOnly bool) (*configs.Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics // When attempting to initialize the current directory with a module @@ -332,15 +360,6 @@ func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, mani diags = tfdiags.OverrideAll(diags, tfdiags.Warning, nil) } - err := manifest.WriteSnapshotToDir(i.modsDir) - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to update module manifest", - fmt.Sprintf("Unable to write the module manifest file: %s", err), - )) - } - return cfg, diags } diff --git a/internal/initwd/module_install_test.go b/internal/initwd/module_install_test.go index 89573ac572..75d66861c1 100644 --- a/internal/initwd/module_install_test.go +++ b/internal/initwd/module_install_test.go @@ -45,7 +45,7 @@ func TestModuleInstaller(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -77,7 +77,7 @@ func TestModuleInstaller(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -109,7 +109,7 @@ func TestModuleInstaller_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -130,7 +130,7 @@ func TestModuleInstaller_emptyModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -151,7 +151,7 @@ func TestModuleInstaller_invalidModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) if !diags.HasErrors() { @@ -189,7 +189,7 @@ func TestModuleInstaller_packageEscapeError(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -227,7 +227,7 @@ func TestModuleInstaller_explicitPackageBoundary(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -250,7 +250,7 @@ func TestModuleInstaller_ExactMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -277,7 +277,7 @@ func TestModuleInstaller_PartialMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -300,7 +300,7 @@ func TestModuleInstaller_invalid_version_constraint_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -326,7 +326,7 @@ func TestModuleInstaller_invalidVersionConstraintGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -352,7 +352,7 @@ func TestModuleInstaller_invalidVersionConstraintLocal(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -378,7 +378,7 @@ func TestModuleInstaller_symlink(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -410,7 +410,7 @@ func TestModuleInstaller_symlink(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -454,7 +454,7 @@ func TestLoaderInstallModules_invalidRegistry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) if !diags.HasErrors() { @@ -493,7 +493,7 @@ func TestLoaderInstallModules_registry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -608,7 +608,7 @@ func TestLoaderInstallModules_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -656,7 +656,7 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -738,7 +738,7 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + config, loadDiags := loader.LoadStaticConfig(".") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -774,7 +774,7 @@ func TestModuleInstaller_fromTests(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -800,7 +800,7 @@ func TestModuleInstaller_fromTests(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfigWithTests(".", "tests") + config, loadDiags := loader.LoadStaticConfigWithTests(".", "tests") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["tests/main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { @@ -831,7 +831,7 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -909,7 +909,7 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfigWithTests(".", "tests") + config, loadDiags := loader.LoadStaticConfigWithTests(".", "tests") tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { diff --git a/internal/initwd/testing.go b/internal/initwd/testing.go index d2c9f7f5c6..6566ce4e67 100644 --- a/internal/initwd/testing.go +++ b/internal/initwd/testing.go @@ -37,7 +37,7 @@ func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs var diags tfdiags.Diagnostics loader, cleanup := configload.NewLoaderForTests(t) - inst := NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, ModuleInstallHooksImpl{}) diags = diags.Append(moreDiags) @@ -53,7 +53,7 @@ func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs t.Fatalf("failed to refresh modules after installation: %s", err) } - config, hclDiags := loader.LoadConfigWithTests(rootDir, testsDir) + config, hclDiags := loader.LoadStaticConfigWithTests(rootDir, testsDir) diags = diags.Append(hclDiags) return config, loader, cleanup, diags } From 4ef9684188a79c04405dea52efb8f9be003bcaf0 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:33:01 +0100 Subject: [PATCH 009/136] Rework most of the configuration loading We previously used a loader -> BuildConfig flow to load configuration. This commit changes most (but not all yet) flows to use the new graph-based approach. Instead of simply recursively loading the modules, we now need to take a stepped approach: 1. Load the root module 2. Collect the variables and their values 3. Build the configuration with the graph-based approach Because this approach relies on different parts from different packages, it can't easliy be done within the `configload` package. So, now we do most of in the backend or command. --- internal/backend/backendrun/operation.go | 10 -- internal/backend/backendrun/unparsed_value.go | 57 +++++++ internal/backend/local/backend_local.go | 149 +++++++++++------- internal/backend/remote/backend_common.go | 7 +- internal/backend/remote/backend_context.go | 25 ++- internal/checks/state_test.go | 4 +- internal/cloud/backend_common.go | 4 +- internal/cloud/backend_context.go | 27 +++- internal/cloud/test_test.go | 4 +- internal/command/apply.go | 4 +- internal/command/command_test.go | 25 ++- internal/command/graph_test.go | 2 +- internal/command/init2_test.go | 101 ++++++++++++ internal/command/meta.go | 6 - internal/command/meta_config.go | 45 +++++- internal/command/test_test.go | 4 +- .../.terraform/modules/child/empty.tf | 0 .../.terraform/modules/modules.json | 4 +- .../add-version-constraint.tf | 0 .../main.tf} | 2 +- .../local-source-with-version/main.tf} | 2 +- internal/command/validate_test.go | 10 +- internal/configs/config_build.go | 144 ++++++++++++++++- internal/configs/configload/loader.go | 5 + internal/configs/configload/loader_load.go | 14 +- .../configs/configload/loader_load_test.go | 32 +--- .../configs/configload/loader_snapshot.go | 20 +-- .../configload/loader_snapshot_test.go | 145 ----------------- internal/configs/import_test.go | 14 +- .../invalid-files/version-variable.tf | 6 - internal/lang/globalref/analyzer_test.go | 4 +- .../moduletest/graph/eval_context_test.go | 4 +- internal/providercache/installer.go | 9 +- internal/refactoring/move_validate_test.go | 8 +- internal/terraform/terraform_test.go | 84 +++++++++- 35 files changed, 641 insertions(+), 340 deletions(-) create mode 100644 internal/command/init2_test.go rename internal/{configs/configload/testdata => command/testdata/dynamic-module-sources}/add-version-constraint/.terraform/modules/child/empty.tf (100%) rename internal/{configs/configload/testdata => command/testdata/dynamic-module-sources}/add-version-constraint/.terraform/modules/modules.json (61%) rename internal/{configs/configload/testdata => command/testdata/dynamic-module-sources}/add-version-constraint/add-version-constraint.tf (100%) rename internal/{configs/testdata/error-files/module-local-source-with-version.tf => command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf} (57%) rename internal/{configs/testdata/error-files/module-invalid-registry-source-with-module.tf => command/testdata/dynamic-module-sources/local-source-with-version/main.tf} (50%) delete mode 100644 internal/configs/configload/loader_snapshot_test.go delete mode 100644 internal/configs/testdata/invalid-files/version-variable.tf diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 071f7c7583..4168f7dbc1 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -14,7 +14,6 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/plans" @@ -175,15 +174,6 @@ func (o *Operation) HasConfig() bool { return o.ConfigLoader.IsConfigDir(o.ConfigDir) } -// Config loads the configuration that the operation applies to, using the -// ConfigDir and ConfigLoader fields within the receiving operation. -func (o *Operation) Config() (*configs.Config, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - config, hclDiags := o.ConfigLoader.LoadConfig(o.ConfigDir) - diags = diags.Append(hclDiags) - return config, diags -} - // ReportResult is a helper for the common chore of setting the status of // a running operation and showing any diagnostics produced during that // operation. diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index 95d8b3f8d6..bf83dc933e 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -200,3 +200,60 @@ func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls ma return ret, diags } + +func ParseConstVariableValues(vv map[string]arguments.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 unset placeholders which Terraform Core + // can then react to. + for name, vc := range decls { + if isDefinedAny(name, ret, undeclared) { + continue + } + + // This check is redundant with a check made in Terraform Core when + // processing undeclared variables, but allows us to generate a more + // specific error message which mentions -var and -var-file command + // line options, whereas the one in Terraform Core is more general + // due to supporting both root and child module variables. + if vc.Const && vc.Required() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No value for required variable", + Detail: fmt.Sprintf("The root module input variable %q is not set, and has no default value. Use a -var or -var-file command line argument to provide a value for this variable.", name), + Subject: vc.DeclRange.Ptr(), + }) + } + + if vc.Required() { + // We'll include a placeholder value anyway, just so that our + // result is complete for any calling code that wants to cautiously + // analyze it for diagnostic purposes. Since our diagnostics now + // includes an error, normal processing will ignore this result. + ret[name] = &terraform.InputValue{ + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange), + } + } else { + // We're still required to put an entry for this variable + // in the mapping to be explicit to Terraform Core that we + // visited it, but its value will be cty.NilVal to represent + // that it wasn't set at all at this layer, and so Terraform Core + // should substitute a default if available, or generate an error + // if not. + ret[name] = &terraform.InputValue{ + Value: cty.NilVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange), + } + } + } + + return ret, diags +} diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index a179352aed..768b94355a 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -146,39 +146,11 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRun, coreOpts *terraform.ContextOpts, s statemgr.Full) (*backendrun.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - // Load the configuration using the caller-provided configuration loader. - config, configSnap, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - run.Config = config - - if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { - var buf strings.Builder - for _, err := range errs { - fmt.Fprintf(&buf, "\n - %s", err.Error()) - } - var suggestion string - switch { - case op.DependencyLocks == nil: - // If we get here then it suggests that there's a caller that we - // didn't yet update to populate DependencyLocks, which is a bug. - suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!" - case op.DependencyLocks.Empty(): - suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n terraform init" - default: - suggestion = "To update the locked dependency selections to match a changed configuration, run:\n terraform init -upgrade" - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Inconsistent dependency lock file", - fmt.Sprintf( - "The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s", - buf.String(), suggestion, - ), - )) - } var rawVariables map[string]arguments.UnparsedVariableValue if op.AllowUnsetVariables { @@ -186,16 +158,16 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu // 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) + rawVariables = b.stubUnsetRequiredVariables(op.Variables, rootMod.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, op.UIIn) + rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, rootMod.Variables, op.UIIn) } - variables, varDiags := backendrun.ParseVariableValues(rawVariables, config.Module.Variables) + variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -224,6 +196,52 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu return nil, nil, diags } run.Core = tfCtx + + walkerSnapshot, configSnap := op.ConfigLoader.ModuleWalkerSnapshot() + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + walkerSnapshot, + variables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, nil, diags + } + run.Config = config + + snapDiags := op.ConfigLoader.AddRootModuleToSnapshot(configSnap, op.ConfigDir) + diags = diags.Append(snapDiags) + if snapDiags.HasErrors() { + return nil, nil, diags + } + + if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { + var buf strings.Builder + for _, err := range errs { + fmt.Fprintf(&buf, "\n - %s", err.Error()) + } + var suggestion string + switch { + case op.DependencyLocks == nil: + // If we get here then it suggests that there's a caller that we + // didn't yet update to populate DependencyLocks, which is a bug. + suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!" + case op.DependencyLocks.Empty(): + suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n terraform init" + default: + suggestion = "To update the locked dependency selections to match a changed configuration, run:\n terraform init -upgrade" + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Inconsistent dependency lock file", + fmt.Sprintf( + "The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s", + buf.String(), suggestion, + ), + )) + } + return run, configSnap, diags } @@ -235,6 +253,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade // A plan file has a snapshot of configuration embedded inside it, which // is used instead of whatever configuration might be already present // in the filesystem. + //TODO why not use pf.ReadConfig? snap, err := pf.ReadConfigSnapshot() if err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -246,32 +265,16 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade } loader := configload.NewLoaderFromSnapshot(snap) loader.AllowLanguageExperiments(op.ConfigLoader.AllowsLanguageExperiments()) - config, configDiags := loader.LoadConfig(snap.Modules[""].Dir) - diags = diags.Append(configDiags) - if configDiags.HasErrors() { - return nil, snap, diags + rootMod, rootDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags = diags.Append(rootDiags) + if rootDiags.HasErrors() { + return nil, nil, diags } - run.Config = config - // NOTE: We're intentionally comparing the current locks with the - // configuration snapshot, rather than the lock snapshot in the plan file, - // because it's the current locks which dictate our plugin selections - // in coreOpts below. However, we'll also separately check that the - // plan file has identical locked plugins below, and thus we're effectively - // checking consistency with both here. - if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { - var buf strings.Builder - for _, err := range errs { - fmt.Fprintf(&buf, "\n - %s", err.Error()) - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Inconsistent dependency lock file", - fmt.Sprintf( - "The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.", - buf.String(), - ), - )) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags } // This check is an important complement to the check above: the locked @@ -359,6 +362,40 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade return nil, nil, diags } run.Core = tfCtx + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + variables, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, nil, diags + } + run.Config = config + + // NOTE: We're intentionally comparing the current locks with the + // configuration snapshot, rather than the lock snapshot in the plan file, + // because it's the current locks which dictate our plugin selections + // in coreOpts below. However, we'll also separately check that the + // plan file has identical locked plugins below, and thus we're effectively + // checking consistency with both here. + if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { + var buf strings.Builder + for _, err := range errs { + fmt.Fprintf(&buf, "\n - %s", err.Error()) + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Inconsistent dependency lock file", + fmt.Sprintf( + "The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.", + buf.String(), + ), + )) + } + return run, snap, diags } diff --git a/internal/backend/remote/backend_common.go b/internal/backend/remote/backend_common.go index 85d42ae879..137c01aa41 100644 --- a/internal/backend/remote/backend_common.go +++ b/internal/backend/remote/backend_common.go @@ -249,11 +249,8 @@ func (b *Remote) waitForRun(stopCtx, cancelCtx context.Context, op *backendrun.O // remote system's responsibility to do final validation of the input. func (b *Remote) hasExplicitVariableValues(op *backendrun.Operation) bool { // Load the configuration using the caller-provided configuration loader. - config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + config, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) if configDiags.HasErrors() { - // If we can't load the configuration then we'll assume no explicit - // variable values just to let the remote operation start and let - // the remote system return the same set of configuration errors. return false } @@ -262,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.Module.Variables) + 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 bf7ec7190d..db72d709ed 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -81,19 +81,18 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state ret.InputState = stateMgr.State() log.Printf("[TRACE] backend/remote: loading configuration for the current working directory") - config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - ret.Config = config 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, config.Module.Variables) + 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 @@ -136,7 +135,7 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state } if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, config.Module.Variables) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -148,6 +147,24 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) ret.Core = tfCtx + if diags.HasErrors() { + return nil, nil, diags + } + + log.Printf("[TRACE] backend/remote: building configuration for the current working directory") + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + op.ConfigLoader.ModuleWalker(), + ret.PlanOpts.SetVariables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if diags.HasErrors() { + return nil, nil, diags + } + + ret.Config = config log.Printf("[TRACE] backend/remote: finished building terraform.Context") diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go index 2e5a30a30d..276f6dc62c 100644 --- a/internal/checks/state_test.go +++ b/internal/checks/state_test.go @@ -18,7 +18,7 @@ func TestChecksHappyPath(t *testing.T) { const fixtureDir = "testdata/happypath" loader, close := configload.NewLoaderForTests(t) defer close() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil, nil) _, instDiags := inst.InstallModules(context.Background(), fixtureDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -29,7 +29,7 @@ func TestChecksHappyPath(t *testing.T) { ///////////////////////////////////////////////////////////////////////// - cfg, hclDiags := loader.LoadConfig(fixtureDir) + cfg, hclDiags := loader.LoadStaticConfig(fixtureDir) if hclDiags.HasErrors() { t.Fatalf("invalid configuration: %s", hclDiags.Error()) } diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go index 360da2a08b..b4f98ef2d0 100644 --- a/internal/cloud/backend_common.go +++ b/internal/cloud/backend_common.go @@ -654,12 +654,12 @@ in order to capture the filesystem context the remote workspace expects: } func (b *Cloud) parseRunVariables(op *backendrun.Operation) ([]*tfe.RunVariable, error) { - config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + config, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) if configDiags.HasErrors() { return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) } - variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) + variables, varDiags := ParseCloudRunVariables(op.Variables, config.Variables) if varDiags.HasErrors() { return nil, varDiags.Err() diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 36f9ab1e3a..e3fc587a33 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -79,20 +79,19 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem log.Printf("[TRACE] cloud: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) ret.InputState = stateMgr.State() - log.Printf("[TRACE] cloud: loading configuration for the current working directory") - config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + log.Printf("[TRACE] cloud: loading root module for the current working directory") + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - ret.Config = config 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, config.Module.Variables) + 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 @@ -136,7 +135,7 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem } if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, config.Module.Variables) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -148,6 +147,24 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) ret.Core = tfCtx + if diags.HasErrors() { + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: building configuration for the current working directory") + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + op.ConfigLoader.ModuleWalker(), + ret.PlanOpts.SetVariables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if diags.HasErrors() { + return nil, nil, diags + } + + ret.Config = config log.Printf("[TRACE] cloud: finished building terraform.Context") diff --git a/internal/cloud/test_test.go b/internal/cloud/test_test.go index d3a97372bc..ca1971ff7e 100644 --- a/internal/cloud/test_test.go +++ b/internal/cloud/test_test.go @@ -267,7 +267,7 @@ func TestTest_Verbose(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadConfigWithTests(directory, "tests") + config, configDiags := loader.LoadStaticConfigWithTests(directory, "tests") if configDiags.HasErrors() { t.Fatalf("failed to load config: %v", configDiags.Error()) } @@ -664,7 +664,7 @@ func TestTest_ForceCancel(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadConfigWithTests("testdata/test-force-cancel", "tests") + config, configDiags := loader.LoadStaticConfigWithTests("testdata/test-force-cancel", "tests") if configDiags.HasErrors() { t.Fatalf("failed to load config: %v", configDiags.Error()) } diff --git a/internal/command/apply.go b/internal/command/apply.go index 1fa43cf950..29cfb7698e 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -359,7 +359,7 @@ Options: Defaults to 10. -replace=resource Terraform will plan to replace this resource instance - instead of doing an update or no-op action. + instead of doing an update or no-op action. -state=path Path to read and save state (unless state-out is specified). Defaults to "terraform.tfstate". @@ -372,7 +372,7 @@ Options: Legacy option for the local backend only. See the local backend's 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. diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 6be429ba62..8eecaf1ef2 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -50,6 +50,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/version" ) @@ -158,15 +159,31 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } - config, snap, diags := loader.LoadConfigWithSnapshot(dir) - if diags.HasErrors() { - t.Fatal(diags.Error()) + rootMod, configDiags := loader.LoadRootModule(dir) + if configDiags.HasErrors() { + t.Fatal(configDiags.Error()) + } + + walkerSnapshot, snap := loader.ModuleWalkerSnapshot() + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + walkerSnapshot, + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatal(buildDiags.Err()) + } + + snapDiags := loader.AddRootModuleToSnapshot(snap, dir) + if snapDiags.HasErrors() { + t.Fatal(snapDiags.Error()) } return config, snap diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index 97bdc78621..9490ed130c 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -224,7 +224,7 @@ func TestGraph_resourcesOnly(t *testing.T) { if err != nil { t.Fatal(err) } - inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) diff --git a/internal/command/init2_test.go b/internal/command/init2_test.go new file mode 100644 index 0000000000..9dcfcf50c4 --- /dev/null +++ b/internal/command/init2_test.go @@ -0,0 +1,101 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/cli" +) + +func TestInit2_versionConstraintAdded(t *testing.T) { + // This test is for what happens when there is a version constraint added + // to a module that previously didn't have one. + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "add-version-constraint")), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{"-get=false"} + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + got := testOutput.All() + + want := "Module version requirements have changed" + if !strings.Contains(got, want) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) + } +} + +func TestInit2_invalidRegistrySourceWithModule(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "invalid-registry-source-with-module")), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{} + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + got := testOutput.All() + + want := "Invalid registry module source address" + if !strings.Contains(got, want) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) + } +} + +func TestInit2_localSourceWithVersion(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "local-source-with-version")), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{} + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + got := testOutput.All() + + want := "Invalid registry module source address" + if !strings.Contains(got, want) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) + } +} diff --git a/internal/command/meta.go b/internal/command/meta.go index cebd76f7f8..bbdf4ed09f 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -825,12 +825,6 @@ func (m *Meta) applyStateArguments(args *arguments.State) { func (m *Meta) checkRequiredVersion() tfdiags.Diagnostics { var diags tfdiags.Diagnostics - loader, err := m.initConfigLoader() - if err != nil { - diags = diags.Append(err) - return diags - } - pwd, err := os.Getwd() if err != nil { diags = diags.Append(fmt.Errorf("Error getting pwd: %s", err)) diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index c99898d74f..15b56cc2ee 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -17,6 +17,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -48,8 +49,28 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) return nil, diags } - config, hclDiags := loader.LoadConfig(rootDir) + rootMod, hclDiags := loader.LoadRootModule(rootDir) diags = diags.Append(hclDiags) + if rootMod == nil || diags.HasErrors() { + cfg := &configs.Config{ + Module: rootMod, + } + cfg.Root = cfg // Root module is self-referential. + return cfg, diags + } + betterVars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return nil, diags + } + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + betterVars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + return config, diags } @@ -65,8 +86,28 @@ func (m *Meta) loadConfigWithTests(rootDir, testDir string) (*configs.Config, tf return nil, diags } - config, hclDiags := loader.LoadConfigWithTests(rootDir, testDir) + rootMod, hclDiags := loader.LoadRootModuleWithTests(rootDir, testDir) diags = diags.Append(hclDiags) + if rootMod == nil || diags.HasErrors() { + cfg := &configs.Config{ + Module: rootMod, + } + cfg.Root = cfg // Root module is self-referential. + return cfg, diags + } + betterVars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return nil, diags + } + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + betterVars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + return config, diags } diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 92adc85738..01d3f548de 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -5707,7 +5707,7 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -5719,7 +5719,7 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") if diags.HasErrors() { t.Fatal(diags.Error()) } diff --git a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/child/empty.tf b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/child/empty.tf similarity index 100% rename from internal/configs/configload/testdata/add-version-constraint/.terraform/modules/child/empty.tf rename to internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/child/empty.tf diff --git a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json similarity index 61% rename from internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json rename to internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json index c02f40016b..a55de19395 100644 --- a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json +++ b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json @@ -3,12 +3,12 @@ { "Key": "", "Source": "", - "Dir": "testdata/add-version-constraint" + "Dir": "" }, { "Key": "child", "Source": "hashicorp/module-installer-acctest/aws", - "Dir": "testdata/add-version-constraint/.terraform/modules/child" + "Dir": ".terraform/modules/child" } ] } diff --git a/internal/configs/configload/testdata/add-version-constraint/add-version-constraint.tf b/internal/command/testdata/dynamic-module-sources/add-version-constraint/add-version-constraint.tf similarity index 100% rename from internal/configs/configload/testdata/add-version-constraint/add-version-constraint.tf rename to internal/command/testdata/dynamic-module-sources/add-version-constraint/add-version-constraint.tf diff --git a/internal/configs/testdata/error-files/module-local-source-with-version.tf b/internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf similarity index 57% rename from internal/configs/testdata/error-files/module-local-source-with-version.tf rename to internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf index f570d65fe9..99036006e8 100644 --- a/internal/configs/testdata/error-files/module-local-source-with-version.tf +++ b/internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf @@ -1,5 +1,5 @@ module "test" { - source = "../boop" # ERROR: Invalid registry module source address + source = "---.com/HashiCorp/Consul/aws" version = "1.0.0" # Makes Terraform assume "source" is a module address } diff --git a/internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf similarity index 50% rename from internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf rename to internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf index 0029be8f4a..6ff0bcd606 100644 --- a/internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf @@ -1,5 +1,5 @@ module "test" { - source = "---.com/HashiCorp/Consul/aws" # ERROR: Invalid registry module source address + source = "../boop" version = "1.0.0" # Makes Terraform assume "source" is a module address } diff --git a/internal/command/validate_test.go b/internal/command/validate_test.go index 9270066ee9..3e4ef667e9 100644 --- a/internal/command/validate_test.go +++ b/internal/command/validate_test.go @@ -5,7 +5,7 @@ package command import ( "encoding/json" - "io/ioutil" + "io" "os" "path" "strings" @@ -190,10 +190,6 @@ func TestModuleWithIncorrectNameShouldFail(t *testing.T) { if !strings.Contains(output.Stderr(), wantError) { t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } - wantError = `Error: Variables not allowed` - if !strings.Contains(output.Stderr(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) - } } func TestWronglyUsedInterpolationShouldFail(t *testing.T) { @@ -406,14 +402,14 @@ func TestValidate_json(t *testing.T) { for _, tc := range tests { t.Run(tc.path, func(t *testing.T) { - var want, got map[string]interface{} + var want, got map[string]any wantFile, err := os.Open(path.Join(testFixturePath(tc.path), "output.json")) if err != nil { t.Fatalf("failed to open output file: %s", err) } defer wantFile.Close() - wantBytes, err := ioutil.ReadAll(wantFile) + wantBytes, err := io.ReadAll(wantFile) if err != nil { t.Fatalf("failed to read output file: %s", err) } diff --git a/internal/configs/config_build.go b/internal/configs/config_build.go index 28e522d226..84abf6df3b 100644 --- a/internal/configs/config_build.go +++ b/internal/configs/config_build.go @@ -12,8 +12,10 @@ import ( version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" ) // BuildConfig constructs a Config from a root module by loading all of its @@ -32,6 +34,20 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con } cfg.Root = cfg // Root module is self-referential. cfg.Children, diags = buildChildModules(cfg, walker) + diags = append(diags, FinalizeConfig(cfg, walker, loader)...) + + return cfg, diags +} + +// FinalizeConfig performs the post-load validation and setup steps that are +// shared by different configuration loaders. +// +// Callers must ensure cfg.Root is set correctly before calling this function. +func FinalizeConfig(cfg *Config, walker ModuleWalker, loader MockDataLoader) hcl.Diagnostics { + var diags hcl.Diagnostics + if cfg == nil { + return diags + } diags = append(diags, buildTestModules(cfg, walker)...) // Skip provider resolution if there are any errors, since the provider @@ -42,7 +58,7 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con providers := cfg.resolveProviderTypes() cfg.resolveProviderTypesForTests(providers) - if cfg.Module.StateStore != nil { + if cfg.Module != nil && cfg.Module.StateStore != nil { stateProviderDiags := cfg.resolveStateStoreProviderType() diags = append(diags, stateProviderDiags...) } @@ -54,7 +70,7 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con // Final step, let's side load any external mock data into our test files. diags = append(diags, installMockDataFiles(cfg, loader)...) - return cfg, diags + return diags } func installMockDataFiles(root *Config, loader MockDataLoader) hcl.Diagnostics { @@ -148,6 +164,108 @@ func buildTestModules(root *Config, walker ModuleWalker) hcl.Diagnostics { return diags } +// legacySourceHelper is used to decode module sources from the old-style +// string-only "source". It assumes that the expression does not contain any +// references and can be decoded without an evaluation context. +// In the long term, we want to get rid of this helper method. +func legacySourceHelper(expr hcl.Expression, haveVersionArg bool) (addrs.ModuleSource, hcl.Diagnostics) { + var diags hcl.Diagnostics + var sourceAddrRaw string + var addr addrs.ModuleSource + + valDiags := gohcl.DecodeExpression(expr, nil, &sourceAddrRaw) + diags = append(diags, valDiags...) + if !valDiags.HasErrors() { + var err error + if haveVersionArg { + addr, err = moduleaddrs.ParseModuleSourceRegistry(sourceAddrRaw) + } else { + addr, err = moduleaddrs.ParseModuleSource(sourceAddrRaw) + } + if err != nil { + // NOTE: We leave addr as nil for any situation where the + // source attribute is invalid, so any code which tries to carefully + // use the partial result of a failed config decode must be + // resilient to that. + addr = nil + + // NOTE: In practice it's actually very unlikely to end up here, + // because our source address parser can turn just about any string + // into some sort of remote package address, and so for most errors + // we'll detect them only during module installation. There are + // still a _few_ purely-syntax errors we can catch at parsing time, + // though, mostly related to remote package sub-paths and local + // paths. + switch err := err.(type) { + case *moduleaddrs.MaybeRelativePathErr: + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf( + "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", + err.Addr, err.Addr, + ), + Subject: expr.Range().Ptr(), + }) + default: + if haveVersionArg { + // In this case we'll include some extra context that + // we assumed a registry source address due to the + // version argument. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid registry module source address", + Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), + Subject: expr.Range().Ptr(), + }) + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), + Subject: expr.Range().Ptr(), + }) + } + } + } + } + + return addr, diags +} + +// legacyVersionHelper is used to decode version constraints from the old-style +// string-only "version". It assumes that the expression does not contain any +// references and can be decoded without an evaluation context. +// In the long term, we want to get rid of this helper method. +func legacyVersionHelper(expr hcl.Expression) (VersionConstraint, hcl.Diagnostics) { + var diags hcl.Diagnostics + var versionRaw string + + ret := VersionConstraint{ + DeclRange: expr.Range(), + } + + valDiags := gohcl.DecodeExpression(expr, nil, &versionRaw) + diags = append(diags, valDiags...) + if !valDiags.HasErrors() { + constraints, err := version.NewConstraint(versionRaw) + if err != nil { + // NewConstraint doesn't return user-friendly errors, so we'll just + // ignore the provided error and produce our own generic one. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + Detail: "This string does not use correct version constraint syntax.", // Not very actionable :( + Subject: expr.Range().Ptr(), + }) + return ret, diags + } + ret.Required = constraints + } + + return ret, diags +} + func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config, hcl.Diagnostics) { var diags hcl.Diagnostics ret := map[string]*Config{} @@ -161,12 +279,28 @@ func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config, path := slices.Clone(parent.Path) path = append(path, call.Name) + sourceAddr, sourceDiags := legacySourceHelper(call.SourceExpr, call.VersionExpr != nil) + diags = append(diags, sourceDiags...) + if sourceDiags.HasErrors() { + continue + } + + var versionConstraint VersionConstraint + if call.VersionExpr != nil { + var versionDiags hcl.Diagnostics + versionConstraint, versionDiags = legacyVersionHelper(call.VersionExpr) + diags = append(diags, versionDiags...) + if versionDiags.HasErrors() { + continue + } + } + req := ModuleRequest{ Name: call.Name, Path: path, - SourceAddr: call.SourceAddr, - SourceAddrRange: call.SourceAddrRange, - VersionConstraint: call.Version, + SourceAddr: sourceAddr, + SourceAddrRange: call.SourceExpr.Range(), + VersionConstraint: versionConstraint, Parent: parent, CallRange: call.DeclRange, } diff --git a/internal/configs/configload/loader.go b/internal/configs/configload/loader.go index 11be47b02f..06becad468 100644 --- a/internal/configs/configload/loader.go +++ b/internal/configs/configload/loader.go @@ -187,3 +187,8 @@ func (l *Loader) AllowLanguageExperiments(allowed bool) { func (l *Loader) AllowsLanguageExperiments() bool { return l.parser.AllowsLanguageExperiments() } + +// ModuleWalker returns a walker suitable for loading already-installed modules. +func (l *Loader) ModuleWalker() configs.ModuleWalker { + return configs.ModuleWalkerFunc(l.moduleWalkerLoad) +} diff --git a/internal/configs/configload/loader_load.go b/internal/configs/configload/loader_load.go index 236575dcd2..5fbe017915 100644 --- a/internal/configs/configload/loader_load.go +++ b/internal/configs/configload/loader_load.go @@ -22,16 +22,26 @@ import ( // // LoadConfig performs the basic syntax and uniqueness validations that are // required to process the individual modules -func (l *Loader) LoadConfig(rootDir string) (*configs.Config, hcl.Diagnostics) { +func (l *Loader) LoadStaticConfig(rootDir string) (*configs.Config, hcl.Diagnostics) { return l.loadConfig(l.parser.LoadConfigDir(rootDir, l.parserOpts...)) } // LoadConfigWithTests matches LoadConfig, except the configs.Config contains // any relevant .tftest.hcl files. -func (l *Loader) LoadConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) { +func (l *Loader) LoadStaticConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) { return l.loadConfig(l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...)) } +// LoadRootModule reads the root module using the loader's parser options. +func (l *Loader) LoadRootModule(rootDir string) (*configs.Module, hcl.Diagnostics) { + return l.parser.LoadConfigDir(rootDir, l.parserOpts...) +} + +// LoadRootModuleWithTests reads the root module and includes test files from the given directory. +func (l *Loader) LoadRootModuleWithTests(rootDir string, testDir string) (*configs.Module, hcl.Diagnostics) { + return l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...) +} + func (l *Loader) loadConfig(rootMod *configs.Module, diags hcl.Diagnostics) (*configs.Config, hcl.Diagnostics) { if rootMod == nil || diags.HasErrors() { // Ensure we return any parsed modules here so that required_version diff --git a/internal/configs/configload/loader_load_test.go b/internal/configs/configload/loader_load_test.go index 22fadb6249..b81814960e 100644 --- a/internal/configs/configload/loader_load_test.go +++ b/internal/configs/configload/loader_load_test.go @@ -25,7 +25,7 @@ func TestLoaderLoadConfig_okay(t *testing.T) { t.Fatalf("unexpected error from NewLoader: %s", err) } - cfg, diags := loader.LoadConfig(fixtureDir) + cfg, diags := loader.LoadStaticConfig(fixtureDir) assertNoDiagnostics(t, diags) if cfg == nil { t.Fatalf("config is nil; want non-nil") @@ -62,28 +62,6 @@ func TestLoaderLoadConfig_okay(t *testing.T) { }) } -func TestLoaderLoadConfig_addVersion(t *testing.T) { - // This test is for what happens when there is a version constraint added - // to a module that previously didn't have one. - fixtureDir := filepath.Clean("testdata/add-version-constraint") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, diags := loader.LoadConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("success; want error") - } - got := diags.Error() - want := "Module version requirements have changed" - if !strings.Contains(got, want) { - t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) - } -} - func TestLoaderLoadConfig_loadDiags(t *testing.T) { // building a config which didn't load correctly may cause configs to panic fixtureDir := filepath.Clean("testdata/invalid-names") @@ -94,7 +72,7 @@ func TestLoaderLoadConfig_loadDiags(t *testing.T) { t.Fatalf("unexpected error from NewLoader: %s", err) } - cfg, diags := loader.LoadConfig(fixtureDir) + cfg, diags := loader.LoadStaticConfig(fixtureDir) if !diags.HasErrors() { t.Fatal("success; want error") } @@ -118,7 +96,7 @@ func TestLoaderLoadConfig_loadDiagsFromSubmodules(t *testing.T) { t.Fatalf("unexpected error from NewLoader: %s", err) } - cfg, diags := loader.LoadConfig(fixtureDir) + cfg, diags := loader.LoadStaticConfig(fixtureDir) if !diags.HasErrors() { t.Fatalf("loading succeeded; want an error") } @@ -168,7 +146,7 @@ func TestLoaderLoadConfig_childProviderGrandchildCount(t *testing.T) { t.Fatalf("unexpected error from NewLoader: %s", err) } - cfg, diags := loader.LoadConfig(fixtureDir) + cfg, diags := loader.LoadStaticConfig(fixtureDir) assertNoDiagnostics(t, diags) if cfg == nil { t.Fatalf("config is nil; want non-nil") @@ -198,7 +176,7 @@ func TestLoaderLoadConfig_childProviderGrandchildCount(t *testing.T) { t.Fatalf("unexpected error from NewLoader: %s", err) } - _, diags := loader.LoadConfig(fixtureDir) + _, diags := loader.LoadStaticConfig(fixtureDir) if !diags.HasErrors() { t.Fatalf("loading succeeded; want an error") } diff --git a/internal/configs/configload/loader_snapshot.go b/internal/configs/configload/loader_snapshot.go index 5388e8bb1b..ba982527c1 100644 --- a/internal/configs/configload/loader_snapshot.go +++ b/internal/configs/configload/loader_snapshot.go @@ -20,26 +20,16 @@ import ( "github.com/hashicorp/terraform/internal/modsdir" ) -// LoadConfigWithSnapshot is a variant of LoadConfig that also simultaneously -// creates an in-memory snapshot of the configuration files used, which can -// be later used to create a loader that may read only from this snapshot. -func (l *Loader) LoadConfigWithSnapshot(rootDir string) (*configs.Config, *Snapshot, hcl.Diagnostics) { - rootMod, diags := l.parser.LoadConfigDir(rootDir, l.parserOpts...) - if rootMod == nil { - return nil, nil, diags - } - +func (l *Loader) ModuleWalkerSnapshot() (configs.ModuleWalker, *Snapshot) { snap := &Snapshot{ Modules: map[string]*SnapshotModule{}, } - walker := l.makeModuleWalkerSnapshot(snap) - cfg, cDiags := configs.BuildConfig(rootMod, walker, configs.MockDataLoaderFunc(l.LoadExternalMockData)) - diags = append(diags, cDiags...) - addDiags := l.addModuleToSnapshot(snap, "", rootDir, "", nil) - diags = append(diags, addDiags...) + return l.makeModuleWalkerSnapshot(snap), snap +} - return cfg, snap, diags +func (l *Loader) AddRootModuleToSnapshot(snap *Snapshot, rootDir string) hcl.Diagnostics { + return l.addModuleToSnapshot(snap, "", rootDir, "", nil) } // NewLoaderFromSnapshot creates a Loader that reads files only from the diff --git a/internal/configs/configload/loader_snapshot_test.go b/internal/configs/configload/loader_snapshot_test.go deleted file mode 100644 index 00411ecc18..0000000000 --- a/internal/configs/configload/loader_snapshot_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/davecgh/go-spew/spew" - "github.com/go-test/deep" -) - -func TestLoadConfigWithSnapshot(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, got, diags := loader.LoadConfigWithSnapshot(fixtureDir) - assertNoDiagnostics(t, diags) - if got == nil { - t.Fatalf("snapshot is nil; want non-nil") - } - - t.Log(spew.Sdump(got)) - - { - gotModuleDirs := map[string]string{} - for k, m := range got.Modules { - gotModuleDirs[k] = m.Dir - } - wantModuleDirs := map[string]string{ - "": "testdata/already-installed", - "child_a": "testdata/already-installed/.terraform/modules/child_a", - "child_a.child_c": "testdata/already-installed/.terraform/modules/child_a/child_c", - "child_b": "testdata/already-installed/.terraform/modules/child_b", - "child_b.child_d": "testdata/already-installed/.terraform/modules/child_b.child_d", - } - - problems := deep.Equal(wantModuleDirs, gotModuleDirs) - for _, problem := range problems { - t.Error(problem) - } - if len(problems) > 0 { - return - } - } - - gotRoot := got.Modules[""] - wantRoot := &SnapshotModule{ - Dir: "testdata/already-installed", - Files: map[string][]byte{ - "root.tf": []byte(` -module "child_a" { - source = "example.com/foo/bar_a/baz" - version = ">= 1.0.0" -} - -module "child_b" { - source = "example.com/foo/bar_b/baz" - version = ">= 1.0.0" -} -`), - }, - } - if !reflect.DeepEqual(gotRoot, wantRoot) { - t.Errorf("wrong root module snapshot\ngot: %swant: %s", spew.Sdump(gotRoot), spew.Sdump(wantRoot)) - } - -} - -func TestLoadConfigWithSnapshot_invalidSource(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed-now-invalid") - - old, _ := os.Getwd() - os.Chdir(fixtureDir) - defer os.Chdir(old) - - loader, err := NewLoader(&Config{ - ModulesDir: ".terraform/modules", - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, _, diags := loader.LoadConfigWithSnapshot(".") - if !diags.HasErrors() { - t.Error("LoadConfigWithSnapshot succeeded; want errors") - } -} - -func TestSnapshotRoundtrip(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, snap, diags := loader.LoadConfigWithSnapshot(fixtureDir) - assertNoDiagnostics(t, diags) - if snap == nil { - t.Fatalf("snapshot is nil; want non-nil") - } - - snapLoader := NewLoaderFromSnapshot(snap) - if loader == nil { - t.Fatalf("loader is nil; want non-nil") - } - - config, diags := snapLoader.LoadConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if config == nil { - t.Fatalf("config is nil; want non-nil") - } - if config.Module == nil { - t.Fatalf("config has no root module") - } - if got, want := config.Module.SourceDir, "testdata/already-installed"; got != want { - t.Errorf("wrong root module sourcedir %q; want %q", got, want) - } - if got, want := len(config.Module.ModuleCalls), 2; got != want { - t.Errorf("wrong number of module calls in root module %d; want %d", got, want) - } - childA := config.Children["child_a"] - if childA == nil { - t.Fatalf("child_a config is nil; want non-nil") - } - if childA.Module == nil { - t.Fatalf("child_a config has no module") - } - if got, want := childA.Module.SourceDir, "testdata/already-installed/.terraform/modules/child_a"; got != want { - t.Errorf("wrong child_a sourcedir %q; want %q", got, want) - } - if got, want := len(childA.Module.ModuleCalls), 1; got != want { - t.Errorf("wrong number of module calls in child_a %d; want %d", got, want) - } -} diff --git a/internal/configs/import_test.go b/internal/configs/import_test.go index 659d8f51c4..1d1c383958 100644 --- a/internal/configs/import_test.go +++ b/internal/configs/import_test.go @@ -16,13 +16,6 @@ import ( ) func TestParseConfigResourceFromExpression(t *testing.T) { - mustExpr := func(expr hcl.Expression, diags hcl.Diagnostics) hcl.Expression { - if diags != nil { - panic(diags.Error()) - } - return expr - } - tests := []struct { expr hcl.Expression expect addrs.ConfigResource @@ -280,3 +273,10 @@ func mustAbsResourceInstanceAddr(str string) addrs.AbsResourceInstance { } return addr } + +func mustExpr(expr hcl.Expression, diags hcl.Diagnostics) hcl.Expression { + if diags != nil { + panic(diags.Error()) + } + return expr +} diff --git a/internal/configs/testdata/invalid-files/version-variable.tf b/internal/configs/testdata/invalid-files/version-variable.tf deleted file mode 100644 index 7c871053de..0000000000 --- a/internal/configs/testdata/invalid-files/version-variable.tf +++ /dev/null @@ -1,6 +0,0 @@ -variable "module_version" { default = "v1.0" } - -module "foo" { - source = "./ff" - version = var.module_version -} diff --git a/internal/lang/globalref/analyzer_test.go b/internal/lang/globalref/analyzer_test.go index 7f50e6aca4..416cccce48 100644 --- a/internal/lang/globalref/analyzer_test.go +++ b/internal/lang/globalref/analyzer_test.go @@ -24,7 +24,7 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { loader, cleanup := configload.NewLoaderForTests(t) defer cleanup() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), configDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatalf("unexpected module installation errors: %s", instDiags.Err().Error()) @@ -33,7 +33,7 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { t.Fatalf("failed to refresh modules after install: %s", err) } - cfg, loadDiags := loader.LoadConfig(configDir) + cfg, loadDiags := loader.LoadStaticConfig(configDir) if loadDiags.HasErrors() { t.Fatalf("unexpected configuration errors: %s", loadDiags.Error()) } diff --git a/internal/moduletest/graph/eval_context_test.go b/internal/moduletest/graph/eval_context_test.go index eec1a08cdc..4b033f771a 100644 --- a/internal/moduletest/graph/eval_context_test.go +++ b/internal/moduletest/graph/eval_context_test.go @@ -835,7 +835,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -847,7 +847,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") if diags.HasErrors() { t.Fatal(diags.Error()) } diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index d54652b6d9..d375e90ca5 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log" + "slices" "sort" "strings" @@ -205,13 +206,7 @@ func (i *Installer) EnsureProviderVersions(ctx context.Context, locks *depsfile. if provider.IsBuiltIn() { // Built in providers do not require installation but we'll still // verify that the requested provider name is valid. - valid := false - for _, name := range i.builtInProviderTypes { - if name == provider.Type { - valid = true - break - } - } + valid := slices.Contains(i.builtInProviderTypes, provider.Type) var err error if valid { if len(versionConstraints) == 0 { diff --git a/internal/refactoring/move_validate_test.go b/internal/refactoring/move_validate_test.go index 9e44da0e58..bc9b66c2db 100644 --- a/internal/refactoring/move_validate_test.go +++ b/internal/refactoring/move_validate_test.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hcltest" + "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" "github.com/hashicorp/terraform/internal/addrs" @@ -519,7 +521,7 @@ func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instance loader, cleanup := configload.NewLoaderForTests(t) defer cleanup() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -531,7 +533,7 @@ func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instance t.Fatalf("failed to refresh modules after installation: %s", err) } - rootCfg, diags := loader.LoadConfig(dir) + rootCfg, diags := loader.LoadStaticConfig(dir) if diags.HasErrors() { t.Fatalf("failed to load root module: %s", diags.Error()) } @@ -565,7 +567,7 @@ func staticPopulateExpanderModule(t *testing.T, rootCfg *configs.Config, moduleA // module to be something that counts as a separate package, // so we can test rules relating to crossing package boundaries // even though we really just loaded the module from a local path. - call.SourceAddr = fakeExternalModuleSource + call.SourceExpr = hcltest.MockExprLiteral(cty.StringVal(fakeExternalModuleSource.String())) } // In order to get a valid, useful set of instances here we're going diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 53071437f3..309f4d044e 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -26,6 +26,7 @@ import ( "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" _ "github.com/hashicorp/terraform/internal/logging" ) @@ -67,7 +68,7 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -79,14 +80,44 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config t.Fatalf("failed to refresh modules after installation: %s", err) } - config, snap, diags := loader.LoadConfigWithSnapshot(dir) + config, snap, diags := testLoadWithSnapshot(dir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config, snap } +func testLoadWithSnapshot(dir string, loader *configload.Loader, vars InputValues) (*configs.Config, *configload.Snapshot, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + rootMod, configDiags := loader.LoadRootModule(dir) + if configDiags.HasErrors() { + diags = diags.Append(configDiags) + return nil, nil, diags + } + + walkerSnapshot, snap := loader.ModuleWalkerSnapshot() + config, buildDiags := BuildConfigWithGraph( + rootMod, + walkerSnapshot, + vars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + diags = diags.Append(buildDiags) + return nil, nil, diags + } + + snapDiags := loader.AddRootModuleToSnapshot(snap, dir) + if snapDiags.HasErrors() { + diags = diags.Append(snapDiags) + return nil, nil, diags + } + + return config, snap, nil +} + // testModuleInline takes a map of path -> config strings and yields a config // structure with those files loaded from disk func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...configs.Option) *configs.Config { @@ -127,7 +158,7 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -139,7 +170,7 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") if diags.HasErrors() { t.Fatal(diags.Error()) } @@ -147,6 +178,49 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con return config } +func testRootModuleInline(t testing.TB, sources map[string]string) *configs.Module { + t.Helper() + + cfgPath, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + for path, configStr := range sources { + dir := filepath.Dir(path) + if dir != "." { + err := os.MkdirAll(filepath.Join(cfgPath, dir), os.FileMode(0777)) + if err != nil { + t.Fatalf("Error creating subdir: %s", err) + } + } + // Write the configuration + cfgF, err := os.Create(filepath.Join(cfgPath, path)) + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + + _, err = io.Copy(cfgF, strings.NewReader(configStr)) + cfgF.Close() + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + } + + loader, cleanup := configload.NewLoaderForTests(t) + defer cleanup() + + // We need to be able to exercise experimental features in our integration tests. + loader.AllowLanguageExperiments(true) + + mod, diags := loader.Parser().LoadConfigDir(cfgPath) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + return mod +} + // testSetResourceInstanceCurrent is a helper function for tests that sets a Current, // Ready resource instance for the given module. func testSetResourceInstanceCurrent(module *states.Module, resource, attrsJson, provider string) { From d5d12cdd490ae3fa3e29dd44684a6b13cad4856e Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:36:58 +0100 Subject: [PATCH 010/136] Treat most values as dynamic during init walk Since init only really cares about references that are used inside a module source (or version), we can treat all other references as dynamic/unknown and shortcut most of their validation steps. --- internal/terraform/evaluate.go | 35 ++++++++++++++++++++++++++++- internal/terraform/evaluate_data.go | 8 +++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 6842ec8c3c..4c61753711 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -166,6 +166,10 @@ var _ lang.Data = (*evaluationStateData)(nil) // the evaluator embedded in this data object, using this data object's // static module path. func (d *evaluationStateData) StaticValidateReferences(refs []*addrs.Reference, self addrs.Referenceable, source addrs.Referenceable) tfdiags.Diagnostics { + if d.Operation == walkInit { + // Skip static validation during init walks + return tfdiags.Diagnostics{} + } return d.Evaluator.StaticValidateReferences(refs, d.ModulePath.Module(), self, source) } @@ -174,6 +178,12 @@ func (d *evaluationStateData) GetCountAttr(addr addrs.CountAttr, rng tfdiags.Sou switch addr.Name { case "index": + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + idxVal := d.InstanceKeyData.CountIndex if idxVal == cty.NilVal { diags = diags.Append(&hcl.Diagnostic{ @@ -226,6 +236,12 @@ func (d *evaluationStateData) GetForEachAttr(addr addrs.ForEachAttr, rng tfdiags return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + if returnVal == cty.NilVal { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -387,6 +403,12 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + // We'll consult the configuration to see what output names we are // expecting, so we can ensure the resulting object is of the expected // type even if our data is incomplete for some reason. @@ -561,6 +583,11 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } // Much of this function was written before we had factored out the handling // of instance keys into the separate instance expander model, and so it // does a bunch of instance-related work itself below. @@ -1000,7 +1027,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi func (d *evaluationStateData) getEphemeralResource(addr addrs.Resource, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - if d.Operation == walkValidate || d.Operation == walkEval { + if d.Operation == walkValidate || d.Operation == walkEval || d.Operation == walkInit { // Ephemeral instances are never live during the validate walk. Eval is // similarly offline, and since there is no value stored we can't return // anything other than dynamic. @@ -1120,6 +1147,12 @@ func (d *evaluationStateData) getResourceSchema(addr addrs.Resource, providerAdd func (d *evaluationStateData) GetOutput(addr addrs.OutputValue, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + // First we'll make sure the requested value is declared in configuration, // so we can produce a nice message if not. moduleConfig := d.Evaluator.Config.DescendantForInstance(d.ModulePath) diff --git a/internal/terraform/evaluate_data.go b/internal/terraform/evaluate_data.go index 3d33b598d8..250e1abc77 100644 --- a/internal/terraform/evaluate_data.go +++ b/internal/terraform/evaluate_data.go @@ -99,6 +99,10 @@ func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.SourceRang // GetTerraformAttr implements lang.Data. func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + if d.Evaluator.Operation == walkInit { + return cty.DynamicVal, tfdiags.Diagnostics{} + } + var diags tfdiags.Diagnostics switch addr.Name { @@ -154,6 +158,10 @@ func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags. // StaticValidateReferences implements lang.Data. func (d *evaluationData) StaticValidateReferences(refs []*addrs.Reference, self addrs.Referenceable, source addrs.Referenceable) tfdiags.Diagnostics { + if d.Evaluator.Operation == walkInit { + // Skip static validation during init walks + return tfdiags.Diagnostics{} + } return d.Evaluator.StaticValidateReferences(refs, d.Module, self, source) } From 0043e1ce249155370dee0dafc5dd027ebb9f6e4f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:39:04 +0100 Subject: [PATCH 011/136] Update module call configuration storage This turns the string representation of the source and version attribute of a module call into an expression. This leads to a change in the JSON output: `"source": "./foo"` --> `"source": {"constant_value": "./foo"},` --- internal/command/jsonconfig/config.go | 26 +- internal/command/show_test.go | 5 +- .../show-json/module-depends-on/output.json | 162 +++-- .../testdata/show-json/modules/output.json | 584 +++++++++--------- .../show-json/nested-modules/output.json | 8 +- .../provider-aliasing-conflict/output.json | 12 +- .../provider-aliasing-default/output.json | 28 +- .../show-json/provider-aliasing/output.json | 56 +- 8 files changed, 428 insertions(+), 453 deletions(-) diff --git a/internal/command/jsonconfig/config.go b/internal/command/jsonconfig/config.go index b2d794bdf4..d466f44d4b 100644 --- a/internal/command/jsonconfig/config.go +++ b/internal/command/jsonconfig/config.go @@ -49,12 +49,12 @@ type module struct { } type moduleCall struct { - Source string `json:"source,omitempty"` + SourceExpression *expression `json:"source,omitempty"` Expressions map[string]interface{} `json:"expressions,omitempty"` CountExpression *expression `json:"count_expression,omitempty"` ForEachExpression *expression `json:"for_each_expression,omitempty"` Module module `json:"module,omitempty"` - VersionConstraint string `json:"version_constraint,omitempty"` + VersionConstraint *expression `json:"version_constraint,omitempty"` DependsOn []string `json:"depends_on,omitempty"` } @@ -415,16 +415,20 @@ func marshalModuleCall(c *configs.Config, mc *configs.ModuleCall, schemas *terra return moduleCall{} } - ret := moduleCall{ - // We're intentionally echoing back exactly what the user entered - // here, rather than the normalized version in SourceAddr, because - // historically we only _had_ the raw address and thus it would be - // a (admittedly minor) breaking change to start normalizing them - // now, in case consumers of this data are expecting a particular - // non-normalized syntax. - Source: mc.SourceAddrRaw, - VersionConstraint: mc.Version.Required.String(), + ret := moduleCall{} + // We're intentionally echoing back exactly what the user entered + // here, rather than the normalized version in SourceAddr, because + // historically we only _had_ the raw address and thus it would be + // a (admittedly minor) breaking change to start normalizing them + // now, in case consumers of this data are expecting a particular + // non-normalized syntax. + sExp := marshalExpression(mc.SourceExpr) + ret.SourceExpression = &sExp + if mc.VersionExpr != nil { + vExp := marshalExpression(mc.VersionExpr) + ret.VersionConstraint = &vExp } + cExp := marshalExpression(mc.Count) if !cExp.Empty() { ret.CountExpression = &cExp diff --git a/internal/command/show_test.go b/internal/command/show_test.go index 99da480f36..71995d9b90 100644 --- a/internal/command/show_test.go +++ b/internal/command/show_test.go @@ -6,6 +6,7 @@ package command import ( "bytes" "encoding/json" + "io" "io/ioutil" "os" "path/filepath" @@ -533,7 +534,7 @@ func TestShow_state(t *testing.T) { func TestShow_json_output(t *testing.T) { fixtureDir := "testdata/show-json" - testDirs, err := ioutil.ReadDir(fixtureDir) + testDirs, err := os.ReadDir(fixtureDir) if err != nil { t.Fatal(err) } @@ -584,7 +585,7 @@ func TestShow_json_output(t *testing.T) { t.Fatalf("unexpected err: %s", err) } defer wantFile.Close() - byteValue, err := ioutil.ReadAll(wantFile) + byteValue, err := io.ReadAll(wantFile) if err != nil { t.Fatalf("unexpected err: %s", err) } diff --git a/internal/command/testdata/show-json/module-depends-on/output.json b/internal/command/testdata/show-json/module-depends-on/output.json index 59f4a1bce9..e29262037c 100644 --- a/internal/command/testdata/show-json/module-depends-on/output.json +++ b/internal/command/testdata/show-json/module-depends-on/output.json @@ -1,87 +1,85 @@ { - "format_version": "1.0", - "terraform_version": "0.13.1-dev", - "applyable": true, - "complete": true, - "planned_values": { - "root_module": { - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "foo-bar" - }, - "sensitive_values": {} - } - ] - } - }, - "resource_changes": [ + "format_version": "1.0", + "terraform_version": "0.13.1-dev", + "applyable": true, + "complete": true, + "planned_values": { + "root_module": { + "resources": [ { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "change": { - "actions": [ - "create" - ], - "before": null, - "after": { - "ami": "foo-bar" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - } - ], - "configuration": { - "provider_config": { - "test": { - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" - } - }, - "root_module": { - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "test", - "expressions": { - "ami": { - "constant_value": "foo-bar" - } - }, - "schema_version": 0 - } - ], - "module_calls": { - "foo": { - "depends_on": [ - "test_instance.test" - ], - "source": "./foo", - "module": { - "variables": { - "test_var": { - "default": "foo-var" - } - } - } - } - } + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "foo-bar" + }, + "sensitive_values": {} } + ] } + }, + "resource_changes": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "foo-bar" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + } + ], + "configuration": { + "provider_config": { + "test": { + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + } + }, + "root_module": { + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "test", + "expressions": { + "ami": { + "constant_value": "foo-bar" + } + }, + "schema_version": 0 + } + ], + "module_calls": { + "foo": { + "depends_on": ["test_instance.test"], + "source": { + "constant_value": "./foo" + }, + "module": { + "variables": { + "test_var": { + "default": "foo-var" + } + } + } + } + } + } + } } diff --git a/internal/command/testdata/show-json/modules/output.json b/internal/command/testdata/show-json/modules/output.json index 96a5f490a6..db9cb2f9a9 100644 --- a/internal/command/testdata/show-json/modules/output.json +++ b/internal/command/testdata/show-json/modules/output.json @@ -1,308 +1,292 @@ { - "format_version": "1.0", - "applyable": true, - "complete": true, - "planned_values": { - "outputs": { - "test": { - "sensitive": false, - "type": "string", - "value": "baz" - } - }, - "root_module": { - "child_modules": [ - { - "resources": [ - { - "address": "module.module_test_bar.test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "bar-var" - }, - "sensitive_values": {} - } - ], - "address": "module.module_test_bar" - }, - { - "resources": [ - { - "address": "module.module_test_foo.test_instance.test[0]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 0, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - }, - { - "address": "module.module_test_foo.test_instance.test[1]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 1, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - }, - { - "address": "module.module_test_foo.test_instance.test[2]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 2, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - } - ], - "address": "module.module_test_foo" - } - ] - } + "format_version": "1.0", + "applyable": true, + "complete": true, + "planned_values": { + "outputs": { + "test": { + "sensitive": false, + "type": "string", + "value": "baz" + } }, - "prior_state": { - "format_version": "1.0", - "values": { - "outputs": { - "test": { - "sensitive": false, - "type": "string", - "value": "baz" - } + "root_module": { + "child_modules": [ + { + "resources": [ + { + "address": "module.module_test_bar.test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "bar-var" + }, + "sensitive_values": {} + } + ], + "address": "module.module_test_bar" + }, + { + "resources": [ + { + "address": "module.module_test_foo.test_instance.test[0]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 0, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} }, - "root_module": {} - } - }, - "resource_changes": [ - { - "address": "module.module_test_bar.test_instance.test", - "module_address": "module.module_test_bar", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "change": { - "actions": [ - "create" - ], - "before": null, - "after": { - "ami": "bar-var" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[0]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 0, - "change": { - "actions": [ - "create" - ], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[1]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 1, - "change": { - "actions": [ - "create" - ], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[2]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 2, - "change": { - "actions": [ - "create" - ], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - } - ], - "output_changes": { - "test": { - "actions": [ - "create" - ], - "before": null, - "after": "baz", - "after_unknown": false, - "before_sensitive": false, - "after_sensitive": false - } - }, - "configuration": { - "root_module": { - "outputs": { - "test": { - "expression": { - "references": [ - "module.module_test_foo.test", - "module.module_test_foo" - ] - }, - "depends_on": [ - "module.module_test_foo" - ] - } + { + "address": "module.module_test_foo.test_instance.test[1]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 1, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} }, - "module_calls": { - "module_test_bar": { - "source": "./bar", - "module": { - "outputs": { - "test": { - "expression": { - "references": [ - "var.test_var" - ] - } - } - }, - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "module.module_test_bar:test", - "expressions": { - "ami": { - "references": [ - "var.test_var" - ] - } - }, - "schema_version": 0 - } - ], - "variables": { - "test_var": { - "default": "bar-var" - } - } - } - }, - "module_test_foo": { - "source": "./foo", - "expressions": { - "test_var": { - "constant_value": "baz" - } - }, - "module": { - "outputs": { - "test": { - "expression": { - "references": [ - "var.test_var" - ] - } - } - }, - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "module.module_test_foo:test", - "expressions": { - "ami": { - "references": [ - "var.test_var" - ] - } - }, - "schema_version": 0, - "count_expression": { - "constant_value": 3 - } - } - ], - "variables": { - "test_var": { - "default": "foo-var" - } - } - } - } - } - }, - "provider_config": { - "module.module_test_foo:test": { - "module_address": "module.module_test_foo", - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" - }, - "module.module_test_bar:test": { - "module_address": "module.module_test_bar", - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" + { + "address": "module.module_test_foo.test_instance.test[2]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 2, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} } + ], + "address": "module.module_test_foo" } + ] } + }, + "prior_state": { + "format_version": "1.0", + "values": { + "outputs": { + "test": { + "sensitive": false, + "type": "string", + "value": "baz" + } + }, + "root_module": {} + } + }, + "resource_changes": [ + { + "address": "module.module_test_bar.test_instance.test", + "module_address": "module.module_test_bar", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "bar-var" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[0]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 0, + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[1]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 1, + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[2]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 2, + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + } + ], + "output_changes": { + "test": { + "actions": ["create"], + "before": null, + "after": "baz", + "after_unknown": false, + "before_sensitive": false, + "after_sensitive": false + } + }, + "configuration": { + "root_module": { + "outputs": { + "test": { + "expression": { + "references": [ + "module.module_test_foo.test", + "module.module_test_foo" + ] + }, + "depends_on": ["module.module_test_foo"] + } + }, + "module_calls": { + "module_test_bar": { + "source": { + "constant_value": "./bar" + }, + "module": { + "outputs": { + "test": { + "expression": { + "references": ["var.test_var"] + } + } + }, + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "module.module_test_bar:test", + "expressions": { + "ami": { + "references": ["var.test_var"] + } + }, + "schema_version": 0 + } + ], + "variables": { + "test_var": { + "default": "bar-var" + } + } + } + }, + "module_test_foo": { + "source": { + "constant_value": "./foo" + }, + "expressions": { + "test_var": { + "constant_value": "baz" + } + }, + "module": { + "outputs": { + "test": { + "expression": { + "references": ["var.test_var"] + } + } + }, + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "module.module_test_foo:test", + "expressions": { + "ami": { + "references": ["var.test_var"] + } + }, + "schema_version": 0, + "count_expression": { + "constant_value": 3 + } + } + ], + "variables": { + "test_var": { + "default": "foo-var" + } + } + } + } + } + }, + "provider_config": { + "module.module_test_foo:test": { + "module_address": "module.module_test_foo", + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + }, + "module.module_test_bar:test": { + "module_address": "module.module_test_bar", + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + } + } + } } diff --git a/internal/command/testdata/show-json/nested-modules/output.json b/internal/command/testdata/show-json/nested-modules/output.json index f96a24484d..cd2d6d9a0c 100644 --- a/internal/command/testdata/show-json/nested-modules/output.json +++ b/internal/command/testdata/show-json/nested-modules/output.json @@ -63,11 +63,15 @@ "root_module": { "module_calls": { "my_module": { - "source": "./modules", + "source": { + "constant_value": "./modules" + }, "module": { "module_calls": { "more": { - "source": "./more-modules", + "source": { + "constant_value": "./more-modules" + }, "module": { "resources": [ { diff --git a/internal/command/testdata/show-json/provider-aliasing-conflict/output.json b/internal/command/testdata/show-json/provider-aliasing-conflict/output.json index b516a4a564..c30f2774c9 100644 --- a/internal/command/testdata/show-json/provider-aliasing-conflict/output.json +++ b/internal/command/testdata/show-json/provider-aliasing-conflict/output.json @@ -48,9 +48,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "foo" @@ -70,9 +68,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp2/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "bar" @@ -120,7 +116,9 @@ ], "module_calls": { "child": { - "source": "./child", + "source": { + "constant_value": "./child" + }, "module": { "resources": [ { diff --git a/internal/command/testdata/show-json/provider-aliasing-default/output.json b/internal/command/testdata/show-json/provider-aliasing-default/output.json index f2639da510..5ccdc089b6 100644 --- a/internal/command/testdata/show-json/provider-aliasing-default/output.json +++ b/internal/command/testdata/show-json/provider-aliasing-default/output.json @@ -84,9 +84,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "foo" @@ -106,9 +104,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "bar" @@ -128,9 +124,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "qux" @@ -150,9 +144,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "baz" @@ -205,7 +197,9 @@ ], "module_calls": { "child": { - "source": "./child", + "source": { + "constant_value": "./child" + }, "module": { "resources": [ { @@ -224,7 +218,9 @@ ], "module_calls": { "no_requirements": { - "source": "./nested-no-requirements", + "source": { + "constant_value": "./nested-no-requirements" + }, "module": { "resources": [ { @@ -244,7 +240,9 @@ } }, "with_requirement": { - "source": "./nested", + "source": { + "constant_value": "./nested" + }, "depends_on": ["module.no_requirements"], "module": { "resources": [ diff --git a/internal/command/testdata/show-json/provider-aliasing/output.json b/internal/command/testdata/show-json/provider-aliasing/output.json index 9f5675036e..bb6429adc8 100755 --- a/internal/command/testdata/show-json/provider-aliasing/output.json +++ b/internal/command/testdata/show-json/provider-aliasing/output.json @@ -163,9 +163,7 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "foo" @@ -184,9 +182,7 @@ "name": "test_backup", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "foo-backup" @@ -206,9 +202,7 @@ "name": "test_primary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "primary" @@ -228,9 +222,7 @@ "name": "test_secondary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "secondary" @@ -250,9 +242,7 @@ "name": "test_primary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "primary" @@ -272,9 +262,7 @@ "name": "test_secondary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "secondary" @@ -294,9 +282,7 @@ "name": "test_alternate", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "secondary" @@ -316,9 +302,7 @@ "name": "test_main", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "main" @@ -338,9 +322,7 @@ "name": "test_alternate", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "secondary" @@ -360,9 +342,7 @@ "name": "test_main", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": [ - "create" - ], + "actions": ["create"], "before": null, "after": { "ami": "main" @@ -428,7 +408,9 @@ ], "module_calls": { "child": { - "source": "./child", + "source": { + "constant_value": "./child" + }, "module": { "resources": [ { @@ -460,7 +442,9 @@ ], "module_calls": { "grandchild": { - "source": "./nested", + "source": { + "constant_value": "./nested" + }, "module": { "resources": [ { @@ -496,7 +480,9 @@ } }, "sibling": { - "source": "./child", + "source": { + "constant_value": "./child" + }, "module": { "resources": [ { @@ -528,7 +514,9 @@ ], "module_calls": { "grandchild": { - "source": "./nested", + "source": { + "constant_value": "./nested" + }, "module": { "resources": [ { From 297ebd989e773f8e1acf3c5a4d6db4b0fe9e5818 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:43:16 +0100 Subject: [PATCH 012/136] Move some planfile tests To be able to read and build the configuration in planfile related tests, we need methods from the `terraform` package. This commit moves the test into that package to make testing easier. --- internal/plans/planfile/config_snapshot.go | 6 +++--- internal/plans/planfile/reader.go | 5 +++-- internal/plans/planfile/writer.go | 2 +- .../config_snapshot_test.go | 13 +++++++------ .../planfile => terraform}/planfile_test.go | 19 ++++++++++--------- .../testdata/planfile}/cloudplan.json | 0 .../.terraform/modules/child_a/child_a.tf | 0 .../modules/child_a/child_c/child_c.tf | 0 .../modules/child_b.child_d/child_d.tf | 0 .../.terraform/modules/child_b/child_b.tf | 0 .../.terraform/modules/modules.json | 10 +++++----- .../testdata/planfile}/test-config/root.tf | 0 12 files changed, 29 insertions(+), 26 deletions(-) rename internal/{plans/planfile => terraform}/config_snapshot_test.go (74%) rename internal/{plans/planfile => terraform}/planfile_test.go (91%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/cloudplan.json (100%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/.terraform/modules/child_a/child_a.tf (100%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/.terraform/modules/child_a/child_c/child_c.tf (100%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/.terraform/modules/child_b.child_d/child_d.tf (100%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/.terraform/modules/child_b/child_b.tf (100%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/.terraform/modules/modules.json (57%) rename internal/{plans/planfile/testdata => terraform/testdata/planfile}/test-config/root.tf (100%) diff --git a/internal/plans/planfile/config_snapshot.go b/internal/plans/planfile/config_snapshot.go index 25c8c2843d..925cce08c1 100644 --- a/internal/plans/planfile/config_snapshot.go +++ b/internal/plans/planfile/config_snapshot.go @@ -30,7 +30,7 @@ type configSnapshotModuleRecord struct { } type configSnapshotModuleManifest []configSnapshotModuleRecord -func readConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { +func ReadConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { // Errors from this function are expected to be reported with some // additional prefix context about them being in a config snapshot, // so they should not themselves refer to the config snapshot. @@ -145,13 +145,13 @@ func readConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { return snap, nil } -// writeConfigSnapshot adds to the given zip.Writer one or more files +// WriteConfigSnapshot adds to the given zip.Writer one or more files // representing the given snapshot. // // This file creates new files in the writer, so any already-open writer // for the file will be invalidated by this call. The writer remains open // when this function returns. -func writeConfigSnapshot(snap *configload.Snapshot, z *zip.Writer) error { +func WriteConfigSnapshot(snap *configload.Snapshot, z *zip.Writer) error { // Errors from this function are expected to be reported with some // additional prefix context about them being in a config snapshot, // so they should not themselves refer to the config snapshot. diff --git a/internal/plans/planfile/reader.go b/internal/plans/planfile/reader.go index 3bb0f439b8..2a54187d1b 100644 --- a/internal/plans/planfile/reader.go +++ b/internal/plans/planfile/reader.go @@ -188,7 +188,7 @@ func (r *Reader) ReadPrevStateFile() (*statefile.File, error) { // This is a lower-level alternative to ReadConfig that just extracts the // source files, without attempting to parse them. func (r *Reader) ReadConfigSnapshot() (*configload.Snapshot, error) { - return readConfigSnapshot(&r.zip.Reader) + return ReadConfigSnapshot(&r.zip.Reader) } // ReadConfig reads the configuration embedded in the plan file. @@ -196,6 +196,7 @@ func (r *Reader) ReadConfigSnapshot() (*configload.Snapshot, error) { // Internally this function delegates to the configs/configload package to // parse the embedded configuration and so it returns diagnostics (rather than // a native Go error as with other methods on Reader). +// TODO remove? func (r *Reader) ReadConfig(allowLanguageExperiments bool) (*configs.Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -212,7 +213,7 @@ func (r *Reader) ReadConfig(allowLanguageExperiments bool) (*configs.Config, tfd loader := configload.NewLoaderFromSnapshot(snap) loader.AllowLanguageExperiments(allowLanguageExperiments) rootDir := snap.Modules[""].Dir // Root module base directory - config, configDiags := loader.LoadConfig(rootDir) + config, configDiags := loader.LoadStaticConfig(rootDir) diags = diags.Append(configDiags) return config, diags diff --git a/internal/plans/planfile/writer.go b/internal/plans/planfile/writer.go index 7781700584..a4db0b3fff 100644 --- a/internal/plans/planfile/writer.go +++ b/internal/plans/planfile/writer.go @@ -111,7 +111,7 @@ func Create(filename string, args CreateArgs) error { // tfconfig directory { - err := writeConfigSnapshot(args.ConfigSnapshot, zw) + err := WriteConfigSnapshot(args.ConfigSnapshot, zw) if err != nil { return fmt.Errorf("failed to write config snapshot: %s", err) } diff --git a/internal/plans/planfile/config_snapshot_test.go b/internal/terraform/config_snapshot_test.go similarity index 74% rename from internal/plans/planfile/config_snapshot_test.go rename to internal/terraform/config_snapshot_test.go index 39344e0dcf..fbc1dcfcbc 100644 --- a/internal/plans/planfile/config_snapshot_test.go +++ b/internal/terraform/config_snapshot_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package planfile +package terraform import ( "archive/zip" @@ -13,10 +13,11 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/plans/planfile" ) func TestConfigSnapshotRoundtrip(t *testing.T) { - fixtureDir := filepath.Join("testdata", "test-config") + fixtureDir := filepath.Join("testdata", "planfile", "test-config") loader, err := configload.NewLoader(&configload.Config{ ModulesDir: filepath.Join(fixtureDir, ".terraform", "modules"), }) @@ -24,14 +25,14 @@ func TestConfigSnapshotRoundtrip(t *testing.T) { t.Fatal(err) } - _, snapIn, diags := loader.LoadConfigWithSnapshot(fixtureDir) + _, snapIn, diags := testLoadWithSnapshot(fixtureDir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } var buf bytes.Buffer zw := zip.NewWriter(&buf) - err = writeConfigSnapshot(snapIn, zw) + err = planfile.WriteConfigSnapshot(snapIn, zw) if err != nil { t.Fatalf("failed to write snapshot: %s", err) } @@ -44,7 +45,7 @@ func TestConfigSnapshotRoundtrip(t *testing.T) { t.Fatal(err) } - snapOut, err := readConfigSnapshot(zr) + snapOut, err := planfile.ReadConfigSnapshot(zr) if err != nil { t.Fatalf("failed to read snapshot: %s", err) } diff --git a/internal/plans/planfile/planfile_test.go b/internal/terraform/planfile_test.go similarity index 91% rename from internal/plans/planfile/planfile_test.go rename to internal/terraform/planfile_test.go index 2342f9404a..b3fe65fe5c 100644 --- a/internal/plans/planfile/planfile_test.go +++ b/internal/terraform/planfile_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package planfile +package terraform import ( "path/filepath" @@ -16,13 +16,14 @@ import ( "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" tfversion "github.com/hashicorp/terraform/version" ) func TestRoundtrip(t *testing.T) { - fixtureDir := filepath.Join("testdata", "test-config") + fixtureDir := filepath.Join("testdata", "planfile", "test-config") loader, err := configload.NewLoader(&configload.Config{ ModulesDir: filepath.Join(fixtureDir, ".terraform", "modules"), }) @@ -30,9 +31,9 @@ func TestRoundtrip(t *testing.T) { t.Fatal(err) } - _, snapIn, diags := loader.LoadConfigWithSnapshot(fixtureDir) + _, snapIn, diags := testLoadWithSnapshot(fixtureDir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } // Just a minimal state file so we can test that it comes out again at all. @@ -92,7 +93,7 @@ func TestRoundtrip(t *testing.T) { planFn := filepath.Join(t.TempDir(), "tfplan") - err = Create(planFn, CreateArgs{ + err = planfile.Create(planFn, planfile.CreateArgs{ ConfigSnapshot: snapIn, PreviousRunStateFile: prevStateFileIn, StateFile: stateFileIn, @@ -103,7 +104,7 @@ func TestRoundtrip(t *testing.T) { t.Fatalf("failed to create plan file: %s", err) } - wpf, err := OpenWrapped(planFn) + wpf, err := planfile.OpenWrapped(planFn) if err != nil { t.Fatalf("failed to open plan file for reading: %s", err) } @@ -181,14 +182,14 @@ func TestRoundtrip(t *testing.T) { func TestWrappedError(t *testing.T) { // Open something that isn't a cloud or local planfile: should error wrongFile := "not a valid zip file" - _, err := OpenWrapped(filepath.Join("testdata", "test-config", "root.tf")) + _, err := planfile.OpenWrapped(filepath.Join("testdata", "planfile", "test-config", "root.tf")) if !strings.Contains(err.Error(), wrongFile) { t.Fatalf("expected %q, got %q", wrongFile, err) } // Open something that doesn't exist: should error missingFile := "no such file or directory" - _, err = OpenWrapped(filepath.Join("testdata", "absent.tfplan")) + _, err = planfile.OpenWrapped(filepath.Join("testdata", "planfile", "absent.tfplan")) if !strings.Contains(err.Error(), missingFile) { t.Fatalf("expected %q, got %q", missingFile, err) } @@ -196,7 +197,7 @@ func TestWrappedError(t *testing.T) { func TestWrappedCloud(t *testing.T) { // Loading valid cloud plan results in a wrapped cloud plan - wpf, err := OpenWrapped(filepath.Join("testdata", "cloudplan.json")) + wpf, err := planfile.OpenWrapped(filepath.Join("testdata", "planfile", "cloudplan.json")) if err != nil { t.Fatalf("failed to open valid cloud plan: %s", err) } diff --git a/internal/plans/planfile/testdata/cloudplan.json b/internal/terraform/testdata/planfile/cloudplan.json similarity index 100% rename from internal/plans/planfile/testdata/cloudplan.json rename to internal/terraform/testdata/planfile/cloudplan.json diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_a.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_a.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_a.tf rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_a.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_c/child_c.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_c/child_c.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_c/child_c.tf rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_c/child_c.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_b.child_d/child_d.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b.child_d/child_d.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_b.child_d/child_d.tf rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b.child_d/child_d.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_b/child_b.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b/child_b.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_b/child_b.tf rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b/child_b.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json b/internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json similarity index 57% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json index ba691877ff..ebd4f0f778 100644 --- a/internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json @@ -3,30 +3,30 @@ { "Key": "", "Source": "", - "Dir": "testdata/test-config" + "Dir": "testdata/planfile/test-config" }, { "Key": "child_a", "Source": "example.com/foo/bar_a/baz", "Version": "1.0.1", - "Dir": "testdata/test-config/.terraform/modules/child_a" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_a" }, { "Key": "child_b", "Source": "example.com/foo/bar_b/baz", "Version": "1.0.0", - "Dir": "testdata/test-config/.terraform/modules/child_b" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_b" }, { "Key": "child_a.child_c", "Source": "./child_c", - "Dir": "testdata/test-config/.terraform/modules/child_a/child_c" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_a/child_c" }, { "Key": "child_b.child_d", "Source": "example.com/foo/bar_d/baz", "Version": "1.2.0", - "Dir": "testdata/test-config/.terraform/modules/child_b.child_d" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_b.child_d" } ] } diff --git a/internal/plans/planfile/testdata/test-config/root.tf b/internal/terraform/testdata/planfile/test-config/root.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/root.tf rename to internal/terraform/testdata/planfile/test-config/root.tf From 5a70c424b3cf3d5d5abdacc332f38aaf7d2875b8 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:45:03 +0100 Subject: [PATCH 013/136] Fix import and show command This commit moves some code around to fix configuration loading during the (legacy) import command. And add vars to the show command. --- internal/command/arguments/show.go | 5 +- internal/command/arguments/show_test.go | 21 ++++- internal/command/import.go | 94 ++++++++++--------- internal/command/show.go | 63 ++++++++++++- .../incorrectmodulename/output.json | 54 +---------- 5 files changed, 128 insertions(+), 109 deletions(-) diff --git a/internal/command/arguments/show.go b/internal/command/arguments/show.go index ebadf64cf8..03f9b029a1 100644 --- a/internal/command/arguments/show.go +++ b/internal/command/arguments/show.go @@ -15,6 +15,8 @@ type Show struct { // ViewType specifies which output format to use: human, JSON, or "raw". ViewType ViewType + + Vars *Vars } // ParseShow processes CLI arguments, returning a Show value and errors. @@ -24,10 +26,11 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics show := &Show{ Path: "", + Vars: &Vars{}, } var jsonOutput bool - cmdFlags := defaultFlagSet("show") + cmdFlags := extendedFlagSet("show", nil, nil, show.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/show_test.go b/internal/command/arguments/show_test.go index 4cee25ab20..6104e69e44 100644 --- a/internal/command/arguments/show_test.go +++ b/internal/command/arguments/show_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 TestParseShow_valid(t *testing.T) { &Show{ Path: "", ViewType: ViewHuman, + Vars: &Vars{}, }, }, "json": { @@ -26,6 +29,7 @@ func TestParseShow_valid(t *testing.T) { &Show{ Path: "", ViewType: ViewJSON, + Vars: &Vars{}, }, }, "path": { @@ -33,18 +37,21 @@ func TestParseShow_valid(t *testing.T) { &Show{ Path: "foo", ViewType: ViewJSON, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseShow(tc.args) - if len(diags) > 0 { + if len(diags) > 0 && diags.HasErrors() { 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.Errorf("unexpected result\n%s", diff) } }) } @@ -61,6 +68,7 @@ func TestParseShow_invalid(t *testing.T) { &Show{ Path: "", ViewType: ViewHuman, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -75,6 +83,7 @@ func TestParseShow_invalid(t *testing.T) { &Show{ Path: "bar", ViewType: ViewJSON, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -86,11 +95,13 @@ func TestParseShow_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseShow(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.Errorf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) diff --git a/internal/command/import.go b/internal/command/import.go index dfc0e2c7d4..f733fa2ae8 100644 --- a/internal/command/import.go +++ b/internal/command/import.go @@ -86,6 +86,54 @@ func (c *ImportCommand) Run(args []string) int { return 1 } + // Load the backend + b, backendDiags := c.backend(".", arguments.ViewHuman) + diags = diags.Append(backendDiags) + if backendDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + // We require a backendrun.Local to build a context. + // This isn't necessarily a "local.Local" backend, which provides local + // operations, however that is the only current implementation. A + // "local.Local" backend also doesn't necessarily provide local state, as + // that may be delegated to a "remotestate.Backend". + local, ok := b.(backendrun.Local) + if !ok { + c.Ui.Error(ErrUnsupportedLocalOp) + return 1 + } + + // Build the operation + var err error + opReq := c.Operation(b, arguments.ViewHuman) + opReq.ConfigDir = parsedArgs.ConfigPath + opReq.ConfigLoader, err = c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + opReq.Hooks = []terraform.Hook{c.uiHook()} + + { + // Collect variable value and add them to the operation request + 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 varDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + c.VariableValues = opReq.Variables + } + opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) + // Load the full config, so we can verify that the target resource is // already configured. config, configDiags := c.loadConfig(parsedArgs.ConfigPath) @@ -144,57 +192,11 @@ func (c *ImportCommand) Run(args []string) int { } // Check for user-supplied plugin path - var err error if c.pluginPath, err = c.loadPluginPath(); err != nil { c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) return 1 } - // Load the backend - b, backendDiags := c.backend(".", arguments.ViewHuman) - diags = diags.Append(backendDiags) - if backendDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - - // We require a backendrun.Local to build a context. - // This isn't necessarily a "local.Local" backend, which provides local - // operations, however that is the only current implementation. A - // "local.Local" backend also doesn't necessarily provide local state, as - // that may be delegated to a "remotestate.Backend". - local, ok := b.(backendrun.Local) - if !ok { - c.Ui.Error(ErrUnsupportedLocalOp) - return 1 - } - - // Build the operation - opReq := c.Operation(b, arguments.ViewHuman) - opReq.ConfigDir = parsedArgs.ConfigPath - opReq.ConfigLoader, err = c.initConfigLoader() - if err != nil { - diags = diags.Append(err) - c.showDiagnostics(diags) - return 1 - } - opReq.Hooks = []terraform.Hook{c.uiHook()} - - { - // Collect variable value and add them to the operation request - 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 varDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } - opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) - // Check remote Terraform version is compatible remoteVersionDiags := c.remoteVersionCheck(b, opReq.Workspace) diags = diags.Append(remoteVersionDiags) diff --git a/internal/command/show.go b/internal/command/show.go index 19a829dfd2..eb7aed4422 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -11,11 +11,13 @@ import ( "strings" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statefile" @@ -66,8 +68,20 @@ func (c *ShowCommand) Run(rawArgs []string) int { // Set up view view := views.NewShow(args.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) + // Check for user-supplied plugin path - var err error if c.pluginPath, err = c.loadPluginPath(); err != nil { diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err)) view.Diagnostics(diags) @@ -267,7 +281,7 @@ func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.Remo } if lp, ok := pf.Local(); ok { - plan, stateFile, config, err = getDataFromPlanfileReader(lp, c.Meta.AllowExperimentalFeatures) + plan, stateFile, config, err = getDataFromPlanfileReader(lp, c.Meta.AllowExperimentalFeatures, c.Meta.VariableValues) } else if cp, ok := pf.Cloud(); ok { redacted := c.viewType != arguments.ViewJSON jsonPlan, err = c.getDataFromCloudPlan(cp, redacted) @@ -297,7 +311,7 @@ func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, re } // getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file. -func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperiments bool) (*plans.Plan, *statefile.File, *configs.Config, error) { +func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperiments bool, variableValues map[string]arguments.UnparsedVariableValue) (*plans.Plan, *statefile.File, *configs.Config, error) { // Get plan plan, err := planReader.ReadPlan() if err != nil { @@ -311,7 +325,7 @@ func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperim } // Get config - config, diags := planReader.ReadConfig(allowLanguageExperiments) + config, diags := readConfig(planReader, allowLanguageExperiments, variableValues) if diags.HasErrors() { return nil, nil, nil, errUnusable(diags.Err(), "local plan") } @@ -319,6 +333,47 @@ func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperim return plan, stateFile, config, err } +func readConfig(r *planfile.Reader, allowLanguageExperiments bool, variableValues map[string]arguments.UnparsedVariableValue) (*configs.Config, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + snap, err := r.ReadConfigSnapshot() + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to read configuration from plan file", + fmt.Sprintf("The configuration file snapshot in the plan file could not be read: %s.", err), + )) + return nil, diags + } + + loader := configload.NewLoaderFromSnapshot(snap) + loader.AllowLanguageExperiments(allowLanguageExperiments) + rootMod, rootDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags = diags.Append(rootDiags) + if rootDiags.HasErrors() { + return nil, diags + } + + variables, varDiags := backendrun.ParseVariableValues(variableValues, rootMod.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, diags + } + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + variables, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, diags + } + + return config, diags +} + // getStateFromPath returns a statefile if the user-supplied path points to a statefile. func getStateFromPath(path string) (*statefile.File, error) { file, err := os.Open(path) diff --git a/internal/command/testdata/validate-invalid/incorrectmodulename/output.json b/internal/command/testdata/validate-invalid/incorrectmodulename/output.json index f144313fa4..d167b1b142 100644 --- a/internal/command/testdata/validate-invalid/incorrectmodulename/output.json +++ b/internal/command/testdata/validate-invalid/incorrectmodulename/output.json @@ -1,7 +1,7 @@ { "format_version": "1.0", "valid": false, - "error_count": 4, + "error_count": 2, "warning_count": 0, "diagnostics": [ { @@ -55,58 +55,6 @@ "highlight_end_offset": 21, "values": [] } - }, - { - "severity": "error", - "summary": "Variables not allowed", - "detail": "Variables may not be used here.", - "range": { - "filename": "testdata/validate-invalid/incorrectmodulename/main.tf", - "start": { - "line": 5, - "column": 12, - "byte": 55 - }, - "end": { - "line": 5, - "column": 15, - "byte": 58 - } - }, - "snippet": { - "context": "module \"super\"", - "code": " source = var.modulename", - "start_line": 5, - "highlight_start_offset": 11, - "highlight_end_offset": 14, - "values": [] - } - }, - { - "severity": "error", - "summary": "Unsuitable value type", - "detail": "Unsuitable value: value must be known", - "range": { - "filename": "testdata/validate-invalid/incorrectmodulename/main.tf", - "start": { - "line": 5, - "column": 12, - "byte": 55 - }, - "end": { - "line": 5, - "column": 26, - "byte": 69 - } - }, - "snippet": { - "context": "module \"super\"", - "code": " source = var.modulename", - "start_line": 5, - "highlight_start_offset": 11, - "highlight_end_offset": 25, - "values": [] - } } ] } From 834c2f4c21cb2671a13e7847ef1f837b5b519ab9 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:45:59 +0100 Subject: [PATCH 014/136] Fix modules command To be able to show version constraint from modules, we now store them during configuration loading. --- internal/configs/config.go | 7 +++++++ internal/moduleref/resolver.go | 6 ++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/configs/config.go b/internal/configs/config.go index 150af76314..a258f94173 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -87,6 +87,13 @@ type Config struct { // This field is meaningless for the root module, where it will always // be nil. Version *version.Version + + // VersionConstraint is the version constraint that was specified for this module. + // This field is nil if no version constraint was specified. + // + // This field is meaningless for the root module, where it will always + // be nil. + VersionConstraint VersionConstraint } // ModuleRequirements represents the provider requirements for an individual diff --git a/internal/moduleref/resolver.go b/internal/moduleref/resolver.go index 8871abc49a..081579dcee 100644 --- a/internal/moduleref/resolver.go +++ b/internal/moduleref/resolver.go @@ -56,12 +56,10 @@ func (r *Resolver) findAndTrimReferencedEntries(cfg *configs.Config, parentRecor var name string var versionConstraints version.Constraints if parentKey != nil { - for key := range cfg.Parent.Children { + for key, child := range cfg.Parent.Children { if key == *parentKey { name = key - if cfg.Parent.Module.ModuleCalls[key] != nil { - versionConstraints = cfg.Parent.Module.ModuleCalls[key].Version.Required - } + versionConstraints = child.VersionConstraint.Required break } } From 65c7a3b9f390ee142219424b79476e5759df81dd Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 17:54:36 +0100 Subject: [PATCH 015/136] Add changelog --- .changes/v1.15/NEW FEATURES-20260226-175431.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/NEW FEATURES-20260226-175431.yaml diff --git a/.changes/v1.15/NEW FEATURES-20260226-175431.yaml b/.changes/v1.15/NEW FEATURES-20260226-175431.yaml new file mode 100644 index 0000000000..73de4ec6c7 --- /dev/null +++ b/.changes/v1.15/NEW FEATURES-20260226-175431.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: Terraform now supports variables and locals in module source and version attributes +time: 2026-02-26T17:54:31.157412+01:00 +custom: + Issue: "38217" From 55198eb2219c063aad8c53b2a495282dcba91873 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 2 Mar 2026 13:03:55 +0100 Subject: [PATCH 016/136] Feedback: Rename remaining static -> const --- internal/command/meta_config.go | 8 ++++---- internal/terraform/context_init_test.go | 16 ++++++++-------- internal/terraform/node_module_variable.go | 4 ++-- internal/terraform/node_root_variable.go | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 15b56cc2ee..02f0b8415f 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 } - betterVars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables) + vars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables) diags = diags.Append(parseDiags) if parseDiags.HasErrors() { return nil, diags @@ -66,7 +66,7 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) config, buildDiags := terraform.BuildConfigWithGraph( rootMod, loader.ModuleWalker(), - betterVars, + vars, configs.MockDataLoaderFunc(loader.LoadExternalMockData), ) diags = diags.Append(buildDiags) @@ -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 } - betterVars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + vars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) diags = diags.Append(parseDiags) if parseDiags.HasErrors() { return nil, diags @@ -103,7 +103,7 @@ func (m *Meta) loadConfigWithTests(rootDir, testDir string) (*configs.Config, tf config, buildDiags := terraform.BuildConfigWithGraph( rootMod, loader.ModuleWalker(), - betterVars, + vars, configs.MockDataLoaderFunc(loader.LoadExternalMockData), ) diags = diags.Append(buildDiags) diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index c9f7f59fd7..5e532dff3b 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -117,7 +117,7 @@ module "example" { }}, }, - "local with non-static variables": { + "local with non-const variables": { module: map[string]string{ "main.tf": ` variable "name" { @@ -134,7 +134,7 @@ module "example" { expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { // TODO: We should try to somehow add an "extra" into the diagnostics to indicate - // that this may be caused by a non-static variable used during init. + // that this may be caused by a non-const variable used during init. return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid module source`, @@ -513,7 +513,7 @@ output "name" { }}, }, - "static variable with no value and no default": { + "const variable with no value and no default": { module: map[string]string{"main.tf": ` variable "name" { type = string @@ -538,7 +538,7 @@ module "example" { }, }, - "static variable with default": { + "const variable with default": { module: map[string]string{"main.tf": ` variable "name" { type = string @@ -555,7 +555,7 @@ module "example" { }}, }, - "non-static variable passed into static module variable": { + "non-const variable passed into const module variable": { module: map[string]string{"main.tf": ` variable "name" { type = string @@ -583,8 +583,8 @@ variable "name" { expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: `Static variables must be known`, - Detail: `Only a static value can be passed into a static module variable.`, + Summary: `Const variables must be known`, + Detail: `Only a constant value can be passed into a constant module variable.`, Subject: &hcl.Range{ Filename: filepath.Join(m.SourceDir, "main.tf"), Start: hcl.Pos{Line: 8, Column: 10, Byte: 118}, @@ -621,7 +621,7 @@ module "nested" { }}, expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { // TODO: We should try to somehow add an "extra" into the diagnostics to indicate - // that this may be caused by a non-static variable used during init. + // that this may be caused by a non-const variable used during init. return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid module source`, diff --git a/internal/terraform/node_module_variable.go b/internal/terraform/node_module_variable.go index 7672906851..7458fd4173 100644 --- a/internal/terraform/node_module_variable.go +++ b/internal/terraform/node_module_variable.go @@ -251,8 +251,8 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t if op == walkInit && n.Config.Const && !val.IsWhollyKnown() { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "Static variables must be known", - Detail: "Only a static value can be passed into a static module variable.", + Summary: "Const variables must be known", + Detail: "Only a constant value can be passed into a constant module variable.", Subject: errSourceRange.ToHCL().Ptr(), }) } diff --git a/internal/terraform/node_root_variable.go b/internal/terraform/node_root_variable.go index 694a65a0a7..5bf530a86f 100644 --- a/internal/terraform/node_root_variable.go +++ b/internal/terraform/node_root_variable.go @@ -110,7 +110,7 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di } } - // During init we only want to prepare the final value for static variables. + // During init we only want to prepare the final value for const variables. if op == walkInit { var finalVal cty.Value if n.Config.Const { @@ -127,7 +127,7 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di return diags } } else { - // All non-static variables are unknown during init. + // All non-const variables are unknown during init. finalVal = cty.UnknownVal(n.Config.Type) } ctx.NamedValues().SetInputVariableValue(addr, finalVal) From b6804e2edd1a858c557f5737878162ded339c858 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 2 Mar 2026 14:26:46 +0100 Subject: [PATCH 017/136] Use configuration values for jsonconfig Instead of outputting the raw values from the ModuleCall, we're now using the evaluated values from the Config. This should keep the JSON output unchanged, we just need to make sure to persist the values in `Config` when loading the configuration. --- internal/command/jsonconfig/config.go | 25 +- .../show-json/module-depends-on/output.json | 152 ++--- .../testdata/show-json/modules/output.json | 584 +++++++++--------- .../show-json/nested-modules/output.json | 8 +- .../provider-aliasing-conflict/output.json | 12 +- .../provider-aliasing-default/output.json | 28 +- .../show-json/provider-aliasing/output.json | 56 +- internal/terraform/node_module_install.go | 32 +- 8 files changed, 463 insertions(+), 434 deletions(-) diff --git a/internal/command/jsonconfig/config.go b/internal/command/jsonconfig/config.go index d466f44d4b..3338fb336a 100644 --- a/internal/command/jsonconfig/config.go +++ b/internal/command/jsonconfig/config.go @@ -49,12 +49,12 @@ type module struct { } type moduleCall struct { - SourceExpression *expression `json:"source,omitempty"` + Source string `json:"source,omitempty"` Expressions map[string]interface{} `json:"expressions,omitempty"` CountExpression *expression `json:"count_expression,omitempty"` ForEachExpression *expression `json:"for_each_expression,omitempty"` Module module `json:"module,omitempty"` - VersionConstraint *expression `json:"version_constraint,omitempty"` + VersionConstraint string `json:"version_constraint,omitempty"` DependsOn []string `json:"depends_on,omitempty"` } @@ -415,18 +415,15 @@ func marshalModuleCall(c *configs.Config, mc *configs.ModuleCall, schemas *terra return moduleCall{} } - ret := moduleCall{} - // We're intentionally echoing back exactly what the user entered - // here, rather than the normalized version in SourceAddr, because - // historically we only _had_ the raw address and thus it would be - // a (admittedly minor) breaking change to start normalizing them - // now, in case consumers of this data are expecting a particular - // non-normalized syntax. - sExp := marshalExpression(mc.SourceExpr) - ret.SourceExpression = &sExp - if mc.VersionExpr != nil { - vExp := marshalExpression(mc.VersionExpr) - ret.VersionConstraint = &vExp + ret := moduleCall{ + // We're intentionally echoing back exactly what the user entered + // here, rather than the normalized version in SourceAddr, because + // historically we only _had_ the raw address and thus it would be + // a (admittedly minor) breaking change to start normalizing them + // now, in case consumers of this data are expecting a particular + // non-normalized syntax. + Source: c.SourceAddrRaw, + VersionConstraint: c.VersionConstraint.Required.String(), } cExp := marshalExpression(mc.Count) diff --git a/internal/command/testdata/show-json/module-depends-on/output.json b/internal/command/testdata/show-json/module-depends-on/output.json index e29262037c..59f4a1bce9 100644 --- a/internal/command/testdata/show-json/module-depends-on/output.json +++ b/internal/command/testdata/show-json/module-depends-on/output.json @@ -1,85 +1,87 @@ { - "format_version": "1.0", - "terraform_version": "0.13.1-dev", - "applyable": true, - "complete": true, - "planned_values": { - "root_module": { - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "foo-bar" - }, - "sensitive_values": {} + "format_version": "1.0", + "terraform_version": "0.13.1-dev", + "applyable": true, + "complete": true, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "foo-bar" + }, + "sensitive_values": {} + } + ] } - ] - } - }, - "resource_changes": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "change": { - "actions": ["create"], - "before": null, - "after": { - "ami": "foo-bar" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - } - ], - "configuration": { - "provider_config": { - "test": { - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" - } }, - "root_module": { - "resources": [ + "resource_changes": [ { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "test", - "expressions": { - "ami": { - "constant_value": "foo-bar" + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "foo-bar" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false } - }, - "schema_version": 0 } - ], - "module_calls": { - "foo": { - "depends_on": ["test_instance.test"], - "source": { - "constant_value": "./foo" - }, - "module": { - "variables": { - "test_var": { - "default": "foo-var" - } + ], + "configuration": { + "provider_config": { + "test": { + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + } + }, + "root_module": { + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "test", + "expressions": { + "ami": { + "constant_value": "foo-bar" + } + }, + "schema_version": 0 + } + ], + "module_calls": { + "foo": { + "depends_on": [ + "test_instance.test" + ], + "source": "./foo", + "module": { + "variables": { + "test_var": { + "default": "foo-var" + } + } + } + } } - } } - } } - } } diff --git a/internal/command/testdata/show-json/modules/output.json b/internal/command/testdata/show-json/modules/output.json index db9cb2f9a9..96a5f490a6 100644 --- a/internal/command/testdata/show-json/modules/output.json +++ b/internal/command/testdata/show-json/modules/output.json @@ -1,292 +1,308 @@ { - "format_version": "1.0", - "applyable": true, - "complete": true, - "planned_values": { - "outputs": { - "test": { - "sensitive": false, - "type": "string", - "value": "baz" - } - }, - "root_module": { - "child_modules": [ - { - "resources": [ - { - "address": "module.module_test_bar.test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "bar-var" - }, - "sensitive_values": {} - } - ], - "address": "module.module_test_bar" - }, - { - "resources": [ - { - "address": "module.module_test_foo.test_instance.test[0]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 0, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - }, - { - "address": "module.module_test_foo.test_instance.test[1]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 1, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - }, - { - "address": "module.module_test_foo.test_instance.test[2]", - "mode": "managed", - "type": "test_instance", - "name": "test", - "index": 2, - "provider_name": "registry.terraform.io/hashicorp/test", - "schema_version": 0, - "values": { - "ami": "baz" - }, - "sensitive_values": {} - } - ], - "address": "module.module_test_foo" - } - ] - } - }, - "prior_state": { "format_version": "1.0", - "values": { - "outputs": { - "test": { - "sensitive": false, - "type": "string", - "value": "baz" - } - }, - "root_module": {} - } - }, - "resource_changes": [ - { - "address": "module.module_test_bar.test_instance.test", - "module_address": "module.module_test_bar", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_name": "registry.terraform.io/hashicorp/test", - "change": { - "actions": ["create"], - "before": null, - "after": { - "ami": "bar-var" + "applyable": true, + "complete": true, + "planned_values": { + "outputs": { + "test": { + "sensitive": false, + "type": "string", + "value": "baz" + } }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[0]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 0, - "change": { - "actions": ["create"], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[1]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 1, - "change": { - "actions": ["create"], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - }, - { - "address": "module.module_test_foo.test_instance.test[2]", - "module_address": "module.module_test_foo", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 2, - "change": { - "actions": ["create"], - "before": null, - "after": { - "ami": "baz" - }, - "after_unknown": { - "id": true - }, - "after_sensitive": {}, - "before_sensitive": false - } - } - ], - "output_changes": { - "test": { - "actions": ["create"], - "before": null, - "after": "baz", - "after_unknown": false, - "before_sensitive": false, - "after_sensitive": false - } - }, - "configuration": { - "root_module": { - "outputs": { - "test": { - "expression": { - "references": [ - "module.module_test_foo.test", - "module.module_test_foo" + "root_module": { + "child_modules": [ + { + "resources": [ + { + "address": "module.module_test_bar.test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "bar-var" + }, + "sensitive_values": {} + } + ], + "address": "module.module_test_bar" + }, + { + "resources": [ + { + "address": "module.module_test_foo.test_instance.test[0]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 0, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} + }, + { + "address": "module.module_test_foo.test_instance.test[1]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 1, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} + }, + { + "address": "module.module_test_foo.test_instance.test[2]", + "mode": "managed", + "type": "test_instance", + "name": "test", + "index": 2, + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz" + }, + "sensitive_values": {} + } + ], + "address": "module.module_test_foo" + } ] - }, - "depends_on": ["module.module_test_foo"] } - }, - "module_calls": { - "module_test_bar": { - "source": { - "constant_value": "./bar" - }, - "module": { - "outputs": { - "test": { - "expression": { - "references": ["var.test_var"] - } - } - }, - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "module.module_test_bar:test", - "expressions": { - "ami": { - "references": ["var.test_var"] - } - }, - "schema_version": 0 - } - ], - "variables": { - "test_var": { - "default": "bar-var" - } - } - } - }, - "module_test_foo": { - "source": { - "constant_value": "./foo" - }, - "expressions": { - "test_var": { - "constant_value": "baz" - } - }, - "module": { - "outputs": { - "test": { - "expression": { - "references": ["var.test_var"] - } - } - }, - "resources": [ - { - "address": "test_instance.test", - "mode": "managed", - "type": "test_instance", - "name": "test", - "provider_config_key": "module.module_test_foo:test", - "expressions": { - "ami": { - "references": ["var.test_var"] - } - }, - "schema_version": 0, - "count_expression": { - "constant_value": 3 - } - } - ], - "variables": { - "test_var": { - "default": "foo-var" - } - } - } - } - } }, - "provider_config": { - "module.module_test_foo:test": { - "module_address": "module.module_test_foo", - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" - }, - "module.module_test_bar:test": { - "module_address": "module.module_test_bar", - "name": "test", - "full_name": "registry.terraform.io/hashicorp/test" - } + "prior_state": { + "format_version": "1.0", + "values": { + "outputs": { + "test": { + "sensitive": false, + "type": "string", + "value": "baz" + } + }, + "root_module": {} + } + }, + "resource_changes": [ + { + "address": "module.module_test_bar.test_instance.test", + "module_address": "module.module_test_bar", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_name": "registry.terraform.io/hashicorp/test", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "bar-var" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[0]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 0, + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[1]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 1, + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + }, + { + "address": "module.module_test_foo.test_instance.test[2]", + "module_address": "module.module_test_foo", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "test", + "index": 2, + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "baz" + }, + "after_unknown": { + "id": true + }, + "after_sensitive": {}, + "before_sensitive": false + } + } + ], + "output_changes": { + "test": { + "actions": [ + "create" + ], + "before": null, + "after": "baz", + "after_unknown": false, + "before_sensitive": false, + "after_sensitive": false + } + }, + "configuration": { + "root_module": { + "outputs": { + "test": { + "expression": { + "references": [ + "module.module_test_foo.test", + "module.module_test_foo" + ] + }, + "depends_on": [ + "module.module_test_foo" + ] + } + }, + "module_calls": { + "module_test_bar": { + "source": "./bar", + "module": { + "outputs": { + "test": { + "expression": { + "references": [ + "var.test_var" + ] + } + } + }, + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "module.module_test_bar:test", + "expressions": { + "ami": { + "references": [ + "var.test_var" + ] + } + }, + "schema_version": 0 + } + ], + "variables": { + "test_var": { + "default": "bar-var" + } + } + } + }, + "module_test_foo": { + "source": "./foo", + "expressions": { + "test_var": { + "constant_value": "baz" + } + }, + "module": { + "outputs": { + "test": { + "expression": { + "references": [ + "var.test_var" + ] + } + } + }, + "resources": [ + { + "address": "test_instance.test", + "mode": "managed", + "type": "test_instance", + "name": "test", + "provider_config_key": "module.module_test_foo:test", + "expressions": { + "ami": { + "references": [ + "var.test_var" + ] + } + }, + "schema_version": 0, + "count_expression": { + "constant_value": 3 + } + } + ], + "variables": { + "test_var": { + "default": "foo-var" + } + } + } + } + } + }, + "provider_config": { + "module.module_test_foo:test": { + "module_address": "module.module_test_foo", + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + }, + "module.module_test_bar:test": { + "module_address": "module.module_test_bar", + "name": "test", + "full_name": "registry.terraform.io/hashicorp/test" + } + } } - } } diff --git a/internal/command/testdata/show-json/nested-modules/output.json b/internal/command/testdata/show-json/nested-modules/output.json index cd2d6d9a0c..f96a24484d 100644 --- a/internal/command/testdata/show-json/nested-modules/output.json +++ b/internal/command/testdata/show-json/nested-modules/output.json @@ -63,15 +63,11 @@ "root_module": { "module_calls": { "my_module": { - "source": { - "constant_value": "./modules" - }, + "source": "./modules", "module": { "module_calls": { "more": { - "source": { - "constant_value": "./more-modules" - }, + "source": "./more-modules", "module": { "resources": [ { diff --git a/internal/command/testdata/show-json/provider-aliasing-conflict/output.json b/internal/command/testdata/show-json/provider-aliasing-conflict/output.json index c30f2774c9..b516a4a564 100644 --- a/internal/command/testdata/show-json/provider-aliasing-conflict/output.json +++ b/internal/command/testdata/show-json/provider-aliasing-conflict/output.json @@ -48,7 +48,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "foo" @@ -68,7 +70,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp2/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "bar" @@ -116,9 +120,7 @@ ], "module_calls": { "child": { - "source": { - "constant_value": "./child" - }, + "source": "./child", "module": { "resources": [ { diff --git a/internal/command/testdata/show-json/provider-aliasing-default/output.json b/internal/command/testdata/show-json/provider-aliasing-default/output.json index 5ccdc089b6..f2639da510 100644 --- a/internal/command/testdata/show-json/provider-aliasing-default/output.json +++ b/internal/command/testdata/show-json/provider-aliasing-default/output.json @@ -84,7 +84,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "foo" @@ -104,7 +106,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "bar" @@ -124,7 +128,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "qux" @@ -144,7 +150,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "baz" @@ -197,9 +205,7 @@ ], "module_calls": { "child": { - "source": { - "constant_value": "./child" - }, + "source": "./child", "module": { "resources": [ { @@ -218,9 +224,7 @@ ], "module_calls": { "no_requirements": { - "source": { - "constant_value": "./nested-no-requirements" - }, + "source": "./nested-no-requirements", "module": { "resources": [ { @@ -240,9 +244,7 @@ } }, "with_requirement": { - "source": { - "constant_value": "./nested" - }, + "source": "./nested", "depends_on": ["module.no_requirements"], "module": { "resources": [ diff --git a/internal/command/testdata/show-json/provider-aliasing/output.json b/internal/command/testdata/show-json/provider-aliasing/output.json index bb6429adc8..9f5675036e 100755 --- a/internal/command/testdata/show-json/provider-aliasing/output.json +++ b/internal/command/testdata/show-json/provider-aliasing/output.json @@ -163,7 +163,9 @@ "name": "test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "foo" @@ -182,7 +184,9 @@ "name": "test_backup", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "foo-backup" @@ -202,7 +206,9 @@ "name": "test_primary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "primary" @@ -222,7 +228,9 @@ "name": "test_secondary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "secondary" @@ -242,7 +250,9 @@ "name": "test_primary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "primary" @@ -262,7 +272,9 @@ "name": "test_secondary", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "secondary" @@ -282,7 +294,9 @@ "name": "test_alternate", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "secondary" @@ -302,7 +316,9 @@ "name": "test_main", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "main" @@ -322,7 +338,9 @@ "name": "test_alternate", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "secondary" @@ -342,7 +360,9 @@ "name": "test_main", "provider_name": "registry.terraform.io/hashicorp/test", "change": { - "actions": ["create"], + "actions": [ + "create" + ], "before": null, "after": { "ami": "main" @@ -408,9 +428,7 @@ ], "module_calls": { "child": { - "source": { - "constant_value": "./child" - }, + "source": "./child", "module": { "resources": [ { @@ -442,9 +460,7 @@ ], "module_calls": { "grandchild": { - "source": { - "constant_value": "./nested" - }, + "source": "./nested", "module": { "resources": [ { @@ -480,9 +496,7 @@ } }, "sibling": { - "source": { - "constant_value": "./child" - }, + "source": "./child", "module": { "resources": [ { @@ -514,9 +528,7 @@ ], "module_calls": { "grandchild": { - "source": { - "constant_value": "./nested" - }, + "source": "./nested", "module": { "resources": [ { diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index 9692c8cb56..a14b417165 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -77,7 +77,7 @@ func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdia var version configs.VersionConstraint if n.ModuleCall.VersionExpr != nil { var versionDiags tfdiags.Diagnostics - version, versionDiags = decodeVersionConstraint(n.ModuleCall.VersionExpr, ctx) + version, versionDiags = evalVersionConstraint(n.ModuleCall.VersionExpr, ctx) diags = diags.Append(versionDiags) if diags.HasErrors() { return diags @@ -85,7 +85,7 @@ func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdia } hasVersion := n.ModuleCall.VersionExpr != nil - source, sourceDiags := decodeSource(n.ModuleCall.SourceExpr, hasVersion, ctx) + source, sourceRaw, sourceDiags := evalSource(n.ModuleCall.SourceExpr, hasVersion, ctx) diags = diags.Append(sourceDiags) if diags.HasErrors() { return diags @@ -115,6 +115,7 @@ func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdia Children: map[string]*configs.Config{}, CallRange: n.ModuleCall.DeclRange, SourceAddr: source, + SourceAddrRaw: sourceRaw, SourceAddrRange: n.ModuleCall.SourceExpr.Range(), Version: v, VersionConstraint: version, @@ -153,7 +154,7 @@ func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diag return &g, nil } -func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, tfdiags.Diagnostics) { +func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, string, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var addr addrs.ModuleSource var err error @@ -161,7 +162,7 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, sourceExpr) diags = diags.Append(refsDiags) if diags.HasErrors() { - return nil, diags + return nil, "", diags } for _, ref := range refs { @@ -175,14 +176,14 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( Detail: "The module source can only reference input variables and local values.", Subject: ref.SourceRange.ToHCL().Ptr(), }) - return nil, diags + return nil, "", diags } } value, valueDiags := ctx.EvaluateExpr(sourceExpr, cty.String, nil) diags = diags.Append(valueDiags) if diags.HasErrors() { - return nil, diags + return nil, "", diags } if !value.IsWhollyKnown() { @@ -194,20 +195,20 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( Detail: "The module source contains a reference that is unknown during init.", Subject: sourceExpr.Range().Ptr(), }) - return nil, diags + return nil, "", diags } for _, part := range tExpr.Parts { partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil) diags = diags.Append(partDiags) if diags.HasErrors() { - return nil, diags + return nil, "", diags } scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) hclCtx, evalDiags := scope.EvalContext(refs) diags = diags.Append(evalDiags) if diags.HasErrors() { - return nil, diags + return nil, "", diags } if !partVal.IsKnown() { diags = diags.Append(&hcl.Diagnostic{ @@ -219,7 +220,7 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( EvalContext: hclCtx, Extra: diagnosticCausedByUnknown(true), }) - return nil, diags + return nil, "", diags } } diags = diags.Append(&hcl.Diagnostic{ @@ -228,13 +229,14 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( Detail: "The module source contains a reference that is unknown.", Subject: sourceExpr.Range().Ptr(), }) - return nil, diags + return nil, "", diags } + rawSource := value.AsString() if hasVersion { - addr, err = moduleaddrs.ParseModuleSourceRegistry(value.AsString()) + addr, err = moduleaddrs.ParseModuleSourceRegistry(rawSource) } else { - addr, err = moduleaddrs.ParseModuleSource(value.AsString()) + addr, err = moduleaddrs.ParseModuleSource(rawSource) } if err != nil { // NOTE: We leave add as nil for any situation where the @@ -283,10 +285,10 @@ func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) ( } } - return addr, diags + return addr, rawSource, diags } -func decodeVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs.VersionConstraint, tfdiags.Diagnostics) { +func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs.VersionConstraint, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics rng := versionExpr.Range() From 45ba6796ba96278d590459715768cd566fa2c9db Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 27 Feb 2026 16:43:11 +0100 Subject: [PATCH 018/136] Add more dynamic module sources tests --- internal/command/init2_test.go | 700 ++++++++++++++++-- .../apply-plan-with-dynamic-source/main.tf | 9 + .../modules/example/main.tf | 3 + .../apply-with-dynamic-source/main.tf | 8 + .../modules/example/main.tf | 3 + .../count-in-module-source/main.tf | 4 + .../each-in-module-source/main.tf | 4 + .../source-module/main.tf | 8 + .../get-false-with-dynamic-source/main.tf | 8 + .../modules/example/empty.tf | 1 + .../local-source-with-local-value/main.tf | 12 + .../modules/example/empty.tf | 1 + .../main.tf | 7 + .../main.tf | 9 + .../modules/alternate/empty.tf | 0 .../modules/example/empty.tf | 1 + .../local-source-with-variable/main.tf | 8 + .../modules/example/empty.tf | 1 + .../local-source-with-varsfile/main.tf | 8 + .../modules/example/empty.tf | 1 + .../local-source-with-varsfile/test.tfvars | 1 + .../module-with-count/main.tf | 10 + .../module-with-count/modules/example/main.tf | 7 + .../module-with-for-each/main.tf | 10 + .../modules/example/main.tf | 7 + .../main.tf | 9 + .../modules/child/main.tf | 1 + .../modules/parent/main.tf | 8 + .../path-attr-in-module-source/main.tf | 3 + .../modules/example/empty.tf | 1 + .../plan-with-dynamic-source/main.tf | 8 + .../modules/example/main.tf | 3 + .../.terraform/modules/child/empty.tf | 1 + .../.terraform/modules/modules.json | 15 + .../plan-with-version-mismatch/main.tf | 15 + .../main.tf | 7 + .../modules/example/main.tf | 3 + .../source-with-resource-reference/main.tf | 5 + .../terraform-attr-in-module-source/main.tf | 3 + internal/terraform/node_module_install.go | 2 +- 40 files changed, 850 insertions(+), 65 deletions(-) create mode 100644 internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/alternate/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars create mode 100644 internal/command/testdata/dynamic-module-sources/module-with-count/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf create mode 100644 internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json create mode 100644 internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf create mode 100644 internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf diff --git a/internal/command/init2_test.go b/internal/command/init2_test.go index 9dcfcf50c4..5efc70ce3b 100644 --- a/internal/command/init2_test.go +++ b/internal/command/init2_test.go @@ -4,6 +4,7 @@ package command import ( + "os" "path/filepath" "strings" "testing" @@ -11,11 +12,197 @@ import ( "github.com/hashicorp/cli" ) -func TestInit2_versionConstraintAdded(t *testing.T) { - // This test is for what happens when there is a version constraint added - // to a module that previously didn't have one. +func TestInit2_dynamicSourceErrors(t *testing.T) { + tests := map[string]struct { + fixture string + args []string + wantError string + }{ + "version constraint added to previously unversioned module": { + fixture: "add-version-constraint", + args: []string{"-get=false"}, + wantError: "Module version requirements have changed", + }, + "invalid registry source with version argument": { + fixture: "invalid-registry-source-with-module", + wantError: "Invalid registry module source address", + }, + "local source with version argument": { + fixture: "local-source-with-version", + wantError: "Invalid registry module source address", + }, + "non-const variable in module source": { + fixture: "local-source-with-non-const-variable", + args: []string{"-var", "module_name=example"}, + wantError: "Invalid module source", + }, + "resource reference in module source": { + fixture: "source-with-resource-reference", + wantError: "Invalid module source", + }, + "module output reference in module source": { + fixture: "source-with-module-output-reference", + wantError: "Invalid module source", + }, + "each.key in module source": { + fixture: "each-in-module-source", + wantError: "Invalid module source", + }, + "count.index in module source": { + fixture: "count-in-module-source", + wantError: "Invalid module source", + }, + "terraform.workspace in module source": { + fixture: "terraform-attr-in-module-source", + wantError: "Invalid module source", + }, + "required const variable not set": { + fixture: "local-source-with-variable", + wantError: "No value for required variable", + }, + "override default with nonexistent module": { + fixture: "local-source-with-variable-default", + args: []string{"-var", "module_name=nonexistent"}, + wantError: "", // any error; the module directory doesn't exist + }, + "version mismatch with dynamic constraint": { + fixture: "plan-with-version-mismatch", + args: []string{"-get=false", "-var", "module_version=0.0.2"}, + wantError: "Module version requirements have changed", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", tc.fixture)), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run(tc.args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + if tc.wantError != "" { + got := testOutput.All() + if !strings.Contains(got, tc.wantError) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, tc.wantError) + } + } + }) + } +} + +func TestInit2_dynamicSourceSuccess(t *testing.T) { + tests := map[string]struct { + fixture string + args []string + }{ + "const variable via -var": { + fixture: "local-source-with-variable", + args: []string{"-var", "module_name=example"}, + }, + "const variable with default value": { + fixture: "local-source-with-variable-default", + }, + "local value referencing const variable": { + fixture: "local-source-with-local-value", + args: []string{"-var", "module_name=example"}, + }, + "nested module with variable passed through parent": { + fixture: "nested-module-with-variable-source", + args: []string{"-var", "child_name=child"}, + }, + "const variable from tfvars file": { + fixture: "local-source-with-varsfile", + args: []string{"-var-file", "test.tfvars"}, + }, + "path.module in module source": { + fixture: "path-attr-in-module-source", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", tc.fixture)), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run(tc.args) + testOutput := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + }) + } +} + +func TestInit2_getFalseWithDynamicSource(t *testing.T) { td := t.TempDir() - testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "add-version-constraint")), td) + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "get-false-with-dynamic-source")), td) + t.Chdir(td) + + // First, run init normally to install the module + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{"-var", "module_name=example"} + code := c.Run(args) + testOutput := done(t) + if code != 0 { + t.Fatalf("first init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Now run init with -get=false; should succeed since modules are already installed + ui2 := new(cli.MockUi) + view2, done2 := testView(t) + c2 := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui2, + View: view2, + }, + } + + args2 := []string{"-get=false", "-var", "module_name=example"} + code = c2.Run(args2) + testOutput2 := done2(t) + if code != 0 { + t.Fatalf("init -get=false failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput2.Stderr(), testOutput2.Stdout()) + } +} + +func TestInit2_getFalseWithDynamicSourceNotInstalled(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "get-false-with-dynamic-source")), td) t.Chdir(td) ui := new(cli.MockUi) @@ -28,74 +215,459 @@ func TestInit2_versionConstraintAdded(t *testing.T) { }, } - args := []string{"-get=false"} + // Run init with -get=false without having installed modules first + args := []string{"-get=false", "-var", "module_name=example"} code := c.Run(args) testOutput := done(t) if code != 1 { t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) } - got := testOutput.All() +} + +func TestInit2_reinitWithDifferentVariable(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "local-source-with-variable-default")), td) + t.Chdir(td) + + // First init with default variable (example) + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run([]string{}) + testOutput := done(t) + if code != 0 { + t.Fatalf("first init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Re-init with different variable + ui2 := new(cli.MockUi) + view2, done2 := testView(t) + c2 := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui2, + View: view2, + }, + } + + code = c2.Run([]string{"-var", "module_name=alternate"}) + testOutput2 := done2(t) + if code != 0 { + t.Fatalf("second init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput2.Stderr(), testOutput2.Stdout()) + } +} + +func TestInit2_fromModuleWithDynamicSource(t *testing.T) { + // TODO: -from-module currently panics when the copied configuration + // contains a dynamic module source (e.g. "./modules/${var.module_name}"). + t.Skip("skipping: -from-module panics on dynamic module sources (see TODO in from_module.go)") + + // Create an empty target directory for -from-module to copy into + td := t.TempDir() + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + // Use -from-module to copy the source module (which has a dynamic source) + // into the empty working directory. This should copy the files but the + // nested dynamic module won't be resolved by -from-module itself. + srcDir := testFixturePath(filepath.Join("dynamic-module-sources", "from-module-with-dynamic-source", "source-module")) + args := []string{"-from-module=" + srcDir} + code := c.Run(args) + testOutput := done(t) + + // -from-module should succeed in copying. The dynamic module source + // within the copied configuration won't be resolved yet — that requires + // a separate init with the variable value. + if code != 0 { + t.Fatalf("init -from-module failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Verify the main.tf was copied + if _, err := os.Stat(filepath.Join(td, "main.tf")); os.IsNotExist(err) { + t.Fatal("main.tf was not copied from the source module") + } +} + +func TestPlan_dynamicModuleSource(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "1 to add") { + t.Fatalf("expected plan to show 1 resource to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceMismatch(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan with a different variable value + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-var", "module_name=nonexistent"} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code == 0 { + t.Fatalf("expected plan to fail, but got exit status 0\nstdout:\n%s", planOutput.Stdout()) + } +} + +func TestApply_dynamicModuleSource(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "apply-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + applyUi := new(cli.MockUi) + applyView, applyDone := testView(t) + applyCmd := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: applyUi, + View: applyView, + }, + } + + applyArgs := []string{"-auto-approve", "-var", "module_name=example"} + code := applyCmd.Run(applyArgs) + applyOutput := applyDone(t) + if code != 0 { + t.Fatalf("apply failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, applyOutput.Stderr(), applyOutput.Stdout()) + } + + output := applyOutput.Stdout() + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to succeed, got:\n%s", output) + } +} + +func TestApply_dynamicModuleSourceWithDefaultPlanFile(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "apply-plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run([]string{}) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Run plan with -out + planPath := filepath.Join(td, "saved.plan") + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-out", planPath} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, planOutput.Stderr(), planOutput.Stdout()) + } + + // Verify the plan file was created + if _, err := os.Stat(planPath); os.IsNotExist(err) { + t.Fatalf("plan file was not created at %s", planPath) + } + + // Apply the saved plan + applyUi := new(cli.MockUi) + applyView, applyDone := testView(t) + applyCmd := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: applyUi, + View: applyView, + }, + } + + applyArgs := []string{planPath} + code = applyCmd.Run(applyArgs) + applyOutput := applyDone(t) + if code != 0 { + t.Fatalf("apply failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, applyOutput.Stderr(), applyOutput.Stdout()) + } + + output := applyOutput.Stdout() + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to succeed, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceWithCount(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "module-with-count")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "2 to add") { + t.Fatalf("expected plan to show 2 resources to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceWithForEach(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "module-with-for-each")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "2 to add") { + t.Fatalf("expected plan to show 2 resources to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleVersionMismatch(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-version-mismatch")), td) + t.Chdir(td) + + p := planFixtureProvider() + + // Plan should fail because the installed module version (0.0.1 in + // modules.json) doesn't satisfy the constraint we provide. + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-var", "module_version=0.0.2"} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code == 0 { + t.Fatalf("expected plan to fail, but got exit status 0\nstdout:\n%s", planOutput.Stdout()) + } + got := planOutput.All() want := "Module version requirements have changed" if !strings.Contains(got, want) { t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) } } - -func TestInit2_invalidRegistrySourceWithModule(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "invalid-registry-source-with-module")), td) - t.Chdir(td) - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, - View: view, - }, - } - - args := []string{} - code := c.Run(args) - testOutput := done(t) - if code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) - } - got := testOutput.All() - - want := "Invalid registry module source address" - if !strings.Contains(got, want) { - t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) - } -} - -func TestInit2_localSourceWithVersion(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "local-source-with-version")), td) - t.Chdir(td) - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, - View: view, - }, - } - - args := []string{} - code := c.Run(args) - testOutput := done(t) - if code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) - } - got := testOutput.All() - - want := "Invalid registry module source address" - if !strings.Contains(got, want) { - t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) - } -} diff --git a/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf new file mode 100644 index 0000000000..98de3a2672 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf new file mode 100644 index 0000000000..50f77309dc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf @@ -0,0 +1,4 @@ +module "example" { + count = 2 + source = "./modules/${count.index}" +} diff --git a/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf new file mode 100644 index 0000000000..53616a849c --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf @@ -0,0 +1,4 @@ +module "example" { + for_each = toset(["one", "two"]) + source = "./modules/${each.key}" +} diff --git a/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf b/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf new file mode 100644 index 0000000000..a4278dae8c --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf @@ -0,0 +1,12 @@ +variable "module_name" { + type = string + const = true +} + +locals { + module_path = "./modules/${var.module_name}" +} + +module "example" { + source = local.module_path +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf new file mode 100644 index 0000000000..836e88c627 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf @@ -0,0 +1,7 @@ +variable "module_name" { + type = string +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf new file mode 100644 index 0000000000..98de3a2672 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/alternate/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/alternate/empty.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars new file mode 100644 index 0000000000..ae980f90f1 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars @@ -0,0 +1 @@ +module_name = "example" diff --git a/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf new file mode 100644 index 0000000000..ccec44c408 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf @@ -0,0 +1,10 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" + count = 2 + number = count.index +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf new file mode 100644 index 0000000000..1855478473 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_instance" "example" { + ami = "bar" +} + +variable "number" { + type = number +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf new file mode 100644 index 0000000000..d50cd6e85b --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf @@ -0,0 +1,10 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" + for_each = toset(["a", "b"]) + letter = each.value +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf new file mode 100644 index 0000000000..9dbbae2569 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_instance" "example" { + ami = "bar" +} + +variable "letter" { + type = string +} diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf new file mode 100644 index 0000000000..b0db6a9f5e --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf @@ -0,0 +1,9 @@ +variable "child_name" { + type = string + const = true +} + +module "parent" { + source = "./modules/parent" + child_name = var.child_name +} diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf new file mode 100644 index 0000000000..048ba194cc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf @@ -0,0 +1 @@ +# Empty child module used by dynamic-module-sources tests diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf new file mode 100644 index 0000000000..6a81115838 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf @@ -0,0 +1,8 @@ +variable "child_name" { + type = string + const = true +} + +module "child" { + source = "../${var.child_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf new file mode 100644 index 0000000000..855ccd0aef --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf @@ -0,0 +1,3 @@ +module "example" { + source = "${path.module}/modules/example" +} diff --git a/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json new file mode 100644 index 0000000000..6b52e103bc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json @@ -0,0 +1,15 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "" + }, + { + "Key": "child", + "Source": "hashicorp/module-installer-acctest/aws", + "Version": "0.0.1", + "Dir": ".terraform/modules/child" + } + ] +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf new file mode 100644 index 0000000000..766cf9987f --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf @@ -0,0 +1,15 @@ +# This fixture tests that plan detects a version mismatch when the dynamic +# version constraint changes between init and plan. +# +# The pre-populated .terraform/modules/modules.json records version 0.0.1 +# but the configuration requires a version determined by the const variable. + +variable "module_version" { + type = string + const = true +} + +module "child" { + source = "hashicorp/module-installer-acctest/aws" + version = var.module_version +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf new file mode 100644 index 0000000000..355f9c1001 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf @@ -0,0 +1,7 @@ +module "example" { + source = "./modules/example" +} + +module "example2" { + source = "./modules/${module.example.name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf new file mode 100644 index 0000000000..a3e6a00391 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf @@ -0,0 +1,3 @@ +output "name" { + value = "example" +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf new file mode 100644 index 0000000000..113df8bd34 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf @@ -0,0 +1,5 @@ +resource "test_instance" "example" {} + +module "example" { + source = "./modules/${test_instance.example.id}" +} diff --git a/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf new file mode 100644 index 0000000000..8757a3094e --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf @@ -0,0 +1,3 @@ +module "example" { + source = "./modules/${terraform.workspace}" +} diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index a14b417165..59982538c7 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -167,7 +167,7 @@ func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (ad for _, ref := range refs { switch ref.Subject.(type) { - case addrs.InputVariable, addrs.LocalValue: + case addrs.InputVariable, addrs.LocalValue, addrs.PathAttr: // These are allowed default: diags = diags.Append(&hcl.Diagnostic{ From ee8f7abc040224e1997b7575b89a87a99f656732 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 3 Mar 2026 16:45:22 +0100 Subject: [PATCH 019/136] Add snapshot-related test for the new graph-based config loader This ensures we test snapshot related configuration loading. These tests previously lived in the `configload` package. --- internal/terraform/config_graph_build_test.go | 166 ++++++++++++++++++ .../.terraform/modules/modules.json | 7 + .../foo/bar/main.tf | 3 + .../already-installed-now-invalid/foo/main.tf | 3 + .../already-installed-now-invalid/root.tf | 3 + .../.terraform/modules/child_a/child_a.tf | 4 + .../modules/child_a/child_c/child_c.tf | 4 + .../modules/child_b.child_d/child_d.tf | 4 + .../.terraform/modules/child_b/child_b.tf | 5 + .../.terraform/modules/modules.json | 32 ++++ .../config-graph/already-installed/root.tf | 10 ++ 11 files changed, 241 insertions(+) create mode 100644 internal/terraform/config_graph_build_test.go create mode 100644 internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json create mode 100644 internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf create mode 100644 internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json create mode 100644 internal/terraform/testdata/config-graph/already-installed/root.tf diff --git a/internal/terraform/config_graph_build_test.go b/internal/terraform/config_graph_build_test.go new file mode 100644 index 0000000000..1853ba3e5a --- /dev/null +++ b/internal/terraform/config_graph_build_test.go @@ -0,0 +1,166 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/go-test/deep" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestLoadConfigWithSnapshot(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, got, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if got == nil { + t.Fatalf("snapshot is nil; want non-nil") + } + + t.Log(spew.Sdump(got)) + + { + gotModuleDirs := map[string]string{} + for k, m := range got.Modules { + gotModuleDirs[k] = m.Dir + } + wantModuleDirs := map[string]string{ + "": "testdata/config-graph/already-installed", + "child_a": "testdata/config-graph/already-installed/.terraform/modules/child_a", + "child_a.child_c": "testdata/config-graph/already-installed/.terraform/modules/child_a/child_c", + "child_b": "testdata/config-graph/already-installed/.terraform/modules/child_b", + "child_b.child_d": "testdata/config-graph/already-installed/.terraform/modules/child_b.child_d", + } + + problems := deep.Equal(wantModuleDirs, gotModuleDirs) + for _, problem := range problems { + t.Error(problem) + } + if len(problems) > 0 { + return + } + } + + gotRoot := got.Modules[""] + wantRoot := &configload.SnapshotModule{ + Dir: "testdata/config-graph/already-installed", + Files: map[string][]byte{ + "root.tf": []byte(` +module "child_a" { + source = "example.com/foo/bar_a/baz" + version = ">= 1.0.0" +} + +module "child_b" { + source = "example.com/foo/bar_b/baz" + version = ">= 1.0.0" +} +`), + }, + } + if !reflect.DeepEqual(gotRoot, wantRoot) { + t.Errorf("wrong root module snapshot\ngot: %swant: %s", spew.Sdump(gotRoot), spew.Sdump(wantRoot)) + } + +} + +func TestLoadConfigWithSnapshot_invalidSource(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed-now-invalid") + + old, _ := os.Getwd() + os.Chdir(fixtureDir) + defer os.Chdir(old) + + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: ".terraform/modules", + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, _, diags := testLoadWithSnapshot(".", loader, nil) + if !diags.HasErrors() { + t.Error("LoadConfigWithSnapshot succeeded; want errors") + } +} + +func TestSnapshotRoundtrip(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, snap, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if snap == nil { + t.Fatalf("snapshot is nil; want non-nil") + } + + snapLoader := configload.NewLoaderFromSnapshot(snap) + if loader == nil { + t.Fatalf("loader is nil; want non-nil") + } + rootMod, rootDiags := snapLoader.LoadRootModule(snap.Modules[""].Dir) + assertNoDiagnostics(t, rootDiags) + + config, diags := BuildConfigWithGraph( + rootMod, + snapLoader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(snapLoader.LoadExternalMockData), + ) + assertNoDiagnostics(t, diags) + if config == nil { + t.Fatalf("config is nil; want non-nil") + } + if config.Module == nil { + t.Fatalf("config has no root module") + } + if got, want := config.Module.SourceDir, "testdata/config-graph/already-installed"; got != want { + t.Errorf("wrong root module sourcedir %q; want %q", got, want) + } + if got, want := len(config.Module.ModuleCalls), 2; got != want { + t.Errorf("wrong number of module calls in root module %d; want %d", got, want) + } + childA := config.Children["child_a"] + if childA == nil { + t.Fatalf("child_a config is nil; want non-nil") + } + if childA.Module == nil { + t.Fatalf("child_a config has no module") + } + if got, want := childA.Module.SourceDir, "testdata/config-graph/already-installed/.terraform/modules/child_a"; got != want { + t.Errorf("wrong child_a sourcedir %q; want %q", got, want) + } + if got, want := len(childA.Module.ModuleCalls), 1; got != want { + t.Errorf("wrong number of module calls in child_a %d; want %d", got, want) + } +} + +func assertNoDiagnostics[D hcl.Diagnostics | tfdiags.Diagnostics](t *testing.T, diags D) bool { + t.Helper() + + if len(diags) != 0 { + t.Errorf("wrong number of diagnostics %d; want %d", len(diags), 0) + return true + } + return false +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json new file mode 100644 index 0000000000..a09a3f4826 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json @@ -0,0 +1,7 @@ +{ + "Modules": [ + { "Key": "", "Source": "", "Dir": "." }, + { "Key": "foo", "Source": "./foo", "Dir": "foo" }, + { "Key": "foo.bar", "Source": "./bar", "Dir": "foo/bar" } + ] +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf new file mode 100644 index 0000000000..48b5e2e067 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf @@ -0,0 +1,3 @@ +output "hello" { + value = "Hello from foo/bar" +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf new file mode 100644 index 0000000000..9fba57235c --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf @@ -0,0 +1,3 @@ +module "bar" { + source = "${path.module}/bar" +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf new file mode 100644 index 0000000000..020494e84d --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf @@ -0,0 +1,3 @@ +module "foo" { + source = "./foo" +} diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf new file mode 100644 index 0000000000..2f4d0f1a0b --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf @@ -0,0 +1,4 @@ + +module "child_c" { + source = "./child_c" +} diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf new file mode 100644 index 0000000000..785d98d98a --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf @@ -0,0 +1,4 @@ + +output "hello" { + value = "Hello from child_c" +} diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf new file mode 100644 index 0000000000..145576a365 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf @@ -0,0 +1,4 @@ + +output "hello" { + value = "Hello from child_d" +} diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf new file mode 100644 index 0000000000..4a1b247d39 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf @@ -0,0 +1,5 @@ + +module "child_d" { + source = "example.com/foo/bar_d/baz" + # Intentionally no version here +} diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json new file mode 100644 index 0000000000..957a8aebed --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json @@ -0,0 +1,32 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "testdata/config-graph/already-installed" + }, + { + "Key": "child_a", + "Source": "example.com/foo/bar_a/baz", + "Version": "1.0.1", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_a" + }, + { + "Key": "child_b", + "Source": "example.com/foo/bar_b/baz", + "Version": "1.0.0", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_b" + }, + { + "Key": "child_a.child_c", + "Source": "./child_c", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_a/child_c" + }, + { + "Key": "child_b.child_d", + "Source": "example.com/foo/bar_d/baz", + "Version": "1.2.0", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_b.child_d" + } + ] +} diff --git a/internal/terraform/testdata/config-graph/already-installed/root.tf b/internal/terraform/testdata/config-graph/already-installed/root.tf new file mode 100644 index 0000000000..8a4473942d --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/root.tf @@ -0,0 +1,10 @@ + +module "child_a" { + source = "example.com/foo/bar_a/baz" + version = ">= 1.0.0" +} + +module "child_b" { + source = "example.com/foo/bar_b/baz" + version = ">= 1.0.0" +} From 6f8592eee472ff4cb9f7c40ddf77712c4ef6c531 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 3 Mar 2026 16:52:38 +0100 Subject: [PATCH 020/136] Make dynamic reference error message more precise --- internal/terraform/context_init_test.go | 6 +++--- internal/terraform/node_module_install.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index 5e532dff3b..9bf78943be 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -235,7 +235,7 @@ module "example" { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid module source`, - Detail: `The module source can only reference input variables and local values.`, + Detail: `The module source can only reference constant input variables and local values.`, Subject: &hcl.Range{ Filename: filepath.Join(m.SourceDir, "main.tf"), Start: hcl.Pos{Line: 4, Column: 31, Byte: 95}, @@ -317,7 +317,7 @@ module "example" { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The module source can only reference input variables and local values.", + Detail: "The module source can only reference constant input variables and local values.", Subject: &hcl.Range{ Filename: filepath.Join(m.SourceDir, "main.tf"), Start: hcl.Pos{Line: 5, Column: 33, Byte: 91}, @@ -402,7 +402,7 @@ output "id" { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The module source can only reference input variables and local values.", + Detail: "The module source can only reference constant input variables and local values.", Subject: &hcl.Range{ Filename: filepath.Join(m.SourceDir, "main.tf"), Start: hcl.Pos{Line: 7, Column: 33, Byte: 107}, diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index 59982538c7..be5c5cc9a1 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -173,7 +173,7 @@ func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (ad diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The module source can only reference input variables and local values.", + Detail: "The module source can only reference constant input variables and local values.", Subject: ref.SourceRange.ToHCL().Ptr(), }) return nil, "", diags @@ -310,7 +310,7 @@ func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module version", - Detail: "The module version can only reference input variables and local values.", + Detail: "The module version can only reference constant input variables and local values.", Subject: ref.SourceRange.ToHCL().Ptr(), }) return ret, diags From 07b244b711efd1623232edb410c126c7ded1543f Mon Sep 17 00:00:00 2001 From: hc-github-team-tf-core <82990137+hc-github-team-tf-core@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:08:25 +0000 Subject: [PATCH 021/136] Prepare before 1.15.0-alpha20260304 release (#38232) --- CHANGELOG.md | 17 +++++++++++++++-- version/VERSION | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbab117451..294db16e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 1.15.0 (Unreleased) +## 1.15.0-alpha20260304 (March 04, 2026) NEW FEATURES: @@ -7,10 +7,14 @@ NEW FEATURES: * You can set a `deprecated` attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output. ([#38001](https://github.com/hashicorp/terraform/issues/38001)) -* backend/s3: Support authentication via `aws login` ([#37967](https://github.com/hashicorp/terraform/issues/37967)) +* backend/s3: Support authentication via `aws login` ([#37976](https://github.com/hashicorp/terraform/issues/37976)) * validate: The validate command now checks the `backend` block. This ensures the backend type exists, that all required attributes are present, and that the backend's own validation logic passes. ([#38021](https://github.com/hashicorp/terraform/issues/38021)) +* `convert` function, which allows for precise inline type conversions ([#38160](https://github.com/hashicorp/terraform/issues/38160)) + +* Terraform now supports variables and locals in module source and version attributes ([#38217](https://github.com/hashicorp/terraform/issues/38217)) + ENHANCEMENTS: @@ -24,6 +28,10 @@ ENHANCEMENTS: * improve detection of deprecated resource attributes / blocks ([#38077](https://github.com/hashicorp/terraform/issues/38077)) +* Deprecation messages providers set on resources / blocks / attributes are now part of the deprecation warning ([#38135](https://github.com/hashicorp/terraform/issues/38135)) + +* Include which attribute paths are marked as sensitive in list_start JSON logs ([#38197](https://github.com/hashicorp/terraform/issues/38197)) + BUG FIXES: @@ -52,6 +60,11 @@ BUG FIXES: * states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations. ([#38181](https://github.com/hashicorp/terraform/issues/38181)) +NOTES: + +* command/init: Provider installation was refactored to enable future enhancements in the area. This results in different order of operations during init and 2 new log messages replacing one (`initializing_provider_plugin_message`). The change should not have any end-user impact aside from the `init` command output. ([#38227](https://github.com/hashicorp/terraform/issues/38227)) + + UPGRADE NOTES: * backend/s3: The `AWS_USE_FIPS_ENDPOINT` and `AWS_USE_DUALSTACK_ENDPOINT` environment variables now only respect `true` or `false` values, aligning with the AWS SDK for Go. This replaces the previous behavior which treated any non-empty value as `true`. ([#37601](https://github.com/hashicorp/terraform/issues/37601)) diff --git a/version/VERSION b/version/VERSION index 9a4866bbce..8065d7dd87 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.0-dev +1.15.0-alpha20260304 From c0b0e1e0ef5704bdde1500a28402e7f07726be59 Mon Sep 17 00:00:00 2001 From: hc-github-team-tf-core <82990137+hc-github-team-tf-core@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:27:26 +0000 Subject: [PATCH 022/136] Cleanup after 1.15.0-alpha20260304 release (#38234) --- CHANGELOG.md | 2 +- version/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 294db16e85..3400550920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 1.15.0-alpha20260304 (March 04, 2026) +## 1.15.0 (Unreleased) NEW FEATURES: diff --git a/version/VERSION b/version/VERSION index 8065d7dd87..9a4866bbce 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.0-alpha20260304 +1.15.0-dev From ec5959f80afa04630c3c993bed4e04d768d17e6a Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Wed, 4 Mar 2026 17:19:46 -0500 Subject: [PATCH 023/136] improvements --- internal/configs/checks.go | 6 +- internal/configs/named_values.go | 4 +- internal/configs/resource.go | 6 +- internal/configs/test_file.go | 2 +- internal/stacks/stackconfig/checks.go | 72 +++ internal/stacks/stackconfig/input_variable.go | 5 +- .../internal/stackeval/input_variable.go | 186 +++--- .../internal/stackeval/input_variable_test.go | 610 ++++++++++++++++++ .../validation/validation.tfcomponent.hcl | 43 ++ ...lidation-provider-function.tfcomponent.hcl | 25 + internal/stacks/stackruntime/plan_test.go | 79 +++ ...tion-invalid-error-message.tfcomponent.hcl | 68 ++ ...idation-provider-functions.tfcomponent.hcl | 45 ++ 13 files changed, 1067 insertions(+), 84 deletions(-) create mode 100644 internal/stacks/stackconfig/checks.go create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl diff --git a/internal/configs/checks.go b/internal/configs/checks.go index 2e5263c4c9..d4b1250ee8 100644 --- a/internal/configs/checks.go +++ b/internal/configs/checks.go @@ -80,14 +80,14 @@ func (cr *CheckRule) validateSelfReferences(checkType string, addr addrs.Resourc return diags } -// DecodeCheckRuleBlock decodes the contents of the given block as a check rule. +// decodeCheckRuleBlock decodes the contents of the given block as a check rule. // // Unlike most of our "decode..." functions, this one can be applied to blocks // of various types as long as their body structures are "check-shaped". The // function takes the containing block only because some error messages will // refer to its location, and the returned object's DeclRange will be the // block's header. -func DecodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { +func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) { var diags hcl.Diagnostics cr := &CheckRule{ DeclRange: block.DefRange, @@ -230,7 +230,7 @@ func decodeCheckBlock(block *hcl.Block, override bool) (*Check, hcl.Diagnostics) check.DataResource = data } case "assert": - assert, moreDiags := DecodeCheckRuleBlock(block, override) + assert, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) if !moreDiags.HasErrors() { check.Asserts = append(check.Asserts, assert) diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index d916dc4ed5..bd5dedec63 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -212,7 +212,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno switch block.Type { case "validation": - vv, moreDiags := DecodeCheckRuleBlock(block, override) + vv, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) diags = append(diags, checkVariableValidationBlock(v.Name, vv)...) @@ -443,7 +443,7 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic for _, block := range content.Blocks { switch block.Type { case "precondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) o.Preconditions = append(o.Preconditions, cr) case "postcondition": diff --git a/internal/configs/resource.go b/internal/configs/resource.go index a8fd88e3de..8f4276a61b 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -285,7 +285,7 @@ func decodeResourceBlock(block *hcl.Block, override bool, allowExperiments bool) for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -497,7 +497,7 @@ func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagn for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) @@ -673,7 +673,7 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di for _, block := range lcContent.Blocks { switch block.Type { case "precondition", "postcondition": - cr, moreDiags := DecodeCheckRuleBlock(block, override) + cr, moreDiags := decodeCheckRuleBlock(block, override) diags = append(diags, moreDiags...) moreDiags = cr.validateSelfReferences(block.Type, r.Addr()) diff --git a/internal/configs/test_file.go b/internal/configs/test_file.go index d378008109..2456bf8933 100644 --- a/internal/configs/test_file.go +++ b/internal/configs/test_file.go @@ -697,7 +697,7 @@ func decodeTestRunBlock(block *hcl.Block, file *TestFile, experimentsAllowed boo for _, block := range content.Blocks { switch block.Type { case "assert": - cr, crDiags := DecodeCheckRuleBlock(block, false) + cr, crDiags := decodeCheckRuleBlock(block, false) diags = append(diags, crDiags...) if !crDiags.HasErrors() { r.CheckRules = append(r.CheckRules, cr) diff --git a/internal/stacks/stackconfig/checks.go b/internal/stacks/stackconfig/checks.go new file mode 100644 index 0000000000..e4afb6dc1d --- /dev/null +++ b/internal/stacks/stackconfig/checks.go @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackconfig + +import "github.com/hashicorp/hcl/v2" + +// CheckRule represents a custom validation rule for a stack input variable. +// +// This is the stacks-specific equivalent of configs.CheckRule in the core +// Terraform package. It is intentionally duplicated here to maintain +// separation between stacks and core Terraform, allowing each to evolve +// independently. +type CheckRule struct { + // Condition is an expression that must evaluate to true if the validation + // passes, or false if it fails. The expression may only refer to the + // variable being validated (via var.). + Condition hcl.Expression + + // ErrorMessage is an expression that evaluates to the error message shown + // to the user when the condition is false. It must evaluate to a string. + ErrorMessage hcl.Expression + + DeclRange hcl.Range +} + +// decodeCheckRuleBlock decodes a validation block for stack input variables. +// This is duplicated from the core configs package to maintain separation between +// stacks and core Terraform, allowing each to evolve independently. +func decodeCheckRuleBlock(block *hcl.Block) (*CheckRule, hcl.Diagnostics) { + var diags hcl.Diagnostics + cr := &CheckRule{ + DeclRange: block.DefRange, + } + + content, hclDiags := block.Body.Content(checkRuleBlockSchema) + diags = append(diags, hclDiags...) + + if attr, exists := content.Attributes["condition"]; exists { + cr.Condition = attr.Expr + + if len(cr.Condition.Variables()) == 0 { + // A condition expression that doesn't refer to any variable is + // pointless, because its result would always be a constant. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid validation expression", + Detail: "The condition expression must refer to at least one object from elsewhere in the configuration, or else its result would not be checking anything.", + Subject: cr.Condition.Range().Ptr(), + }) + } + } + + if attr, exists := content.Attributes["error_message"]; exists { + cr.ErrorMessage = attr.Expr + } + + return cr, diags +} + +var checkRuleBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "condition", + Required: true, + }, + { + Name: "error_message", + Required: true, + }, + }, +} diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index ebd27feacf..6f8fbb7a72 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -7,7 +7,6 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" @@ -29,7 +28,7 @@ type InputVariable struct { // that provided values meet the specified constraints. // Each CheckRule includes a condition expression that must evaluate to true, // and an error message to display if the validation fails. - Validations []*configs.CheckRule + Validations []*CheckRule DeclRange tfdiags.SourceRange } @@ -103,7 +102,7 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos // Decode the validation block into a CheckRule structure. // This only validates the syntax and structure of the validation block itself, // not the actual runtime validation of input values. - vv, hclDiags := configs.DecodeCheckRuleBlock(block, false) + vv, hclDiags := decodeCheckRuleBlock(block) diags = diags.Append(hclDiags) // Only add the validation rule if it was successfully parsed. // If there were errors (e.g., missing condition or error_message), diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 2d0be06b52..ebcdc5037f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -12,13 +12,13 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/objchange" "github.com/hashicorp/terraform/internal/promising" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" "github.com/hashicorp/terraform/internal/tfdiags" @@ -147,10 +147,7 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // First, apply any defaults that are declared in the - // configuration. - - // Next, convert the value to the expected type. + // Convert the value to the expected type. val, err = convert.Convert(val, wantTy) if err != nil { diags = diags.Append(&hcl.Diagnostic{ @@ -189,17 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } + // Mark the value before validation so that validation can detect + // sensitive/ephemeral marks and avoid leaking protected values in error messages. + val = cfg.markValue(val) + // Evaluate custom validation rules against the input value. - // Validation is skipped during ValidatePhase because: - // 1. Input variable values are not available during validate (only during plan/apply) - // 2. Validation conditions may reference resources or other runtime values + // Validation is skipped during ValidatePhase because actual input values + // are not yet available at that phase — only plan/apply provide them. // This matches the behavior of core Terraform's variable validation. if phase != ValidatePhase { moreDiags := v.evalVariableValidations(ctx, val, phase) diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags default: definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase) @@ -208,6 +208,9 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va allVals := definedByCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) + // Evaluate custom validation rules for values from stack call instances. // Skip during ValidatePhase as values are not yet available. if phase != ValidatePhase { @@ -215,19 +218,22 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags case definedByRemovedCallInst != nil: allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // Evaluate validation rules even for removed stack instances. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) + + // Evaluate validation rules for removed stack instances. // Skip during ValidatePhase as values are not yet available. if phase != ValidatePhase { moreDiags := v.evalVariableValidations(ctx, val, phase) diags = diags.Append(moreDiags) } - return cfg.markValue(val), diags + return val, diags default: // We seem to belong to a call instance that doesn't actually // exist in the configuration. That either means that @@ -391,9 +397,11 @@ func (v *InputVariable) tracingName() string { // during config loading; this function evaluates those rules against actual input values. // // The validation process: -// 1. Creates an HCL evaluation context with the variable's value and available functions -// 2. Evaluates each validation rule's condition expression -// 3. If the condition returns false, evaluates the error_message and reports a diagnostic +// 1. Creates an HCL evaluation context with the variable's value and available functions +// 2. Evaluates each validation rule's condition and error_message expressions +// 3. Always validates the error_message structure (sensitive/ephemeral marks are flagged +// regardless of whether the condition passes or fails) +// 4. If the condition is false, reports an "Invalid value for variable" diagnostic // // This follows the same approach as core Terraform's evalVariableValidations, including // handling of sensitive values, unknown values, and error message evaluation. @@ -406,25 +414,29 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val return diags } - // Get the available functions from the stack scope. - // This allows validation conditions to use built-in functions like length(), regex(), etc. + // Get provider-defined functions from the stack scope. + // These will be combined with built-in functions below. functions, moreDiags := v.stack.ExternalFunctions(ctx) diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // If we can't get the function table, we can't evaluate validation expressions + // that depend on functions. Return early to avoid confusing downstream errors. + return diags + } - // Create a scope to get the function table. - // We don't need a full evaluation context, just the functions. + // Create a scope to get the complete function table (provider-defined + built-in). + // fakeScope.Functions() will combine the provider functions with built-in functions + // like length(), regex(), etc. We don't need a full evaluation context, just the functions. fakeScope := &lang.Scope{ Data: nil, // not a real scope; can't actually make an evalcontext BaseDir: ".", PureOnly: phase != ApplyPhase, - ConsoleMode: false, PlanTimestamp: v.stack.PlanTimestamp(), ExternalFuncs: functions, } // Create an HCL evaluation context with the variable value and functions. // The variable is made available as var. within validation expressions. - // This mirrors how validation conditions are evaluated in core Terraform. hclCtx := &hcl.EvalContext{ Variables: map[string]cty.Value{ "var": cty.ObjectVal(map[string]cty.Value{ @@ -449,24 +461,28 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val // This function handles the evaluation of one validation block's condition and error_message. // It follows the same logic as core Terraform's variable validation: // -// 1. Evaluates the condition expression -// 2. Handles unknown/null/invalid results appropriately -// 3. If condition is false, evaluates the error_message -// 4. Checks for sensitive/ephemeral values in error messages -// 5. Constructs a diagnostic with the error message and validation rule location +// 1. Evaluates the condition and error_message expressions up front +// 2. Handles unknown/null/invalid condition results appropriately +// 3. Always validates the error_message structure — sensitive/ephemeral marks +// in the message are flagged even when the condition passes +// 4. Returns early if the condition passes (after reporting any message issues) +// 5. Otherwise constructs an "Invalid value for variable" diagnostic // // Parameters: // - validation: The validation rule to evaluate (contains condition and error_message expressions) // - hclCtx: The HCL evaluation context with the variable value and functions // - valueRng: The source range of the variable declaration (for diagnostic reporting) -func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { +func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { const errInvalidCondition = "Invalid variable validation result" const errInvalidValue = "Invalid value for variable" var diags tfdiags.Diagnostics - // Evaluate the validation condition expression + // Evaluate both condition and error message up front. The error message is + // always inspected for structural problems (sensitive/ephemeral marks), not only + // when the condition fails. result, moreDiags := validation.Condition.Value(hclCtx) diags = diags.Append(moreDiags) + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) if moreDiags.HasErrors() { // If we couldn't evaluate the condition at all (syntax error, etc.), @@ -495,7 +511,8 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte } // Convert result to boolean - result, err := convert.Convert(result, cty.Bool) + var err error + result, err = convert.Convert(result, cty.Bool) if err != nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -512,66 +529,91 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte // The marks don't affect the validation result, only how we handle the error message. result, _ = result.Unmark() - // If the condition evaluated to true, the validation passed. - if result.True() { - return diags - } - - // Validation failed - now evaluate the error_message to show to the user. - errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) + // Always process and validate the error_message expression, even when the condition + // passes — an invalid error message (sensitive, ephemeral, non-string, etc.) should + // be flagged regardless of whether the check succeeds or fails. diags = diags.Append(errorDiags) var errorMessage string - if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() { - errorValue, err := convert.Convert(errorValue, cty.String) - if err != nil { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid error message", - Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), - Subject: validation.ErrorMessage.Range().Ptr(), - Expression: validation.ErrorMessage, - EvalContext: hclCtx, - }) - errorMessage = "Failed to evaluate condition error message." - } else { - // Check for sensitive/ephemeral marks - if marks.Has(errorValue, marks.Sensitive) { + if !errorDiags.HasErrors() { + if !errorValue.IsKnown() { + if !result.True() { + // An unknown error message is only a problem when the condition actually + // fails, since we need to display it to the user. diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Error message refers to sensitive values", - Detail: "The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.", - Subject: validation.ErrorMessage.Range().Ptr(), + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.", + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, }) - errorMessage = "The error message included a sensitive value, so it will not be displayed." - } else if marks.Has(errorValue, marks.Ephemeral) { + return diags + } + } else if !errorValue.IsNull() { + errorValue, err = convert.Convert(errorValue, cty.String) + if err != nil { diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Error message refers to ephemeral values", - Detail: "The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.", - Subject: validation.ErrorMessage.Range().Ptr(), + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, }) - errorMessage = "The error message included an ephemeral value, so it will not be displayed." } else { - errorMessage = strings.TrimSpace(errorValue.AsString()) + // Check for sensitive/ephemeral marks; these are flagged even when + // the condition passes, since the error message is structurally invalid. + if marks.Has(errorValue, marks.Sensitive) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to sensitive values", + Detail: `The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message. + +You can correct this by removing references to sensitive values, or by carefully using the nonsensitive() function if the expression will not reveal the sensitive data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included a sensitive value, so it will not be displayed." + } else if marks.Has(errorValue, marks.Ephemeral) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to ephemeral values", + Detail: `The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message. + +You can correct this by removing references to ephemeral values, or by carefully using the ephemeralasnull() function if the expression will not reveal the ephemeral data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included an ephemeral value, so it will not be displayed." + } else { + errorMessage = strings.TrimSpace(errorValue.AsString()) + } } } - } else { + } + if errorMessage == "" { errorMessage = "Failed to evaluate condition error message." } + // If the condition evaluated to true, the validation passed. We've validated + // the error message above, so any structural issues are already reported. + if result.True() { + return diags + } + // Construct the validation failure diagnostic. // The detail includes both the custom error message and a reference to where // the validation rule is defined, helping users locate the validation in their config. - detail := fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", - errorMessage, - validation.DeclRange.String()) - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: errInvalidValue, - Detail: detail, - Subject: &valueRng, + Severity: hcl.DiagError, + Summary: errInvalidValue, + Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()), + Subject: &valueRng, + Expression: validation.Condition, + EvalContext: hclCtx, }) return diags diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go index 67a69ccbff..2288c62b24 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -5,10 +5,15 @@ package stackeval import ( "context" + "fmt" + "strings" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hcltest" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/encoding/prototext" @@ -17,9 +22,13 @@ import ( "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/promising" + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" + "github.com/hashicorp/terraform/internal/tfdiags" ) func TestInputVariableValue(t *testing.T) { @@ -487,3 +496,604 @@ func TestInputVariablePlanChanges(t *testing.T) { }) } } + +// TestEvalVariableValidation tests the evalVariableValidation function directly, +// covering all the "invalid" cases: sensitive/ephemeral values in the error message, +// unknown/null condition results, and unknown error messages. These tests +// exercise the logic independently of the full stack-evaluator machinery. +func TestEvalVariableValidation(t *testing.T) { + // parseExpr parses a real HCL expression from a source string. + parseExpr := func(t *testing.T, src string) hcl.Expression { + t.Helper() + expr, diags := hclsyntax.ParseExpression([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse expression %q: %s", src, diags.Error()) + } + return expr + } + + // makeFakeRule builds a minimal stackconfig.CheckRule from two expressions. + makeFakeRule := func(condition, errorMessage hcl.Expression) *stackconfig.CheckRule { + return &stackconfig.CheckRule{ + Condition: condition, + ErrorMessage: errorMessage, + DeclRange: hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 2, Column: 1}, + End: hcl.Pos{Line: 5, Column: 1}, + }, + } + } + + // makeVarCtx builds an HCL evaluation context that exposes var.foo = val. + makeVarCtx := func(val cty.Value) *hcl.EvalContext { + return &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "foo": val, + }), + }, + } + } + + valueRange := hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 10}, + } + + // --- Basic pass/fail --- + + t.Run("condition passes, clean message → no diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition fails, clean message → Invalid value for variable", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Value must be 'good'.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Sensitive error message --- + + t.Run("condition passes, sensitive error_message → flagged even on success", func(t *testing.T) { + // The error_message evaluates to a sensitive string even though the + // condition passes. This structural problem must always be reported. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, sensitive error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Ephemeral error message --- + + t.Run("condition passes, ephemeral error_message → flagged even on success", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, ephemeral error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Unknown / null condition results --- + + t.Run("condition result unknown → no diagnostics", func(t *testing.T) { + // Unknown condition means we cannot determine validity yet; skip quietly. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.UnknownVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.UnknownVal(cty.String)), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition result null → Invalid variable validation result", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.NullVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + }) + + // --- Unknown error message --- + + t.Run("error message unknown, condition fails → Invalid error message (no failure diag)", func(t *testing.T) { + // We must flag the unknown error message but must NOT also emit + // "Invalid value for variable" because we return early. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when error message is unknown") + } + } + }) + + t.Run("error message unknown, condition passes → no diagnostic about error message", func(t *testing.T) { + // Unknown error message is only a problem when the condition actually + // fails (because we'd need to display it). When the condition passes + // the unknown error message must be silently ignored. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertNoDiags(t, diags) + }) + + // --- Sensitive variable value in condition expression --- + + t.Run("sensitive variable value, plain error message, condition fails → only Invalid value for variable", func(t *testing.T) { + // var.foo carries a sensitive mark. The condition expression + // (var.foo == "good") evaluates to a sensitive bool; Unmark() peels + // off the mark so the check works correctly. The error_message is a + // plain literal → no "Error message refers to sensitive values" diag. + hclCtx := makeVarCtx(cty.StringVal("bad").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + hcltest.MockExprLiteral(cty.StringVal("Value is not allowed.")), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + for _, d := range diags { + if d.Description().Summary == "Error message refers to sensitive values" { + t.Errorf("unexpected 'Error message refers to sensitive values' when error message is plain text") + } + } + }) + + t.Run("sensitive variable referenced in error message, condition fails → both diagnostics", func(t *testing.T) { + // When the error_message interpolates a sensitive variable the + // evaluated message is itself sensitive — both the sensitive-value + // diagnostic and the generic failure diagnostic must be emitted. + hclCtx := makeVarCtx(cty.StringVal("secret").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + t.Run("ephemeral variable referenced in error message, condition passes → flagged even on success", func(t *testing.T) { + // The condition passes but the error_message references an ephemeral + // variable, making the message itself ephemeral. This structural + // problem must still be reported. + hclCtx := makeVarCtx(cty.StringVal("good").Mark(marks.Ephemeral)) + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition passed") + } + } + }) + + // --- Condition evaluation error --- + + t.Run("condition evaluation error → early return with HCL error", func(t *testing.T) { + // When the condition expression itself fails to evaluate (e.g. it references + // an undefined variable), evalVariableValidation must return early with the + // evaluation error and must NOT emit "Invalid value for variable" or + // "Invalid variable validation result". + rule := makeFakeRule( + parseExpr(t, "undefined_var.foo"), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + // hclCtx only has "var", so "undefined_var" is unknown → evaluation error. + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + if !diags.HasErrors() { + t.Fatal("expected at least one error diagnostic, got none") + } + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' on condition evaluation error") + } + if d.Description().Summary == "Invalid variable validation result" { + t.Errorf("unexpected 'Invalid variable validation result' on condition evaluation error") + } + } + }) + + // --- Non-bool condition result --- + + t.Run("condition result is non-bool (list) → Invalid variable validation result", func(t *testing.T) { + // A condition that returns a list (or any value that cannot be converted + // to bool) hits the convert.Convert(result, cty.Bool) failure path. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + // Must return early — no "Invalid value for variable" should follow. + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition type conversion failed") + } + } + }) + + // --- Null error message --- + + t.Run("null error message, condition fails → Invalid value for variable with fallback text", func(t *testing.T) { + // A null error_message is skipped during string conversion; the framework + // falls back to "Failed to evaluate condition error message." in the + // detail of the "Invalid value for variable" diagnostic. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.NullVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) + + // --- Non-string error message --- + + t.Run("non-string error message (list), condition fails → Invalid error message + fallback in failure diag", func(t *testing.T) { + // An error_message that evaluates to a list (unconvertible to string) + // hits the convert.Convert(errorValue, cty.String) failure path. Both + // "Invalid error message" and "Invalid value for variable" (with the + // fallback text) must be emitted. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) +} + +// TestInputVariableValidation exercises evalVariableValidations end-to-end +// through CheckValue, using the "validation" fixture that declares variables +// with validation blocks. +func TestInputVariableValidation(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + varName string + inputVal cty.Value + wantSummaries []string // diagnostics that MUST be present (by Summary) + wantNoErrors bool // if true, no error diagnostics are expected + }{ + // --- validated (plain error message) --- + "validated: clean pass": { + varName: "validated", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "validated: clean fail": { + varName: "validated", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + + // --- with_msg_ref (error message interpolates var.with_msg_ref) --- + "with_msg_ref: clean pass": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "with_msg_ref: clean fail": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + "with_msg_ref: sensitive value passes → error message diag only": { + // Condition passes, but the interpolated error_message is sensitive + // → we should still flag the structural problem. + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Sensitive), + wantSummaries: []string{"Error message refers to sensitive values"}, + }, + "with_msg_ref: sensitive value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Sensitive), + wantSummaries: []string{ + "Error message refers to sensitive values", + "Invalid value for variable", + }, + }, + "with_msg_ref: ephemeral value passes → error message diag only": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Ephemeral), + wantSummaries: []string{"Error message refers to ephemeral values"}, + }, + "with_msg_ref: ephemeral value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Ephemeral), + wantSummaries: []string{ + "Error message refers to ephemeral values", + "Invalid value for variable", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + tc.varName: tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: tc.varName}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + if tc.wantNoErrors { + if diags.HasErrors() { + t.Errorf("unexpected errors: %s", diags.Err()) + } + return + } + + for _, wantSummary := range tc.wantSummaries { + wantSummary := wantSummary // capture for closure + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == wantSummary + }) + } + }) + }) + } +} + +// TestInputVariableValidationWithProviderFunction verifies that provider-defined +// functions can be called inside a variable validation condition expression. +// It uses the "validation_provider_function" fixture together with a mock provider +// that exposes a simple "upper" string function. +func TestInputVariableValidationWithProviderFunction(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation_provider_function") + providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/testing") + + newMockProvider := func(t *testing.T) (*testing_provider.MockProvider, providers.Factory) { + t.Helper() + mockProvider := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Functions: map[string]providers.FunctionDecl{ + "upper": { + Parameters: []providers.FunctionParam{ + {Name: "input", Type: cty.String}, + }, + ReturnType: cty.String, + Summary: "Converts a string to upper-case.", + }, + }, + }, + CallFunctionFn: func(req providers.CallFunctionRequest) providers.CallFunctionResponse { + if req.FunctionName != "upper" { + return providers.CallFunctionResponse{ + Err: fmt.Errorf("unexpected function call: %s", req.FunctionName), + } + } + input, _ := req.Arguments[0].Unmark() + return providers.CallFunctionResponse{ + Result: cty.StringVal(strings.ToUpper(input.AsString())), + } + }, + } + return mockProvider, providers.FactoryFixed(mockProvider) + } + + t.Run("passes validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("hello"), // upper("hello") == "HELLO" → condition passes + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertNoDiags(t, diags) + }) + }) + + t.Run("fails validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("world"), // upper("world") == "WORLD" ≠ "HELLO" → condition fails + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + }) +} + +// TestInputVariableMultipleValidationRules verifies that when a variable has +// more than one validation block, every failing rule produces its own +// diagnostic — i.e., all rules are evaluated and none are short-circuited. +// +// The "multi_rule" variable in the "validation" fixture has two rules: +// +// Rule 1: length(var.multi_rule) >= 5 +// Rule 2: var.multi_rule != "bad" +// +// The value "bad" has length 3 (< 5) and equals "bad", so it violates both +// rules simultaneously, giving us exactly two "Invalid value for variable" +// diagnostics. +func TestInputVariableMultipleValidationRules(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + inputVal cty.Value + wantErrCount int // expected number of "Invalid value for variable" diagnostics + }{ + "passes both rules": { + inputVal: cty.StringVal("hello"), // length 5 >= 5, != "bad" + wantErrCount: 0, + }, + "fails first rule only": { + inputVal: cty.StringVal("hi"), // length 2 < 5, != "bad" + wantErrCount: 1, + }, + "fails second rule only": { + // length("hello!") = 6 >= 5 → first passes; "hello!" != "bad" → second passes. + // To fail only the second rule we need length >= 5 AND value == "bad". + // "bad" itself has length 3, so the only way to isolate rule 2 failure + // is with a longer value that equals "bad" — impossible for a plain + // string. We therefore omit this sub-case and rely on the unit-level + // TestEvalVariableValidation coverage instead. + inputVal: cty.StringVal("hello"), // deliberately a pass case + wantErrCount: 0, + }, + "fails both rules": { + inputVal: cty.StringVal("bad"), // length 3 < 5 AND == "bad" + wantErrCount: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "multi_rule": tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "multi_rule"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + var failCount int + for _, d := range diags { + if d.Severity() == tfdiags.Error && d.Description().Summary == "Invalid value for variable" { + failCount++ + } + } + if failCount != tc.wantErrCount { + t.Errorf("expected %d 'Invalid value for variable' diagnostic(s), got %d; diags:\n%s", + tc.wantErrCount, failCount, diags.ErrWithWarnings()) + } + }) + }) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl new file mode 100644 index 0000000000..5e80f82b09 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl @@ -0,0 +1,43 @@ + +# A variable with a simple validation that does not reference the variable +# value inside the error_message. +variable "validated" { + type = string + + validation { + condition = var.validated != "bad" + error_message = "Value must not be 'bad'." + } +} + +# A variable whose error_message expression interpolates the variable value. +# When the input carries a sensitive or ephemeral mark, evaluating the +# interpolation causes the error_message result to inherit that mark — which +# is the behaviour we want to exercise. +variable "with_msg_ref" { + type = string + + validation { + condition = var.with_msg_ref != "bad" + error_message = "Got disallowed value '${var.with_msg_ref}'." + } +} + +# A variable with two validation blocks to verify that all rules are evaluated +# and all failures are reported independently. +# +# Inputs that trigger both failures simultaneously: +# "bad" — length("bad") = 3 < 5 AND "bad" == "bad" +variable "multi_rule" { + type = string + + validation { + condition = length(var.multi_rule) >= 5 + error_message = "Value must be at least 5 characters long." + } + + validation { + condition = var.multi_rule != "bad" + error_message = "Value must not be 'bad'." + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl new file mode 100644 index 0000000000..15b9ac35ea --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl @@ -0,0 +1,25 @@ + +# This fixture is used by TestInputVariableValidationWithProviderFunction to +# verify that provider-defined functions can be called inside a validation +# condition expression. +# +# The mock test provider exposes a single function "upper" that converts a +# string to upper-case; the validation here checks that the given value +# equals "HELLO" when converted to upper-case. + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" {} + +variable "foo" { + type = string + + validation { + condition = provider::testing::upper(var.foo) == "HELLO" + error_message = "Value must equal 'hello' (case-insensitive)." + } +} diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 831101c981..18c0683f1a 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6607,6 +6607,85 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { }, wantErrorMessages: []string{"Tag values must be 1-256 characters."}, }, + + // Invalid error message tests - these verify that invalid error messages + // are caught even when validation passes or fails + "invalid-error-message-sensitive-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("short"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + "invalid-error-message-ephemeral-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("short_token"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to ephemeral values", + }, + }, + "invalid-error-message-not-string": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(-5), + "api_key": cty.StringVal("abcdef0123456789"), + }, + // When error_message is not a string type, we get the raw value in the validation failure + wantErrorMessages: []string{ + "-5", + }, + }, + "invalid-error-message-sensitive-even-when-passing": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.NumberIntVal(5), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"), + }, + // This tests that we evaluate error_message even when validation passes + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + + // Provider function tests + "provider-functions-pass": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test_value"), + "combined": cty.StringVal("long_enough"), + }, + wantErrorMessages: nil, + }, + "provider-functions-fail": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test"), + "combined": cty.StringVal("short"), + }, + wantErrorMessages: []string{ + "Combined value must be longer than 5 characters after echo", + }, + }, } for name, tc := range testCases { diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl new file mode 100644 index 0000000000..d7c90622c4 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl @@ -0,0 +1,68 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: error_message references sensitive value +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password '${var.password}' is too short." + } +} + +# Test case: error_message references ephemeral value +variable "token" { + type = string + ephemeral = true + + validation { + condition = length(var.token) == 32 + error_message = "Token '${var.token}' is invalid." + } +} + +# Test case: error_message that is not a string +variable "count_value" { + type = number + + validation { + condition = var.count_value > 0 + error_message = var.count_value # Invalid: should be a string + } +} + +# Test case: error_message references sensitive value even when validation passes +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) >= 16 + error_message = "API key '${var.api_key}' must be at least 16 characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl new file mode 100644 index 0000000000..ae6770daaf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl @@ -0,0 +1,45 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: Validation using provider-defined function in condition +variable "echo_value" { + type = string + + validation { + condition = provider::testing::echo(var.echo_value) == var.echo_value + error_message = "Echo function did not return the same value." + } +} + +# Test case: Validation using provider function with built-in functions +variable "combined" { + type = string + + validation { + condition = length(provider::testing::echo(var.combined)) > 5 + error_message = "Combined value must be longer than 5 characters after echo." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} From 2077b53936a47d9668adca04227d509e42cb09aa Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 5 Mar 2026 13:49:30 +0100 Subject: [PATCH 024/136] add tests for dynamic module sources in stacks --- .../stacks/stackconfig/input_variable_test.go | 51 +++++++++++++++++++ .../internal/stackeval/component_config.go | 13 +++++ .../const-variable-in-component.tf | 18 +++++++ ...onst-variable-in-component.tfcomponent.hcl | 16 ++++++ internal/stacks/stackruntime/validate_test.go | 16 ++++++ 5 files changed, 114 insertions(+) create mode 100644 internal/stacks/stackconfig/input_variable_test.go create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl diff --git a/internal/stacks/stackconfig/input_variable_test.go b/internal/stacks/stackconfig/input_variable_test.go new file mode 100644 index 0000000000..a3bb1d6ae9 --- /dev/null +++ b/internal/stacks/stackconfig/input_variable_test.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackconfig + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func TestDecodeInputVariableBlock_constNotSupported(t *testing.T) { + // const = true is not supported in the stacks component language. + // This test documents that using const produces an "Unsupported argument" + // error from the HCL schema validation. + src := []byte(`variable "example" { + type = string + const = true +}`) + file, diags := hclsyntax.ParseConfig(src, "test.tfcomponent.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("unexpected parse error: %s", diags.Error()) + } + + content, diags := file.Body.Content(&hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: "variable", LabelNames: []string{"name"}}, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected schema error: %s", diags.Error()) + } + + if len(content.Blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(content.Blocks)) + } + + _, decodeDiags := decodeInputVariableBlock(content.Blocks[0]) + if len(decodeDiags) != 1 { + t.Fatalf("expected exactly 1 diagnostic, got %d:\n%s", len(decodeDiags), decodeDiags.NonFatalErr()) + } + + diag := decodeDiags[0] + if got, want := diag.Description().Summary, "Unsupported argument"; got != want { + t.Errorf("wrong summary\ngot: %s\nwant: %s", got, want) + } + if got, want := diag.Description().Detail, `An argument named "const" is not expected here.`; got != want { + t.Errorf("wrong detail\ngot: %s\nwant: %s", got, want) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/component_config.go b/internal/stacks/stackruntime/internal/stackeval/component_config.go index 4e5817f90f..7813106d31 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_config.go @@ -186,6 +186,19 @@ func validateModuleForStacks(moduleAddr addrs.Module, module *configs.Module) tf } } + // Const variables are not supported in stacks, because stacks does not + // perform the early evaluation phase that const variables rely on. + for _, v := range module.Variables { + if v.ConstSet { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable not supported in stacks", + Detail: "Variables with const = true are not supported in modules used as stack components. Const variables are evaluated during configuration loading, which is not supported in the stacks runtime.", + Subject: v.DeclRange.Ptr(), + }) + } + } + return diags } diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf new file mode 100644 index 0000000000..1c9b27c6cc --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +variable "input" { + type = string + const = true + default = "hello" +} + +resource "testing_resource" "data" { + value = var.input +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl new file mode 100644 index 0000000000..18c62cbcbf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl @@ -0,0 +1,16 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +component "self" { + source = "./" + + providers = { + testing = provider.testing.default + } +} diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index c155937228..86aae05869 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -71,6 +71,22 @@ var ( // invalidConfigurations are shared between the validate and plan tests. invalidConfigurations = map[string]validateTestInput{ + "const-variable-in-component": { + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable not supported in stacks", + Detail: "Variables with const = true are not supported in modules used as stack components. Const variables are evaluated during configuration loading, which is not supported in the stacks runtime.", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("const-variable-in-component/const-variable-in-component.tf"), + Start: hcl.Pos{Line: 10, Column: 1, Byte: 124}, + End: hcl.Pos{Line: 10, Column: 17, Byte: 140}, + }, + }) + return diags + }, + }, "validate-undeclared-variable": { diags: func() tfdiags.Diagnostics { var diags tfdiags.Diagnostics From 02723fcd7951f2bc713c5441d71a97cc771a4919 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 5 Mar 2026 16:36:38 +0100 Subject: [PATCH 025/136] Add variable arguments to init help text --- internal/command/init.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/command/init.go b/internal/command/init.go index d5796e92e8..6bcb08c5ff 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -1139,6 +1139,15 @@ Options: -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. + -enable-pluggable-state-storage-experiment [EXPERIMENTAL] A flag to enable an alternative init command that allows use of pluggable state storage. Only usable with experiments enabled. From 3e8db727ebf630e1d954db1a8600d97211c24728 Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Thu, 5 Mar 2026 11:03:27 -0500 Subject: [PATCH 026/136] Create ENHANCEMENTS-20260305-103721.yaml --- .changes/v1.15/ENHANCEMENTS-20260305-103721.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/ENHANCEMENTS-20260305-103721.yaml diff --git a/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml b/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml new file mode 100644 index 0000000000..af43f8ea8e --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: Add input variable validation for Stacks +time: 2026-03-05T10:37:21.047704-05:00 +custom: + Issue: "38240" From 93bc487fae1d665a8a0b7c1aae2c974b33163d0d Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Thu, 5 Mar 2026 12:33:09 -0500 Subject: [PATCH 027/136] update the unknown error --- .../internal/stackeval/input_variable.go | 33 ++++++++----------- .../internal/stackeval/input_variable_test.go | 29 +++++++++++----- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index ebcdc5037f..48a883f71c 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -477,12 +477,8 @@ func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalC const errInvalidValue = "Invalid value for variable" var diags tfdiags.Diagnostics - // Evaluate both condition and error message up front. The error message is - // always inspected for structural problems (sensitive/ephemeral marks), not only - // when the condition fails. result, moreDiags := validation.Condition.Value(hclCtx) diags = diags.Append(moreDiags) - errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) if moreDiags.HasErrors() { // If we couldn't evaluate the condition at all (syntax error, etc.), @@ -529,27 +525,24 @@ func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalC // The marks don't affect the validation result, only how we handle the error message. result, _ = result.Unmark() - // Always process and validate the error_message expression, even when the condition - // passes — an invalid error message (sensitive, ephemeral, non-string, etc.) should - // be flagged regardless of whether the check succeeds or fails. + // Always evaluate the error_message expression, even when the condition passes — + // unknown, sensitive, or ephemeral values in the message are structural problems + // regardless of whether the check succeeds or fails. + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) diags = diags.Append(errorDiags) var errorMessage string if !errorDiags.HasErrors() { if !errorValue.IsKnown() { - if !result.True() { - // An unknown error message is only a problem when the condition actually - // fails, since we need to display it to the user. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid error message", - Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.", - Subject: validation.ErrorMessage.Range().Ptr(), - Expression: validation.ErrorMessage, - EvalContext: hclCtx, - }) - return diags - } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.", + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + return diags } else if !errorValue.IsNull() { errorValue, err = convert.Convert(errorValue, cty.String) if err != nil { diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go index 2288c62b24..e8c3f27457 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -665,9 +665,12 @@ func TestEvalVariableValidation(t *testing.T) { // --- Unknown error message --- - t.Run("error message unknown, condition fails → Invalid error message (no failure diag)", func(t *testing.T) { - // We must flag the unknown error message but must NOT also emit - // "Invalid value for variable" because we return early. + t.Run("error message unknown, condition fails → Invalid error message only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT also be emitted. rule := makeFakeRule( hcltest.MockExprLiteral(cty.False), hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), @@ -684,16 +687,26 @@ func TestEvalVariableValidation(t *testing.T) { } }) - t.Run("error message unknown, condition passes → no diagnostic about error message", func(t *testing.T) { - // Unknown error message is only a problem when the condition actually - // fails (because we'd need to display it). When the condition passes - // the unknown error message must be silently ignored. + t.Run("error message unknown, condition passes → Invalid error message only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT be emitted even though the condition passed. rule := makeFakeRule( hcltest.MockExprLiteral(cty.True), hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), ) diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) - assertNoDiags(t, diags) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when error message is unknown") + } + } }) // --- Sensitive variable value in condition expression --- From 95d0b3aabeed78505c6e66a8032dff0e0ae57d5d Mon Sep 17 00:00:00 2001 From: sahar-azizighannad Date: Thu, 5 Mar 2026 13:40:54 -0500 Subject: [PATCH 028/136] update the test --- internal/stacks/stackruntime/plan_test.go | 13 +++++++------ ...validation-invalid-error-message.tfcomponent.hcl | 10 +++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 18c0683f1a..5298327bee 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6616,7 +6616,7 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { "input": cty.StringVal("test"), "password": cty.StringVal("short"), "token": cty.StringVal("abcdef0123456789abcdef0123456789"), - "count_value": cty.NumberIntVal(5), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), "api_key": cty.StringVal("abcdef0123456789"), }, wantErrorMessages: []string{ @@ -6629,7 +6629,7 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { "input": cty.StringVal("test"), "password": cty.StringVal("SecurePass123"), "token": cty.StringVal("short_token"), - "count_value": cty.NumberIntVal(5), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), "api_key": cty.StringVal("abcdef0123456789"), }, wantErrorMessages: []string{ @@ -6642,12 +6642,13 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { "input": cty.StringVal("test"), "password": cty.StringVal("SecurePass123"), "token": cty.StringVal("abcdef0123456789abcdef0123456789"), - "count_value": cty.NumberIntVal(-5), + "count_value": cty.SetValEmpty(cty.String), // empty set fails condition; a set cannot be converted to a string "api_key": cty.StringVal("abcdef0123456789"), }, - // When error_message is not a string type, we get the raw value in the validation failure + // A set value cannot be converted to a string, so we get an "Invalid error message" diagnostic + // rather than the condition failure message. wantErrorMessages: []string{ - "-5", + "Unsuitable value for error message", }, }, "invalid-error-message-sensitive-even-when-passing": { @@ -6656,7 +6657,7 @@ func TestPlan_variableValidationAdvanced(t *testing.T) { "input": cty.StringVal("test"), "password": cty.StringVal("SecurePass123"), "token": cty.StringVal("abcdef0123456789abcdef0123456789"), - "count_value": cty.NumberIntVal(5), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), "api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"), }, // This tests that we evaluate error_message even when validation passes diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl index d7c90622c4..ed7d2fffd2 100644 --- a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl @@ -32,13 +32,13 @@ variable "token" { } } -# Test case: error_message that is not a string +# Test case: error_message that is not a string (and cannot be converted to one) variable "count_value" { - type = number - + type = set(string) + validation { - condition = var.count_value > 0 - error_message = var.count_value # Invalid: should be a string + condition = length(var.count_value) > 0 + error_message = var.count_value # Invalid: a set cannot be converted to a string } } From 0c796986bb63eafe3e56ef205025ec4dc16f310a Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 16:32:51 +0100 Subject: [PATCH 029/136] move load config calls to new mechanism --- internal/cloud/test_test.go | 30 +++++++-- internal/command/test_test.go | 15 ++++- internal/initwd/from_module_test.go | 37 +++++++++-- internal/initwd/module_install_test.go | 61 +++++++++++++++++-- .../moduletest/graph/eval_context_test.go | 14 ++++- internal/terraform/terraform_test.go | 16 ++++- 6 files changed, 150 insertions(+), 23 deletions(-) diff --git a/internal/cloud/test_test.go b/internal/cloud/test_test.go index ca1971ff7e..fb58e819b8 100644 --- a/internal/cloud/test_test.go +++ b/internal/cloud/test_test.go @@ -13,8 +13,10 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -267,9 +269,19 @@ func TestTest_Verbose(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadStaticConfigWithTests(directory, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(directory, "tests") + if hclDiags.HasErrors() { + t.Fatalf("failed to load root module: %v", hclDiags.Error()) + } + + config, configDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if configDiags.HasErrors() { - t.Fatalf("failed to load config: %v", configDiags.Error()) + t.Fatalf("failed to load config: %v", configDiags.Err()) } streams, done := terminal.StreamsForTesting(t) @@ -664,9 +676,19 @@ func TestTest_ForceCancel(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadStaticConfigWithTests("testdata/test-force-cancel", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests("testdata/test-force-cancel", "tests") + if hclDiags.HasErrors() { + t.Fatalf("failed to load root module: %v", hclDiags.Error()) + } + + config, configDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if configDiags.HasErrors() { - t.Fatalf("failed to load config: %v", configDiags.Error()) + t.Fatalf("failed to load config: %v", configDiags.Err()) } streams, outputFn := terminal.StreamsForTesting(t) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 01d3f548de..d10fe31a3a 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -37,6 +37,7 @@ import ( "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -5719,9 +5720,19 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, diags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config, cfgPath, func() { diff --git a/internal/initwd/from_module_test.go b/internal/initwd/from_module_test.go index 9f57be4de7..690f142c78 100644 --- a/internal/initwd/from_module_test.go +++ b/internal/initwd/from_module_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -107,8 +108,16 @@ func TestDirFromModule_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in example", @@ -187,8 +196,16 @@ func TestDirFromModule_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in root module", @@ -312,8 +329,16 @@ func TestDirFromModule_rel_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in root module", diff --git a/internal/initwd/module_install_test.go b/internal/initwd/module_install_test.go index 75d66861c1..d36b2bf03d 100644 --- a/internal/initwd/module_install_test.go +++ b/internal/initwd/module_install_test.go @@ -25,6 +25,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" _ "github.com/hashicorp/terraform/internal/logging" @@ -77,7 +78,15 @@ func TestModuleInstaller(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -410,7 +419,15 @@ func TestModuleInstaller_symlink(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -608,7 +625,15 @@ func TestLoaderInstallModules_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -738,7 +763,15 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -800,7 +833,15 @@ func TestModuleInstaller_fromTests(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfigWithTests(".", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(".", "tests") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["tests/main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { @@ -909,7 +950,15 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadStaticConfigWithTests(".", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(".", "tests") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { diff --git a/internal/moduletest/graph/eval_context_test.go b/internal/moduletest/graph/eval_context_test.go index 4b033f771a..d2bde834fb 100644 --- a/internal/moduletest/graph/eval_context_test.go +++ b/internal/moduletest/graph/eval_context_test.go @@ -847,9 +847,19 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, diags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 309f4d044e..6ec0d84b34 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -170,9 +170,19 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadStaticConfigWithTests(cfgPath, "tests") - if diags.HasErrors() { - t.Fatal(diags.Error()) + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, buildDiags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, // no variables needed for this helper + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatal(buildDiags.Err()) } return config From 8fd8a48a06af33d3a3614b4b48768bd1516632fe Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 16:39:42 +0100 Subject: [PATCH 030/136] use common testing helper in configs --- internal/checks/state_test.go | 18 ++++- internal/initwd/testing.go | 77 ---------------------- internal/lang/globalref/analyzer_test.go | 18 ++++- internal/refactoring/move_validate_test.go | 17 ++++- 4 files changed, 47 insertions(+), 83 deletions(-) delete mode 100644 internal/initwd/testing.go diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go index 276f6dc62c..7c2b0c73a8 100644 --- a/internal/checks/state_test.go +++ b/internal/checks/state_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/initwd" ) @@ -29,9 +30,22 @@ func TestChecksHappyPath(t *testing.T) { ///////////////////////////////////////////////////////////////////////// - cfg, hclDiags := loader.LoadStaticConfig(fixtureDir) + // Note: This test uses BuildConfig instead of + // terraform.BuildConfigWithGraph to avoid an import cycle (terraform + // imports the checks package). Since this test only needs basic config + // structure without expression evaluation, the static loader is appropriate. + rootMod, hclDiags := loader.LoadRootModule(fixtureDir) if hclDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", hclDiags.Error()) + t.Fatalf("invalid root module: %s", hclDiags.Error()) + } + + cfg, buildDiags := configs.BuildConfig( + rootMod, + loader.ModuleWalker(), + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Error()) } resourceA := addrs.Resource{ diff --git a/internal/initwd/testing.go b/internal/initwd/testing.go deleted file mode 100644 index 6566ce4e67..0000000000 --- a/internal/initwd/testing.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package initwd - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/registry" - "github.com/hashicorp/terraform/internal/tfdiags" -) - -// LoadConfigForTests is a convenience wrapper around configload.NewLoaderForTests, -// ModuleInstaller.InstallModules and configload.Loader.LoadConfig that allows -// a test configuration to be loaded in a single step. -// -// If module installation fails, t.Fatal (or similar) is called to halt -// execution of the test, under the assumption that installation failures are -// not expected. If installation failures _are_ expected then use -// NewLoaderForTests and work with the loader object directly. If module -// installation succeeds but generates warnings, these warnings are discarded. -// -// If installation succeeds but errors are detected during loading then a -// possibly-incomplete config is returned along with error diagnostics. The -// test run is not aborted in this case, so that the caller can make assertions -// against the returned diagnostics. -// -// As with NewLoaderForTests, a cleanup function is returned which must be -// called before the test completes in order to remove the temporary -// modules directory. -func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs.Config, *configload.Loader, func(), tfdiags.Diagnostics) { - t.Helper() - - var diags tfdiags.Diagnostics - - loader, cleanup := configload.NewLoaderForTests(t) - inst := NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - - _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, ModuleInstallHooksImpl{}) - diags = diags.Append(moreDiags) - if diags.HasErrors() { - cleanup() - t.Fatal(diags.Err()) - return nil, nil, func() {}, diags - } - - // Since module installer has modified the module manifest on disk, we need - // to refresh the cache of it in the loader. - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - - config, hclDiags := loader.LoadStaticConfigWithTests(rootDir, testsDir) - diags = diags.Append(hclDiags) - return config, loader, cleanup, diags -} - -// MustLoadConfigForTests is a variant of LoadConfigForTests which calls -// t.Fatal (or similar) if there are any errors during loading, and thus -// does not return diagnostics at all. -// -// This is useful for concisely writing tests that don't expect errors at -// all. For tests that expect errors and need to assert against them, use -// LoadConfigForTests instead. -func MustLoadConfigForTests(t *testing.T, rootDir, testsDir string) (*configs.Config, *configload.Loader, func()) { - t.Helper() - - config, loader, cleanup, diags := LoadConfigForTests(t, rootDir, testsDir) - if diags.HasErrors() { - cleanup() - t.Fatal(diags.Err()) - } - return config, loader, cleanup -} diff --git a/internal/lang/globalref/analyzer_test.go b/internal/lang/globalref/analyzer_test.go index 416cccce48..76a1190959 100644 --- a/internal/lang/globalref/analyzer_test.go +++ b/internal/lang/globalref/analyzer_test.go @@ -11,6 +11,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" @@ -33,9 +34,22 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { t.Fatalf("failed to refresh modules after install: %s", err) } - cfg, loadDiags := loader.LoadStaticConfig(configDir) + // Note: This test uses BuildConfig instead of + // terraform.BuildConfigWithGraph to avoid an import cycle (terraform + // imports the lang package). Since this test only needs basic config + // structure without expression evaluation, the static loader is appropriate. + rootMod, loadDiags := loader.LoadRootModule(configDir) if loadDiags.HasErrors() { - t.Fatalf("unexpected configuration errors: %s", loadDiags.Error()) + t.Fatalf("invalid root module: %s", loadDiags.Error()) + } + + cfg, buildDiags := configs.BuildConfig( + rootMod, + loader.ModuleWalker(), + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Error()) } resourceTypeSchema := &configschema.Block{ diff --git a/internal/refactoring/move_validate_test.go b/internal/refactoring/move_validate_test.go index bc9b66c2db..e0b2ca1e30 100644 --- a/internal/refactoring/move_validate_test.go +++ b/internal/refactoring/move_validate_test.go @@ -533,9 +533,22 @@ func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instance t.Fatalf("failed to refresh modules after installation: %s", err) } - rootCfg, diags := loader.LoadStaticConfig(dir) + // Note: This test uses BuildConfig instead of + // terraform.BuildConfigWithGraph to avoid an import cycle (terraform + // imports the refactoring package). Since this test only needs basic config + // structure without expression evaluation, the static loader is appropriate. + rootMod, diags := loader.LoadRootModule(dir) if diags.HasErrors() { - t.Fatalf("failed to load root module: %s", diags.Error()) + t.Fatalf("invalid root module: %s", diags.Error()) + } + + rootCfg, buildDiags := configs.BuildConfig( + rootMod, + loader.ModuleWalker(), + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Error()) } expander := instances.NewExpander(nil) From 188eeac4ff8584e7b448ea2d5b1ea83afa41f3ac Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 16:47:57 +0100 Subject: [PATCH 031/136] remove now obsolete ReadConfig on planfile --- internal/plans/planfile/reader.go | 29 ----------------------------- internal/terraform/context_test.go | 20 +++++++++++++++++++- internal/terraform/planfile_test.go | 10 ---------- 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/internal/plans/planfile/reader.go b/internal/plans/planfile/reader.go index 2a54187d1b..da6c5ca87e 100644 --- a/internal/plans/planfile/reader.go +++ b/internal/plans/planfile/reader.go @@ -10,7 +10,6 @@ import ( "io" "os" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/plans" @@ -191,34 +190,6 @@ func (r *Reader) ReadConfigSnapshot() (*configload.Snapshot, error) { return ReadConfigSnapshot(&r.zip.Reader) } -// ReadConfig reads the configuration embedded in the plan file. -// -// Internally this function delegates to the configs/configload package to -// parse the embedded configuration and so it returns diagnostics (rather than -// a native Go error as with other methods on Reader). -// TODO remove? -func (r *Reader) ReadConfig(allowLanguageExperiments bool) (*configs.Config, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - - snap, err := r.ReadConfigSnapshot() - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to read configuration from plan file", - fmt.Sprintf("The configuration file snapshot in the plan file could not be read: %s.", err), - )) - return nil, diags - } - - loader := configload.NewLoaderFromSnapshot(snap) - loader.AllowLanguageExperiments(allowLanguageExperiments) - rootDir := snap.Modules[""].Dir // Root module base directory - config, configDiags := loader.LoadStaticConfig(rootDir) - diags = diags.Append(configDiags) - - return config, diags -} - // ReadDependencyLocks reads the dependency lock information embedded in // the plan file. // diff --git a/internal/terraform/context_test.go b/internal/terraform/context_test.go index 61feeccd8f..ef4276874c 100644 --- a/internal/terraform/context_test.go +++ b/internal/terraform/context_test.go @@ -810,7 +810,25 @@ func contextOptsForPlanViaFile(t *testing.T, configSnap *configload.Snapshot, pl return nil, nil, nil, err } - config, diags := pr.ReadConfig(false) + snap, err := pr.ReadConfigSnapshot() + if err != nil { + return nil, nil, nil, err + } + + loader := configload.NewLoaderFromSnapshot(snap) + rootMod, hclDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags := tfdiags.Diagnostics(nil).Append(hclDiags) + if diags.HasErrors() { + return nil, nil, nil, diags.Err() + } + + config, buildDiags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) if diags.HasErrors() { return nil, nil, nil, diags.Err() } diff --git a/internal/terraform/planfile_test.go b/internal/terraform/planfile_test.go index b3fe65fe5c..ca0f59c6e3 100644 --- a/internal/terraform/planfile_test.go +++ b/internal/terraform/planfile_test.go @@ -156,16 +156,6 @@ func TestRoundtrip(t *testing.T) { } }) - t.Run("ReadConfig", func(t *testing.T) { - // Reading from snapshots is tested in the configload package, so - // here we'll just test that we can successfully do it, to see if the - // glue code in _this_ package is correct. - _, diags := pr.ReadConfig(false) - if diags.HasErrors() { - t.Errorf("when reading config: %s", diags.Err()) - } - }) - t.Run("ReadDependencyLocks", func(t *testing.T) { locksOut, diags := pr.ReadDependencyLocks() if diags.HasErrors() { From ef179c102e26d57c7081727c1f3b5fd25dfdd5b2 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 16:54:24 +0100 Subject: [PATCH 032/136] remove loadStaticConfig --- internal/configs/configload/loader_load.go | 14 -- .../configs/configload/loader_load_test.go | 188 ------------------ internal/configs/configload/loader_test.go | 36 ---- 3 files changed, 238 deletions(-) delete mode 100644 internal/configs/configload/loader_load_test.go delete mode 100644 internal/configs/configload/loader_test.go diff --git a/internal/configs/configload/loader_load.go b/internal/configs/configload/loader_load.go index 5fbe017915..6bd87f4bfd 100644 --- a/internal/configs/configload/loader_load.go +++ b/internal/configs/configload/loader_load.go @@ -12,20 +12,6 @@ import ( "github.com/hashicorp/terraform/internal/configs" ) -// LoadConfig reads the Terraform module in the given directory and uses it as the -// root module to build the static module tree that represents a configuration, -// assuming that all required descendant modules have already been installed. -// -// If error diagnostics are returned, the returned configuration may be either -// nil or incomplete. In the latter case, cautious static analysis is possible -// in spite of the errors. -// -// LoadConfig performs the basic syntax and uniqueness validations that are -// required to process the individual modules -func (l *Loader) LoadStaticConfig(rootDir string) (*configs.Config, hcl.Diagnostics) { - return l.loadConfig(l.parser.LoadConfigDir(rootDir, l.parserOpts...)) -} - // LoadConfigWithTests matches LoadConfig, except the configs.Config contains // any relevant .tftest.hcl files. func (l *Loader) LoadStaticConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) { diff --git a/internal/configs/configload/loader_load_test.go b/internal/configs/configload/loader_load_test.go deleted file mode 100644 index b81814960e..0000000000 --- a/internal/configs/configload/loader_load_test.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "path/filepath" - "reflect" - "sort" - "strings" - "testing" - - "github.com/davecgh/go-spew/spew" - "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/configs" -) - -func TestLoaderLoadConfig_okay(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadStaticConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if cfg == nil { - t.Fatalf("config is nil; want non-nil") - } - - var gotPaths []string - cfg.DeepEach(func(c *configs.Config) { - gotPaths = append(gotPaths, strings.Join(c.Path, ".")) - }) - sort.Strings(gotPaths) - wantPaths := []string{ - "", // root module - "child_a", - "child_a.child_c", - "child_b", - "child_b.child_d", - } - - if !reflect.DeepEqual(gotPaths, wantPaths) { - t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) - } - - t.Run("child_a.child_c output", func(t *testing.T) { - output := cfg.Children["child_a"].Children["child_c"].Module.Outputs["hello"] - got, diags := output.Expr.Value(nil) - assertNoDiagnostics(t, diags) - assertResultCtyEqual(t, got, cty.StringVal("Hello from child_c")) - }) - t.Run("child_b.child_d output", func(t *testing.T) { - output := cfg.Children["child_b"].Children["child_d"].Module.Outputs["hello"] - got, diags := output.Expr.Value(nil) - assertNoDiagnostics(t, diags) - assertResultCtyEqual(t, got, cty.StringVal("Hello from child_d")) - }) -} - -func TestLoaderLoadConfig_loadDiags(t *testing.T) { - // building a config which didn't load correctly may cause configs to panic - fixtureDir := filepath.Clean("testdata/invalid-names") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadStaticConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatal("success; want error") - } - - if cfg == nil { - t.Fatal("partial config not returned with diagnostics") - } - - if cfg.Module == nil { - t.Fatal("expected config module") - } -} - -func TestLoaderLoadConfig_loadDiagsFromSubmodules(t *testing.T) { - // building a config which didn't load correctly may cause configs to panic - fixtureDir := filepath.Clean("testdata/invalid-names-in-submodules") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadStaticConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("loading succeeded; want an error") - } - if got, want := diags.Error(), " Invalid provider local name"; !strings.Contains(got, want) { - t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) - } - - if cfg == nil { - t.Fatal("partial config not returned with diagnostics") - } - - if cfg.Module == nil { - t.Fatal("expected config module") - } -} - -func TestLoaderLoadConfig_childProviderGrandchildCount(t *testing.T) { - // This test is focused on the specific situation where: - // - A child module contains a nested provider block, which is no longer - // recommended but supported for backward-compatibility. - // - A child of that child does _not_ contain a nested provider block, - // and is called with "count" (would also apply to "for_each" and - // "depends_on"). - // It isn't valid to use "count" with a module that _itself_ contains - // a provider configuration, but it _is_ valid for a module with a - // provider configuration to call another module with count. We previously - // botched this rule and so this is a regression test to cover the - // solution to that mistake: - // https://github.com/hashicorp/terraform/issues/31081 - - // Since this test is based on success rather than failure and it's - // covering a relatively large set of code where only a small part - // contributes to the test, we'll make sure to test both the success and - // failure cases here so that we'll have a better chance of noticing if a - // future change makes this succeed only because we've reorganized the code - // so that the check isn't happening at all anymore. - // - // If the "not okay" subtest fails, you should also be skeptical about - // whether the "okay" subtest is still valid, even if it happens to - // still be passing. - t.Run("okay", func(t *testing.T) { - fixtureDir := filepath.Clean("testdata/child-provider-grandchild-count") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadStaticConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if cfg == nil { - t.Fatalf("config is nil; want non-nil") - } - - var gotPaths []string - cfg.DeepEach(func(c *configs.Config) { - gotPaths = append(gotPaths, strings.Join(c.Path, ".")) - }) - sort.Strings(gotPaths) - wantPaths := []string{ - "", // root module - "child", - "child.grandchild", - } - - if !reflect.DeepEqual(gotPaths, wantPaths) { - t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) - } - }) - t.Run("not okay", func(t *testing.T) { - fixtureDir := filepath.Clean("testdata/child-provider-child-count") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, diags := loader.LoadStaticConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("loading succeeded; want an error") - } - if got, want := diags.Error(), "Module is incompatible with count, for_each, and depends_on"; !strings.Contains(got, want) { - t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) - } - }) - -} diff --git a/internal/configs/configload/loader_test.go b/internal/configs/configload/loader_test.go deleted file mode 100644 index e61f3134ad..0000000000 --- a/internal/configs/configload/loader_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "testing" - - "github.com/hashicorp/hcl/v2" - "github.com/zclconf/go-cty/cty" -) - -func assertNoDiagnostics(t *testing.T, diags hcl.Diagnostics) bool { - t.Helper() - return assertDiagnosticCount(t, diags, 0) -} - -func assertDiagnosticCount(t *testing.T, diags hcl.Diagnostics, want int) bool { - t.Helper() - if len(diags) != want { - t.Errorf("wrong number of diagnostics %d; want %d", len(diags), want) - for _, diag := range diags { - t.Logf("- %s", diag) - } - return true - } - return false -} -func assertResultCtyEqual(t *testing.T, got, want cty.Value) bool { - t.Helper() - if !got.RawEquals(want) { - t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) - return true - } - return false -} From a9756b273cf0f892b48d85141d0295a1bb672539 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 17:03:25 +0100 Subject: [PATCH 033/136] move initwd config loading into terraform otherwise we run into cyclic references --- internal/backend/local/backend_apply_test.go | 4 +- internal/backend/local/backend_local_test.go | 10 +- internal/backend/local/backend_plan_test.go | 4 +- .../backend/local/backend_refresh_test.go | 4 +- internal/backend/remote/backend_apply_test.go | 4 +- .../backend/remote/backend_context_test.go | 6 +- internal/backend/remote/backend_plan_test.go | 4 +- internal/cloud/backend_apply_test.go | 4 +- internal/cloud/backend_context_test.go | 6 +- internal/cloud/backend_plan_test.go | 4 +- internal/cloud/backend_query_test.go | 4 +- internal/cloud/backend_refresh_test.go | 4 +- internal/command/views/show_test.go | 4 +- internal/repl/session_test.go | 4 +- internal/terraform/testing/config.go | 93 +++++++++++++++++++ 15 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 internal/terraform/testing/config.go diff --git a/internal/backend/local/backend_apply_test.go b/internal/backend/local/backend_apply_test.go index 58a81ab0d2..424bfd000a 100644 --- a/internal/backend/local/backend_apply_test.go +++ b/internal/backend/local/backend_apply_test.go @@ -21,12 +21,12 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -364,7 +364,7 @@ func (s failingState) WriteState(state *states.State) error { func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index ce8fd3a25a..9c169bd4a1 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -19,7 +19,6 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/schemarepo" @@ -27,6 +26,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -34,7 +34,7 @@ func TestLocalRun(t *testing.T) { configDir := "./testdata/empty" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() streams, _ := terminal.StreamsForTesting(t) @@ -65,7 +65,7 @@ func TestLocalRun_error(t *testing.T) { // should then cause LocalRun to return with the state unlocked. b.Backend = backendWithStateStorageThatFailsRefresh{} - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() streams, _ := terminal.StreamsForTesting(t) @@ -92,7 +92,7 @@ func TestLocalRun_cloudPlan(t *testing.T) { configDir := "./testdata/apply" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() planPath := "./testdata/plan-bookmark/bookmark.json" @@ -127,7 +127,7 @@ func TestLocalRun_stalePlan(t *testing.T) { configDir := "./testdata/apply" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() // Write an empty state file with serial 3 diff --git a/internal/backend/local/backend_plan_test.go b/internal/backend/local/backend_plan_test.go index a47693354a..38d6619f69 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -20,13 +20,13 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func TestLocal_planBasic(t *testing.T) { @@ -726,7 +726,7 @@ func TestLocal_planOutPathNoChange(t *testing.T) { func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/local/backend_refresh_test.go b/internal/backend/local/backend_refresh_test.go index bb516d07a5..fb2c726bae 100644 --- a/internal/backend/local/backend_refresh_test.go +++ b/internal/backend/local/backend_refresh_test.go @@ -16,11 +16,11 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/zclconf/go-cty/cty" ) @@ -267,7 +267,7 @@ func TestLocal_refreshEmptyState(t *testing.T) { func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index 261b0c0b38..7032f977e0 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -25,12 +25,12 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" tfversion "github.com/hashicorp/terraform/version" ) @@ -43,7 +43,7 @@ func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/backend/remote/backend_context_test.go b/internal/backend/remote/backend_context_test.go index 5cdb93077a..70883b3bae 100644 --- a/internal/backend/remote/backend_context_test.go +++ b/internal/backend/remote/backend_context_test.go @@ -17,10 +17,10 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -187,7 +187,7 @@ func TestRemoteContextWithVars(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) @@ -410,7 +410,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 6026f9908a..3e725bcc8a 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -24,12 +24,12 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -41,7 +41,7 @@ func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, f func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 82452ce274..0c454c39cc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -28,12 +28,12 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" tfversion "github.com/hashicorp/terraform/version" ) @@ -46,7 +46,7 @@ func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 06c3426e2b..3cc8b0f330 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -16,10 +16,10 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -186,7 +186,7 @@ func TestRemoteContextWithVars(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) @@ -409,7 +409,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 4988b5b63e..98d04ae9b6 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -26,12 +26,12 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -43,7 +43,7 @@ func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, f func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_query_test.go b/internal/cloud/backend_query_test.go index ecb9409063..3752f6a6ae 100644 --- a/internal/cloud/backend_query_test.go +++ b/internal/cloud/backend_query_test.go @@ -18,9 +18,9 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationQuery(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -32,7 +32,7 @@ func testOperationQuery(t *testing.T, configDir string) (*backendrun.Operation, func testOperationQueryWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_refresh_test.go b/internal/cloud/backend_refresh_test.go index abfac6345a..c7d38186fa 100644 --- a/internal/cloud/backend_refresh_test.go +++ b/internal/cloud/backend_refresh_test.go @@ -15,10 +15,10 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -30,7 +30,7 @@ func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation func testOperationRefreshWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/command/views/show_test.go b/internal/command/views/show_test.go index bc3ecb8949..1b2bee9ad4 100644 --- a/internal/command/views/show_test.go +++ b/internal/command/views/show_test.go @@ -13,13 +13,13 @@ import ( "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/zclconf/go-cty/cty" ) @@ -169,7 +169,7 @@ func TestShowJSON(t *testing.T) { }, } - config, _, configCleanup := initwd.MustLoadConfigForTests(t, "./testdata/show", "tests") + config, _, configCleanup := tftesting.MustLoadConfigForTests(t, "./testdata/show", "tests") defer configCleanup() for name, testCase := range testCases { diff --git a/internal/repl/session_test.go b/internal/repl/session_test.go index 71c82849d2..9609270ebe 100644 --- a/internal/repl/session_test.go +++ b/internal/repl/session_test.go @@ -14,11 +14,11 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" _ "github.com/hashicorp/terraform/internal/logging" ) @@ -275,7 +275,7 @@ func testSession(t *testing.T, test testSessionTest) { }, } - config, _, cleanup, configDiags := initwd.LoadConfigForTests(t, "testdata/config-fixture", "tests") + config, _, cleanup, configDiags := tftesting.LoadConfigForTests(t, "testdata/config-fixture", "tests") defer cleanup() if configDiags.HasErrors() { t.Fatalf("unexpected problems loading config: %s", configDiags.Err()) diff --git a/internal/terraform/testing/config.go b/internal/terraform/testing/config.go new file mode 100644 index 0000000000..8d3b9ab953 --- /dev/null +++ b/internal/terraform/testing/config.go @@ -0,0 +1,93 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package testing + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// LoadConfigForTests is a convenience wrapper around configload.NewLoaderForTests, +// initwd.ModuleInstaller.InstallModules and terraform.BuildConfigWithGraph that +// allows a test configuration to be loaded in a single step using the graph-based +// configuration loading mechanism. +// +// If module installation fails, t.Fatal (or similar) is called to halt +// execution of the test, under the assumption that installation failures are +// not expected. If installation failures _are_ expected then use +// configload.NewLoaderForTests and work with the loader object directly. If +// module installation succeeds but generates warnings, these warnings are +// discarded. +// +// If installation succeeds but errors are detected during loading then a +// possibly-incomplete config is returned along with error diagnostics. The +// test run is not aborted in this case, so that the caller can make assertions +// against the returned diagnostics. +// +// As with configload.NewLoaderForTests, a cleanup function is returned which +// must be called before the test completes in order to remove the temporary +// modules directory. +func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs.Config, *configload.Loader, func(), tfdiags.Diagnostics) { + t.Helper() + + var diags tfdiags.Diagnostics + + loader, cleanup := configload.NewLoaderForTests(t) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) + + _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, initwd.ModuleInstallHooksImpl{}) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + cleanup() + t.Fatal(diags.Err()) + return nil, nil, func() {}, diags + } + + // Since module installer has modified the module manifest on disk, we need + // to refresh the cache of it in the loader. + if err := loader.RefreshModules(); err != nil { + t.Fatalf("failed to refresh modules after installation: %s", err) + } + + rootMod, hclDiags := loader.LoadRootModuleWithTests(rootDir, testsDir) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return nil, loader, cleanup, diags + } + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, // No input variables for test configs + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + + return config, loader, cleanup, diags +} + +// MustLoadConfigForTests is a variant of LoadConfigForTests which calls +// t.Fatal (or similar) if there are any errors during loading, and thus +// does not return diagnostics at all. +// +// This is useful for concisely writing tests that don't expect errors at +// all. For tests that expect errors and need to assert against them, use +// LoadConfigForTests instead. +func MustLoadConfigForTests(t *testing.T, rootDir, testsDir string) (*configs.Config, *configload.Loader, func()) { + t.Helper() + + config, loader, cleanup, diags := LoadConfigForTests(t, rootDir, testsDir) + if diags.HasErrors() { + cleanup() + t.Fatal(diags.Err()) + } + return config, loader, cleanup +} From fceb418f6270b1f36634e43b41d14a286e10768d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 17:04:36 +0100 Subject: [PATCH 034/136] remove loadStaticConfigWithTests --- internal/configs/configload/loader_load.go | 23 ---------------------- 1 file changed, 23 deletions(-) diff --git a/internal/configs/configload/loader_load.go b/internal/configs/configload/loader_load.go index 6bd87f4bfd..f27ed0d94d 100644 --- a/internal/configs/configload/loader_load.go +++ b/internal/configs/configload/loader_load.go @@ -12,12 +12,6 @@ import ( "github.com/hashicorp/terraform/internal/configs" ) -// LoadConfigWithTests matches LoadConfig, except the configs.Config contains -// any relevant .tftest.hcl files. -func (l *Loader) LoadStaticConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) { - return l.loadConfig(l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...)) -} - // LoadRootModule reads the root module using the loader's parser options. func (l *Loader) LoadRootModule(rootDir string) (*configs.Module, hcl.Diagnostics) { return l.parser.LoadConfigDir(rootDir, l.parserOpts...) @@ -28,23 +22,6 @@ func (l *Loader) LoadRootModuleWithTests(rootDir string, testDir string) (*confi return l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...) } -func (l *Loader) loadConfig(rootMod *configs.Module, diags hcl.Diagnostics) (*configs.Config, hcl.Diagnostics) { - if rootMod == nil || diags.HasErrors() { - // Ensure we return any parsed modules here so that required_version - // constraints can be verified even when encountering errors. - cfg := &configs.Config{ - Module: rootMod, - } - - return cfg, diags - } - - cfg, cDiags := configs.BuildConfig(rootMod, configs.ModuleWalkerFunc(l.moduleWalkerLoad), configs.MockDataLoaderFunc(l.LoadExternalMockData)) - diags = append(diags, cDiags...) - - return cfg, diags -} - // LoadExternalMockData reads the external mock data files for the given // provider, if they are present. func (l *Loader) LoadExternalMockData(provider *configs.Provider) (*configs.MockData, hcl.Diagnostics) { From 0c8d86989e3d5eb4b34d9079a2d101a695c07846 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 17:38:27 +0100 Subject: [PATCH 035/136] move checks to graph loader --- internal/checks/state_test.go | 95 +++++++++++---------------------- internal/checks/testing_test.go | 50 +++++++++++++++++ 2 files changed, 82 insertions(+), 63 deletions(-) create mode 100644 internal/checks/testing_test.go diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go index 7c2b0c73a8..38590a1697 100644 --- a/internal/checks/state_test.go +++ b/internal/checks/state_test.go @@ -1,52 +1,21 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package checks +package checks_test import ( - "context" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/checks" ) func TestChecksHappyPath(t *testing.T) { const fixtureDir = "testdata/happypath" - loader, close := configload.NewLoaderForTests(t) - defer close() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil, nil) - _, instDiags := inst.InstallModules(context.Background(), fixtureDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) - if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) - } - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - ///////////////////////////////////////////////////////////////////////// - - // Note: This test uses BuildConfig instead of - // terraform.BuildConfigWithGraph to avoid an import cycle (terraform - // imports the checks package). Since this test only needs basic config - // structure without expression evaluation, the static loader is appropriate. - rootMod, hclDiags := loader.LoadRootModule(fixtureDir) - if hclDiags.HasErrors() { - t.Fatalf("invalid root module: %s", hclDiags.Error()) - } - - cfg, buildDiags := configs.BuildConfig( - rootMod, - loader.ModuleWalker(), - configs.MockDataLoaderFunc(loader.LoadExternalMockData), - ) - if buildDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", buildDiags.Error()) - } + cfg := LoadConfigForTests(t, fixtureDir, "tests") resourceA := addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -104,36 +73,36 @@ func TestChecksHappyPath(t *testing.T) { ///////////////////////////////////////////////////////////////////////// - checks := NewState(cfg) + state := checks.NewState(cfg) missing := 0 - if addr := resourceA; !checks.ConfigHasChecks(addr) { + if addr := resourceA; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceB; !checks.ConfigHasChecks(addr) { + if addr := resourceB; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceC; !checks.ConfigHasChecks(addr) { + if addr := resourceC; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := rootOutput; !checks.ConfigHasChecks(addr) { + if addr := rootOutput; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := childOutput; !checks.ConfigHasChecks(addr) { + if addr := childOutput; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceNoChecks; checks.ConfigHasChecks(addr) { + if addr := resourceNoChecks; state.ConfigHasChecks(addr) { t.Errorf("checks detected for %s, even though it has none", addr) } - if addr := resourceNonExist; checks.ConfigHasChecks(addr) { + if addr := resourceNonExist; state.ConfigHasChecks(addr) { t.Errorf("checks detected for %s, even though it doesn't exist", addr) } - if addr := checkBlock; !checks.ConfigHasChecks(addr) { + if addr := checkBlock; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } @@ -154,13 +123,13 @@ func TestChecksHappyPath(t *testing.T) { childOutput, checkBlock, ) - gotConfigAddrs := checks.AllConfigAddrs() + gotConfigAddrs := state.AllConfigAddrs() if diff := cmp.Diff(wantConfigAddrs, gotConfigAddrs); diff != "" { t.Errorf("wrong detected config addresses\n%s", diff) } for _, configAddr := range gotConfigAddrs { - if got, want := checks.AggregateCheckStatus(configAddr), StatusUnknown; got != want { + if got, want := state.AggregateCheckStatus(configAddr), checks.StatusUnknown; got != want { t.Errorf("incorrect initial aggregate check status for %s: %s, but want %s", configAddr, got, want) } } @@ -184,26 +153,26 @@ func TestChecksHappyPath(t *testing.T) { childOutputInst := childOutput.OutputValue.Absolute(moduleChildInst) checkBlockInst := checkBlock.Check.Absolute(addrs.RootModuleInstance) - checks.ReportCheckableObjects(resourceA, addrs.MakeSet[addrs.Checkable](resourceInstA)) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 0, StatusPass) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 1, StatusPass) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePostcondition, 0, StatusPass) + state.ReportCheckableObjects(resourceA, addrs.MakeSet[addrs.Checkable](resourceInstA)) + state.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 0, checks.StatusPass) + state.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 1, checks.StatusPass) + state.ReportCheckResult(resourceInstA, addrs.ResourcePostcondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(resourceB, addrs.MakeSet[addrs.Checkable](resourceInstB)) - checks.ReportCheckResult(resourceInstB, addrs.ResourcePrecondition, 0, StatusPass) + state.ReportCheckableObjects(resourceB, addrs.MakeSet[addrs.Checkable](resourceInstB)) + state.ReportCheckResult(resourceInstB, addrs.ResourcePrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(resourceC, addrs.MakeSet[addrs.Checkable](resourceInstC0, resourceInstC1)) - checks.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, StatusPass) - checks.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, StatusPass) + state.ReportCheckableObjects(resourceC, addrs.MakeSet[addrs.Checkable](resourceInstC0, resourceInstC1)) + state.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, checks.StatusPass) + state.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst)) - checks.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, StatusPass) + state.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst)) + state.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(rootOutput, addrs.MakeSet[addrs.Checkable](rootOutputInst)) - checks.ReportCheckResult(rootOutputInst, addrs.OutputPrecondition, 0, StatusPass) + state.ReportCheckableObjects(rootOutput, addrs.MakeSet[addrs.Checkable](rootOutputInst)) + state.ReportCheckResult(rootOutputInst, addrs.OutputPrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(checkBlock, addrs.MakeSet[addrs.Checkable](checkBlockInst)) - checks.ReportCheckResult(checkBlockInst, addrs.CheckAssertion, 0, StatusPass) + state.ReportCheckableObjects(checkBlock, addrs.MakeSet[addrs.Checkable](checkBlockInst)) + state.ReportCheckResult(checkBlockInst, addrs.CheckAssertion, 0, checks.StatusPass) ///////////////////////////////////////////////////////////////////////// @@ -212,9 +181,9 @@ func TestChecksHappyPath(t *testing.T) { { configCount := 0 - for _, configAddr := range checks.AllConfigAddrs() { + for _, configAddr := range state.AllConfigAddrs() { configCount++ - if got, want := checks.AggregateCheckStatus(configAddr), StatusPass; got != want { + if got, want := state.AggregateCheckStatus(configAddr), checks.StatusPass; got != want { t.Errorf("incorrect final aggregate check status for %s: %s, but want %s", configAddr, got, want) } } @@ -234,7 +203,7 @@ func TestChecksHappyPath(t *testing.T) { checkBlockInst, ) for _, addr := range objAddrs { - if got, want := checks.ObjectCheckStatus(addr), StatusPass; got != want { + if got, want := state.ObjectCheckStatus(addr), checks.StatusPass; got != want { t.Errorf("incorrect final check status for object %s: %s, but want %s", addr, got, want) } } diff --git a/internal/checks/testing_test.go b/internal/checks/testing_test.go new file mode 100644 index 0000000000..183ed4b9e6 --- /dev/null +++ b/internal/checks/testing_test.go @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package checks_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/terraform" +) + +// LoadConfigForTests is a test helper that loads a configuration using +// terraform.BuildConfigWithGraph. This helper exists in the checks package +// so that tests can load configs without creating an import cycle. +func LoadConfigForTests(t *testing.T, fixtureDir, testsDir string) *configs.Config { + t.Helper() + + loader, close := configload.NewLoaderForTests(t) + defer close() + + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil, nil) + _, instDiags := inst.InstallModules(context.Background(), fixtureDir, testsDir, true, false, initwd.ModuleInstallHooksImpl{}) + if instDiags.HasErrors() { + t.Fatal(instDiags.Err()) + } + if err := loader.RefreshModules(); err != nil { + t.Fatalf("failed to refresh modules after installation: %s", err) + } + + rootMod, hclDiags := loader.LoadRootModuleWithTests(fixtureDir, testsDir) + if hclDiags.HasErrors() { + t.Fatalf("invalid root module: %s", hclDiags.Error()) + } + + cfg, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, // no input variables + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Err()) + } + + return cfg +} From 051310751ffdf17c1ea8fc7886f4f5a83d791cdf Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 17:58:33 +0100 Subject: [PATCH 036/136] move refactoring package to use graph config loading --- internal/refactoring/move_statement_test.go | 11 +- internal/refactoring/move_validate_test.go | 228 +++--------------- internal/refactoring/remove_statement_test.go | 19 +- internal/refactoring/testing_helpers.go | 109 +++++++++ internal/refactoring/testing_test.go | 58 +++++ 5 files changed, 220 insertions(+), 205 deletions(-) create mode 100644 internal/refactoring/testing_helpers.go create mode 100644 internal/refactoring/testing_test.go diff --git a/internal/refactoring/move_statement_test.go b/internal/refactoring/move_statement_test.go index c80a53288a..975f026930 100644 --- a/internal/refactoring/move_statement_test.go +++ b/internal/refactoring/move_statement_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( "sort" @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -114,9 +115,9 @@ func TestImpliedMoveStatements(t *testing.T) { ) }) - explicitStmts := FindMoveStatements(rootCfg) - got := ImpliedMoveStatements(rootCfg, prevRunState, explicitStmts) - want := []MoveStatement{ + explicitStmts := refactoring.FindMoveStatements(rootCfg) + got := refactoring.ImpliedMoveStatements(rootCfg, prevRunState, explicitStmts) + want := []refactoring.MoveStatement{ { From: addrs.ImpliedMoveStatementEndpoint(resourceAddr("formerly_count").Instance(addrs.IntKey(0)), tfdiags.SourceRange{}), To: addrs.ImpliedMoveStatementEndpoint(resourceAddr("formerly_count").Instance(addrs.NoKey), tfdiags.SourceRange{}), @@ -199,7 +200,7 @@ func TestImpliedMoveStatements(t *testing.T) { sort.Slice(got, func(i, j int) bool { // This is just an arbitrary sort to make the result consistent - // regardless of what order the ImpliedMoveStatements function + // regardless of what order the refactoring.ImpliedMoveStatements function // visits the entries in the state/config. return got[i].DeclRange.Start.Line < got[j].DeclRange.Start.Line }) diff --git a/internal/refactoring/move_validate_test.go b/internal/refactoring/move_validate_test.go index e0b2ca1e30..598a2eaf41 100644 --- a/internal/refactoring/move_validate_test.go +++ b/internal/refactoring/move_validate_test.go @@ -1,25 +1,17 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( - "context" "strings" "testing" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/hashicorp/hcl/v2/hcltest" - "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/gocty" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/initwd" - "github.com/hashicorp/terraform/internal/instances" - "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -27,7 +19,7 @@ func TestValidateMoves(t *testing.T) { rootCfg, instances := loadRefactoringFixture(t, "testdata/move-validate-zoo") tests := map[string]struct { - Statements []MoveStatement + Statements []refactoring.MoveStatement WantError string }{ "no move statements": { @@ -35,7 +27,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "some valid statements": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ // This is just a grab bag of various valid cases that don't // generate any errors at all. makeTestMoveStmt(t, @@ -112,7 +104,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "two statements with the same endpoints": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -127,7 +119,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "moving nowhere": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -137,7 +129,7 @@ func TestValidateMoves(t *testing.T) { WantError: `Redundant move statement: This statement declares a move from module.a to the same address, which is the same as not declaring this move at all.`, }, "cyclic chain": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -162,7 +154,7 @@ func TestValidateMoves(t *testing.T) { A chain of move statements must end with an address that doesn't appear in any other statements, and which typically also refers to an object still declared in the configuration.`, }, "module.single as a call still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single`, @@ -174,7 +166,7 @@ A chain of move statements must end with an address that doesn't appear in any o Change your configuration so that this call will be declared as module.other instead.`, }, "module.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single`, @@ -186,7 +178,7 @@ Change your configuration so that this call will be declared as module.other ins Change your configuration so that this instance will be declared as module.other[0] instead.`, }, "module.count[0] still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.count[0]`, @@ -198,7 +190,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this instance will be declared as module.other instead.`, }, `module.for_each["a"] still exists in configuration`: { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.for_each["a"]`, @@ -210,7 +202,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this instance will be declared as module.other instead.`, }, "test.single as a resource still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.single`, @@ -222,7 +214,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this resource will be declared as test.other instead.`, }, "test.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.single`, @@ -234,7 +226,7 @@ Change your configuration so that this resource will be declared as test.other i Change your configuration so that this instance will be declared as test.other[0] instead.`, }, "module.single.test.single as a resource still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single.test.single`, @@ -246,7 +238,7 @@ Change your configuration so that this instance will be declared as test.other[0 Change your configuration so that this resource will be declared as test.other instead.`, }, "module.single.test.single as a resource declared in module.single still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `single`, `test.single`, @@ -258,7 +250,7 @@ Change your configuration so that this resource will be declared as test.other i Change your configuration so that this resource will be declared as module.single.test.other instead.`, }, "module.single.test.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single.test.single`, @@ -270,7 +262,7 @@ Change your configuration so that this resource will be declared as module.singl Change your configuration so that this instance will be declared as test.other[0] instead.`, }, "module.count[0].test.single still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.count[0].test.single`, @@ -282,7 +274,7 @@ Change your configuration so that this instance will be declared as test.other[0 Change your configuration so that this resource will be declared as test.other instead.`, }, "two different moves from test.nonexist": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.nonexist`, @@ -299,7 +291,7 @@ Change your configuration so that this resource will be declared as test.other i Each resource can move to only one destination resource.`, }, "two different moves to test.single": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.other1`, @@ -316,7 +308,7 @@ Each resource can move to only one destination resource.`, Each resource can have moved from only one source resource.`, }, "two different moves to module.count[0].test.single across two modules": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.other1`, @@ -333,7 +325,7 @@ Each resource can have moved from only one source resource.`, Each resource can have moved from only one source resource.`, }, "move from resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.fake_external.test.thing`, @@ -343,7 +335,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.thing`, @@ -353,7 +345,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move from module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.fake_external.module.a`, @@ -363,7 +355,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -373,7 +365,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move from resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.fake_external.test.thing`, @@ -384,7 +376,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move to resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `test.thing`, @@ -395,7 +387,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move from module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.fake_external.module.a`, @@ -406,7 +398,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move to module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.a`, @@ -417,7 +409,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to a call that refers to another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist`, @@ -427,7 +419,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, // This is okay because the call itself is not considered to be inside the package it refers to }, "move to instance of a call that refers to another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist`, @@ -438,7 +430,7 @@ Each resource can have moved from only one source resource.`, }, "crossing nested statements": { // overlapping nested moves will result in a cycle. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist.test.single`, `module.count[0].test.count[0]`, @@ -458,7 +450,7 @@ A chain of move statements must end with an address that doesn't appear in any o // we have to avoid a cycle because the nested moves appear in both // the from and to address of the parent when only the module index // is changing. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `count`, `test.count`, `test.count[0]`, @@ -473,7 +465,7 @@ A chain of move statements must end with an address that doesn't appear in any o // we have to avoid a cycle because the nested moves appear in both // the from and to address of the parent when only the module index // is changing. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `count`, `module.count`, `module.count[0]`, @@ -492,7 +484,7 @@ A chain of move statements must end with an address that doesn't appear in any o for name, test := range tests { t.Run(name, func(t *testing.T) { - gotDiags := ValidateMoves(test.Statements, rootCfg, instances) + gotDiags := refactoring.ValidateMoves(test.Statements, rootCfg, instances) switch { case test.WantError != "": @@ -511,149 +503,7 @@ A chain of move statements must end with an address that doesn't appear in any o } } -// loadRefactoringFixture reads a configuration from the given directory and -// does some naive static processing on any count and for_each expressions -// inside, in order to get a realistic-looking instances.Set for what it -// declares without having to run a full Terraform plan. -func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instances.Set) { - t.Helper() - - loader, cleanup := configload.NewLoaderForTests(t) - defer cleanup() - - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) - if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) - } - - // Since module installer has modified the module manifest on disk, we need - // to refresh the cache of it in the loader. - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - - // Note: This test uses BuildConfig instead of - // terraform.BuildConfigWithGraph to avoid an import cycle (terraform - // imports the refactoring package). Since this test only needs basic config - // structure without expression evaluation, the static loader is appropriate. - rootMod, diags := loader.LoadRootModule(dir) - if diags.HasErrors() { - t.Fatalf("invalid root module: %s", diags.Error()) - } - - rootCfg, buildDiags := configs.BuildConfig( - rootMod, - loader.ModuleWalker(), - configs.MockDataLoaderFunc(loader.LoadExternalMockData), - ) - if buildDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", buildDiags.Error()) - } - - expander := instances.NewExpander(nil) - staticPopulateExpanderModule(t, rootCfg, addrs.RootModuleInstance, expander) - return rootCfg, expander.AllInstances() -} - -func staticPopulateExpanderModule(t *testing.T, rootCfg *configs.Config, moduleAddr addrs.ModuleInstance, expander *instances.Expander) { - t.Helper() - - modCfg := rootCfg.DescendantForInstance(moduleAddr) - if modCfg == nil { - t.Fatalf("no configuration for %s", moduleAddr) - } - - if len(modCfg.Path) > 0 && modCfg.Path[len(modCfg.Path)-1] == "fake_external" { - // As a funny special case we modify the source address of this - // module to be something that counts as a separate package, - // so we can test rules relating to crossing package boundaries - // even though we really just loaded the module from a local path. - modCfg.SourceAddr = fakeExternalModuleSource - } - - for _, call := range modCfg.Module.ModuleCalls { - callAddr := addrs.ModuleCall{Name: call.Name} - - if call.Name == "fake_external" { - // As a funny special case we modify the source address of this - // module to be something that counts as a separate package, - // so we can test rules relating to crossing package boundaries - // even though we really just loaded the module from a local path. - call.SourceExpr = hcltest.MockExprLiteral(cty.StringVal(fakeExternalModuleSource.String())) - } - - // In order to get a valid, useful set of instances here we're going - // to just statically evaluate the count and for_each expressions. - // Normally it's valid to use references and functions there, but for - // our unit tests we'll just limit it to literal values to avoid - // bringing all of the core evaluator complexity. - switch { - case call.ForEach != nil: - val, diags := call.ForEach.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid for_each: %s", diags.Error()) - } - expander.SetModuleForEach(moduleAddr, callAddr, val.AsValueMap()) - case call.Count != nil: - val, diags := call.Count.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid count: %s", diags.Error()) - } - var count int - err := gocty.FromCtyValue(val, &count) - if err != nil { - t.Fatalf("invalid count at %s: %s", call.Count.Range(), err) - } - expander.SetModuleCount(moduleAddr, callAddr, count) - default: - expander.SetModuleSingle(moduleAddr, callAddr) - } - - // We need to recursively analyze the child modules too. - calledMod := modCfg.Path.Child(call.Name) - for _, inst := range expander.ExpandModule(calledMod, false) { - staticPopulateExpanderModule(t, rootCfg, inst, expander) - } - } - - for _, rc := range modCfg.Module.ManagedResources { - staticPopulateExpanderResource(t, moduleAddr, rc, expander) - } - for _, rc := range modCfg.Module.DataResources { - staticPopulateExpanderResource(t, moduleAddr, rc, expander) - } - -} - -func staticPopulateExpanderResource(t *testing.T, moduleAddr addrs.ModuleInstance, rCfg *configs.Resource, expander *instances.Expander) { - t.Helper() - - addr := rCfg.Addr() - switch { - case rCfg.ForEach != nil: - val, diags := rCfg.ForEach.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid for_each: %s", diags.Error()) - } - expander.SetResourceForEach(moduleAddr, addr, val.AsValueMap()) - case rCfg.Count != nil: - val, diags := rCfg.Count.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid count: %s", diags.Error()) - } - var count int - err := gocty.FromCtyValue(val, &count) - if err != nil { - t.Fatalf("invalid count at %s: %s", rCfg.Count.Range(), err) - } - expander.SetResourceCount(moduleAddr, addr, count) - default: - expander.SetResourceSingle(moduleAddr, addr) - } -} - -func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatement { +func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) refactoring.MoveStatement { t.Helper() module := addrs.RootModule @@ -684,7 +534,7 @@ func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatem t.Fatalf("incompatible move endpoints") } - return MoveStatement{ + return refactoring.MoveStatement{ From: fromInModule, To: toInModule, DeclRange: tfdiags.SourceRange{ @@ -695,13 +545,9 @@ func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatem } } -func makeTestImpliedMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatement { +func makeTestImpliedMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) refactoring.MoveStatement { t.Helper() ret := makeTestMoveStmt(t, moduleStr, fromStr, toStr) ret.Implied = true return ret } - -var fakeExternalModuleSource = addrs.ModuleSourceRemote{ - Package: addrs.ModulePackage("fake-external:///"), -} diff --git a/internal/refactoring/remove_statement_test.go b/internal/refactoring/remove_statement_test.go index 049e7aad36..e15ceaa15f 100644 --- a/internal/refactoring/remove_statement_test.go +++ b/internal/refactoring/remove_statement_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( "testing" @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -59,8 +60,8 @@ func TestFindRemoveStatements(t *testing.T) { configModuleInModule := addrs.Module{"child", "grandchild"} - want := addrs.MakeMap[addrs.ConfigMoveable, RemoveStatement]( - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceBasic, RemoveStatement{ + want := addrs.MakeMap[addrs.ConfigMoveable, refactoring.RemoveStatement]( + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceBasic, refactoring.RemoveStatement{ From: configResourceBasic, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -69,7 +70,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 2, Column: 8, Byte: 34}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceWithModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceWithModule, refactoring.RemoveStatement{ From: configResourceWithModule, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -78,7 +79,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 10, Column: 8, Byte: 145}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configModuleBasic, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configModuleBasic, refactoring.RemoveStatement{ From: configModuleBasic, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -87,7 +88,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 18, Column: 8, Byte: 260}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceOverridden, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceOverridden, refactoring.RemoveStatement{ From: configResourceOverridden, Destroy: true, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -96,7 +97,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 30, Column: 8, Byte: 435}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceInModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceInModule, refactoring.RemoveStatement{ From: configResourceInModule, Destroy: true, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -105,7 +106,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 10, Column: 8, Byte: 148}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configModuleInModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configModuleInModule, refactoring.RemoveStatement{ From: configModuleInModule, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -116,7 +117,7 @@ func TestFindRemoveStatements(t *testing.T) { }), ) - got, diags := FindRemoveStatements(rootCfg) + got, diags := refactoring.FindRemoveStatements(rootCfg) if diags.HasErrors() { t.Fatal(diags.Err().Error()) } diff --git a/internal/refactoring/testing_helpers.go b/internal/refactoring/testing_helpers.go new file mode 100644 index 0000000000..32d7b15750 --- /dev/null +++ b/internal/refactoring/testing_helpers.go @@ -0,0 +1,109 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package refactoring + +import ( + "testing" + + "github.com/hashicorp/hcl/v2/hcltest" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/instances" +) + +// FakeExternalModuleSource is used in tests to simulate an external module source. +var FakeExternalModuleSource = addrs.ModuleSourceRemote{ + Package: addrs.ModulePackage("example.com/test/fake"), +} + +// StaticPopulateExpanderModule populates an expander for testing by statically +// evaluating count and for_each expressions in a configuration. +// +// This is exported so that test code in package refactoring_test can use it +// without creating an import cycle with the terraform package. +func StaticPopulateExpanderModule(t *testing.T, rootCfg *configs.Config, moduleAddr addrs.ModuleInstance, expander *instances.Expander) { + t.Helper() + + modCfg := rootCfg.DescendantForInstance(moduleAddr) + if modCfg == nil { + t.Fatalf("no configuration for %s", moduleAddr) + } + + if len(modCfg.Path) > 0 && modCfg.Path[len(modCfg.Path)-1] == "fake_external" { + modCfg.SourceAddr = FakeExternalModuleSource + } + + for _, call := range modCfg.Module.ModuleCalls { + callAddr := addrs.ModuleCall{Name: call.Name} + + if call.Name == "fake_external" { + call.SourceExpr = hcltest.MockExprLiteral(cty.StringVal(FakeExternalModuleSource.String())) + } + + switch { + case call.ForEach != nil: + val, diags := call.ForEach.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid for_each: %s", diags.Error()) + } + expander.SetModuleForEach(moduleAddr, callAddr, val.AsValueMap()) + case call.Count != nil: + val, diags := call.Count.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid count: %s", diags.Error()) + } + var count int + err := gocty.FromCtyValue(val, &count) + if err != nil { + t.Fatalf("invalid count at %s: %s", call.Count.Range(), err) + } + expander.SetModuleCount(moduleAddr, callAddr, count) + default: + expander.SetModuleSingle(moduleAddr, callAddr) + } + + calledMod := modCfg.Path.Child(call.Name) + for _, inst := range expander.ExpandModule(calledMod, false) { + StaticPopulateExpanderModule(t, rootCfg, inst, expander) + } + } + + for _, rc := range modCfg.Module.ManagedResources { + StaticPopulateExpanderResource(t, moduleAddr, rc, expander) + } + for _, rc := range modCfg.Module.DataResources { + StaticPopulateExpanderResource(t, moduleAddr, rc, expander) + } +} + +// StaticPopulateExpanderResource populates resource instances in an expander for testing. +func StaticPopulateExpanderResource(t *testing.T, moduleAddr addrs.ModuleInstance, rCfg *configs.Resource, expander *instances.Expander) { + t.Helper() + + addr := rCfg.Addr() + switch { + case rCfg.ForEach != nil: + val, diags := rCfg.ForEach.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid for_each: %s", diags.Error()) + } + expander.SetResourceForEach(moduleAddr, addr, val.AsValueMap()) + case rCfg.Count != nil: + val, diags := rCfg.Count.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid count: %s", diags.Error()) + } + var count int + err := gocty.FromCtyValue(val, &count) + if err != nil { + t.Fatalf("invalid count at %s: %s", rCfg.Count.Range(), err) + } + expander.SetResourceCount(moduleAddr, addr, count) + default: + expander.SetResourceSingle(moduleAddr, addr) + } +} diff --git a/internal/refactoring/testing_test.go b/internal/refactoring/testing_test.go new file mode 100644 index 0000000000..1fab18b0b2 --- /dev/null +++ b/internal/refactoring/testing_test.go @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package refactoring_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" +) + +// loadRefactoringFixture reads a configuration from the given directory and +// does some naive static processing on any count and for_each expressions +// inside, in order to get a realistic-looking instances.Set for what it +// declares without having to run a full Terraform plan. +func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instances.Set) { + t.Helper() + + loader, cleanup := configload.NewLoaderForTests(t) + defer cleanup() + + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) + _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + if instDiags.HasErrors() { + t.Fatal(instDiags.Err()) + } + + if err := loader.RefreshModules(); err != nil { + t.Fatalf("failed to refresh modules after installation: %s", err) + } + + rootMod, diags := loader.LoadRootModule(dir) + if diags.HasErrors() { + t.Fatalf("invalid root module: %s", diags.Error()) + } + + rootCfg, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Err()) + } + + expander := instances.NewExpander(nil) + refactoring.StaticPopulateExpanderModule(t, rootCfg, addrs.RootModuleInstance, expander) + return rootCfg, expander.AllInstances() +} From ddefbdf5e4c17095f6f491868ae86ee0cf40623f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 2 Mar 2026 18:03:14 +0100 Subject: [PATCH 037/136] Replace static config loading in globalref tests --- .../analyzer_contributing_resources_test.go | 23 ++++++------ .../analyzer_meta_references_test.go | 5 ++- .../{analyzer_test.go => testing_test.go} | 37 +++++++++++++------ 3 files changed, 41 insertions(+), 24 deletions(-) rename internal/lang/globalref/{analyzer_test.go => testing_test.go} (75%) diff --git a/internal/lang/globalref/analyzer_contributing_resources_test.go b/internal/lang/globalref/analyzer_contributing_resources_test.go index 464bd5f0d7..0464648c42 100644 --- a/internal/lang/globalref/analyzer_contributing_resources_test.go +++ b/internal/lang/globalref/analyzer_contributing_resources_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "sort" @@ -10,17 +10,18 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/lang/globalref" ) func TestAnalyzerContributingResources(t *testing.T) { azr := testAnalyzer(t, "contributing-resources") tests := map[string]struct { - StartRefs func() []Reference + StartRefs func() []globalref.Reference WantAddrs []string }{ "root output 'network'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "network"}.Absolute(addrs.RootModuleInstance), ) @@ -32,7 +33,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "root output 'c10s_url'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "c10s_url"}.Absolute(addrs.RootModuleInstance), ) @@ -51,7 +52,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "module.compute.test_thing.load_balancer": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -68,7 +69,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "data.test_thing.environment": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.DataResourceMode, @@ -104,11 +105,11 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { azr := testAnalyzer(t, "contributing-resources") tests := map[string]struct { - StartRefs func() []Reference + StartRefs func() []globalref.Reference WantAttrs []string }{ "root output 'network'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "network"}.Absolute(addrs.RootModuleInstance), ) @@ -120,7 +121,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "root output 'c10s_url'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "c10s_url"}.Absolute(addrs.RootModuleInstance), ) @@ -133,7 +134,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "module.compute.test_thing.load_balancer": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -150,7 +151,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "data.test_thing.environment": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.DataResourceMode, diff --git a/internal/lang/globalref/analyzer_meta_references_test.go b/internal/lang/globalref/analyzer_meta_references_test.go index 164fc984ae..2b7b9c129a 100644 --- a/internal/lang/globalref/analyzer_meta_references_test.go +++ b/internal/lang/globalref/analyzer_meta_references_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "sort" @@ -9,6 +9,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/lang/globalref" ) func TestAnalyzerMetaReferences(t *testing.T) { @@ -152,7 +153,7 @@ func TestAnalyzerMetaReferences(t *testing.T) { t.Fatalf("input reference is invalid: %s", diags.Err()) } - ref := Reference{ + ref := globalref.Reference{ ContainerAddr: containerAddr, LocalRef: localRef, } diff --git a/internal/lang/globalref/analyzer_test.go b/internal/lang/globalref/testing_test.go similarity index 75% rename from internal/lang/globalref/analyzer_test.go rename to internal/lang/globalref/testing_test.go index 76a1190959..20a9c4a3bc 100644 --- a/internal/lang/globalref/analyzer_test.go +++ b/internal/lang/globalref/testing_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "context" @@ -15,11 +15,15 @@ import ( "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/lang/globalref" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" ) -func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { +// testAnalyzer creates an analyzer for testing by loading a configuration +// and setting up provider schemas. +func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { configDir := filepath.Join("testdata", fixtureName) loader, cleanup := configload.NewLoaderForTests(t) @@ -34,22 +38,19 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { t.Fatalf("failed to refresh modules after install: %s", err) } - // Note: This test uses BuildConfig instead of - // terraform.BuildConfigWithGraph to avoid an import cycle (terraform - // imports the lang package). Since this test only needs basic config - // structure without expression evaluation, the static loader is appropriate. rootMod, loadDiags := loader.LoadRootModule(configDir) if loadDiags.HasErrors() { t.Fatalf("invalid root module: %s", loadDiags.Error()) } - cfg, buildDiags := configs.BuildConfig( + cfg, buildDiags := terraform.BuildConfigWithGraph( rootMod, loader.ModuleWalker(), + nil, configs.MockDataLoaderFunc(loader.LoadExternalMockData), ) if buildDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", buildDiags.Error()) + t.Fatalf("invalid configuration: %s", buildDiags.Err()) } resourceTypeSchema := &configschema.Block{ @@ -83,6 +84,14 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { }, }, }, + "list_dynamic": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "z": {Type: cty.DynamicPseudoType, Optional: true}, + }, + }, + }, "map": { Nesting: configschema.NestingMap, Block: configschema.Block{ @@ -101,6 +110,13 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { }, }, } + dataSourceTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "string": {Type: cty.String, Optional: true}, + "number": {Type: cty.Number, Optional: true}, + "any": {Type: cty.DynamicPseudoType, Optional: true}, + }, + } schemas := map[addrs.Provider]providers.ProviderSchema{ addrs.MustParseProviderSourceString("hashicorp/test"): { ResourceTypes: map[string]providers.Schema{ @@ -110,11 +126,10 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { }, DataSources: map[string]providers.Schema{ "test_thing": { - Body: resourceTypeSchema, + Body: dataSourceTypeSchema, }, }, }, } - - return NewAnalyzer(cfg, schemas) + return globalref.NewAnalyzer(cfg, schemas) } From f14581f27ac90425c1f685c1f5af74bdd51e9531 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 5 Mar 2026 17:29:29 +0100 Subject: [PATCH 038/136] implement review feedback --- internal/checks/state_test.go | 4 +- internal/checks/testing_test.go | 50 ----- internal/lang/globalref/testing_test.go | 17 +- internal/terraform/config_graph_build_test.go | 176 ++++++++++++++++++ .../.terraform/modules/modules.json | 19 ++ .../child-provider-child-count.tf | 4 + .../child/child-provider-child-count-child.tf | 7 + .../child-provider-child-count-grandchild.tf | 1 + .../.terraform/modules/modules.json | 19 ++ .../child-provider-grandchild-count.tf | 3 + .../child-provider-grandchild-count-child.tf | 12 ++ ...ld-provider-grandchild-count-grandchild.tf | 1 + .../.terraform/modules/modules.json | 14 ++ .../invalid-names-in-submodules/main.tf | 3 + .../invalid-names-in-submodules/sub/main.tf | 7 + .../config-graph/invalid-names/main.tf | 3 + 16 files changed, 273 insertions(+), 67 deletions(-) delete mode 100644 internal/checks/testing_test.go create mode 100644 internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json create mode 100644 internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf create mode 100644 internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf create mode 100644 internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf create mode 100644 internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json create mode 100644 internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf create mode 100644 internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf create mode 100644 internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf create mode 100644 internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json create mode 100644 internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf create mode 100644 internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf create mode 100644 internal/terraform/testdata/config-graph/invalid-names/main.tf diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go index 38590a1697..3804679036 100644 --- a/internal/checks/state_test.go +++ b/internal/checks/state_test.go @@ -10,12 +10,14 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/checks" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func TestChecksHappyPath(t *testing.T) { const fixtureDir = "testdata/happypath" - cfg := LoadConfigForTests(t, fixtureDir, "tests") + cfg, _, configCleanup := tftesting.MustLoadConfigForTests(t, fixtureDir, "tests") + t.Cleanup(configCleanup) resourceA := addrs.Resource{ Mode: addrs.ManagedResourceMode, diff --git a/internal/checks/testing_test.go b/internal/checks/testing_test.go deleted file mode 100644 index 183ed4b9e6..0000000000 --- a/internal/checks/testing_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package checks_test - -import ( - "context" - "testing" - - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/initwd" - "github.com/hashicorp/terraform/internal/terraform" -) - -// LoadConfigForTests is a test helper that loads a configuration using -// terraform.BuildConfigWithGraph. This helper exists in the checks package -// so that tests can load configs without creating an import cycle. -func LoadConfigForTests(t *testing.T, fixtureDir, testsDir string) *configs.Config { - t.Helper() - - loader, close := configload.NewLoaderForTests(t) - defer close() - - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil, nil) - _, instDiags := inst.InstallModules(context.Background(), fixtureDir, testsDir, true, false, initwd.ModuleInstallHooksImpl{}) - if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) - } - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - - rootMod, hclDiags := loader.LoadRootModuleWithTests(fixtureDir, testsDir) - if hclDiags.HasErrors() { - t.Fatalf("invalid root module: %s", hclDiags.Error()) - } - - cfg, buildDiags := terraform.BuildConfigWithGraph( - rootMod, - loader.ModuleWalker(), - nil, // no input variables - configs.MockDataLoaderFunc(loader.LoadExternalMockData), - ) - if buildDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", buildDiags.Err()) - } - - return cfg -} diff --git a/internal/lang/globalref/testing_test.go b/internal/lang/globalref/testing_test.go index 20a9c4a3bc..9d6d843f21 100644 --- a/internal/lang/globalref/testing_test.go +++ b/internal/lang/globalref/testing_test.go @@ -84,14 +84,6 @@ func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { }, }, }, - "list_dynamic": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "z": {Type: cty.DynamicPseudoType, Optional: true}, - }, - }, - }, "map": { Nesting: configschema.NestingMap, Block: configschema.Block{ @@ -110,13 +102,6 @@ func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { }, }, } - dataSourceTypeSchema := &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "string": {Type: cty.String, Optional: true}, - "number": {Type: cty.Number, Optional: true}, - "any": {Type: cty.DynamicPseudoType, Optional: true}, - }, - } schemas := map[addrs.Provider]providers.ProviderSchema{ addrs.MustParseProviderSourceString("hashicorp/test"): { ResourceTypes: map[string]providers.Schema{ @@ -126,7 +111,7 @@ func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { }, DataSources: map[string]providers.Schema{ "test_thing": { - Body: dataSourceTypeSchema, + Body: resourceTypeSchema, }, }, }, diff --git a/internal/terraform/config_graph_build_test.go b/internal/terraform/config_graph_build_test.go index 1853ba3e5a..459be8004b 100644 --- a/internal/terraform/config_graph_build_test.go +++ b/internal/terraform/config_graph_build_test.go @@ -7,11 +7,15 @@ import ( "os" "path/filepath" "reflect" + "sort" + "strings" "testing" "github.com/davecgh/go-spew/spew" "github.com/go-test/deep" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/tfdiags" @@ -155,6 +159,178 @@ func TestSnapshotRoundtrip(t *testing.T) { } } +func TestBuildConfigWithGraph_okay(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + cfg, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if cfg == nil { + t.Fatalf("config is nil; want non-nil") + } + + var gotPaths []string + cfg.DeepEach(func(c *configs.Config) { + gotPaths = append(gotPaths, strings.Join(c.Path, ".")) + }) + sort.Strings(gotPaths) + wantPaths := []string{ + "", // root module + "child_a", + "child_a.child_c", + "child_b", + "child_b.child_d", + } + + if !reflect.DeepEqual(gotPaths, wantPaths) { + t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) + } + + t.Run("child_a.child_c output", func(t *testing.T) { + output := cfg.Children["child_a"].Children["child_c"].Module.Outputs["hello"] + got, diags := output.Expr.Value(nil) + assertNoDiagnostics(t, diags) + if !got.RawEquals(cty.StringVal("Hello from child_c")) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.StringVal("Hello from child_c")) + } + }) + t.Run("child_b.child_d output", func(t *testing.T) { + output := cfg.Children["child_b"].Children["child_d"].Module.Outputs["hello"] + got, diags := output.Expr.Value(nil) + assertNoDiagnostics(t, diags) + if !got.RawEquals(cty.StringVal("Hello from child_d")) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.StringVal("Hello from child_d")) + } + }) +} + +func TestBuildConfigWithGraph_loadDiags(t *testing.T) { + // building a config which didn't load correctly may cause configs to panic + fixtureDir := filepath.Clean("testdata/config-graph/invalid-names") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + rootMod, rootDiags := loader.LoadRootModule(fixtureDir) + if !rootDiags.HasErrors() { + t.Fatal("success; want error") + } + + if rootMod == nil { + t.Fatal("partial module not returned with diagnostics") + } +} + +func TestBuildConfigWithGraph_loadDiagsFromSubmodules(t *testing.T) { + // building a config which didn't load correctly may cause configs to panic + fixtureDir := filepath.Clean("testdata/config-graph/invalid-names-in-submodules") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + rootMod, rootDiags := loader.LoadRootModule(fixtureDir) + if rootDiags.HasErrors() { + t.Fatalf("unexpected root module load error: %s", rootDiags.Error()) + } + + _, diags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if !diags.HasErrors() { + t.Fatalf("loading succeeded; want an error") + } + if got, want := diags.Err().Error(), " Invalid provider local name"; !strings.Contains(got, want) { + t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) + } +} + +func TestBuildConfigWithGraph_childProviderGrandchildCount(t *testing.T) { + // This test is focused on the specific situation where: + // - A child module contains a nested provider block, which is no longer + // recommended but supported for backward-compatibility. + // - A child of that child does _not_ contain a nested provider block, + // and is called with "count" (would also apply to "for_each" and + // "depends_on"). + // It isn't valid to use "count" with a module that _itself_ contains + // a provider configuration, but it _is_ valid for a module with a + // provider configuration to call another module with count. We previously + // botched this rule and so this is a regression test to cover the + // solution to that mistake: + // https://github.com/hashicorp/terraform/issues/31081 + + // Since this test is based on success rather than failure and it's + // covering a relatively large set of code where only a small part + // contributes to the test, we'll make sure to test both the success and + // failure cases here so that we'll have a better chance of noticing if a + // future change makes this succeed only because we've reorganized the code + // so that the check isn't happening at all anymore. + // + // If the "not okay" subtest fails, you should also be skeptical about + // whether the "okay" subtest is still valid, even if it happens to + // still be passing. + t.Run("okay", func(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/child-provider-grandchild-count") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + cfg, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if cfg == nil { + t.Fatalf("config is nil; want non-nil") + } + + var gotPaths []string + cfg.DeepEach(func(c *configs.Config) { + gotPaths = append(gotPaths, strings.Join(c.Path, ".")) + }) + sort.Strings(gotPaths) + wantPaths := []string{ + "", // root module + "child", + "child.grandchild", + } + + if !reflect.DeepEqual(gotPaths, wantPaths) { + t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) + } + }) + t.Run("not okay", func(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/child-provider-child-count") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + if !diags.HasErrors() { + t.Fatalf("loading succeeded; want an error") + } + if got, want := diags.Err().Error(), "Module is incompatible with count, for_each, and depends_on"; !strings.Contains(got, want) { + t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) + } + }) +} + func assertNoDiagnostics[D hcl.Diagnostics | tfdiags.Diagnostics](t *testing.T, diags D) bool { t.Helper() diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json new file mode 100644 index 0000000000..5da6d5aac1 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json @@ -0,0 +1,19 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "child", + "Source": "./child", + "Dir": "testdata/config-graph/child-provider-child-count/child" + }, + { + "Key": "child.grandchild", + "Source": "../grandchild", + "Dir": "testdata/config-graph/child-provider-child-count/grandchild" + } + ] +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf new file mode 100644 index 0000000000..5b39941a03 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf @@ -0,0 +1,4 @@ +module "child" { + source = "./child" + count = 1 +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf new file mode 100644 index 0000000000..524742c3fc --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf @@ -0,0 +1,7 @@ +provider "boop" { + blah = true +} + +module "grandchild" { + source = "../grandchild" +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf new file mode 100644 index 0000000000..ccd9dcef9e --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf @@ -0,0 +1 @@ +# Intentionally blank diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json new file mode 100644 index 0000000000..c70acf81dd --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json @@ -0,0 +1,19 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "child", + "Source": "./child", + "Dir": "testdata/config-graph/child-provider-grandchild-count/child" + }, + { + "Key": "child.grandchild", + "Source": "../grandchild", + "Dir": "testdata/config-graph/child-provider-grandchild-count/grandchild" + } + ] +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf new file mode 100644 index 0000000000..1f95749fa7 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf new file mode 100644 index 0000000000..8d3fe1023d --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf @@ -0,0 +1,12 @@ +provider "boop" { + blah = true +} + +module "grandchild" { + source = "../grandchild" + + # grandchild's caller (this file) has a legacy nested provider block, but + # grandchild itself does not and so it's valid to use "count" here even + # though it wouldn't be valid to call "child" (this file) with "count". + count = 2 +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf new file mode 100644 index 0000000000..ccd9dcef9e --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf @@ -0,0 +1 @@ +# Intentionally blank diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json new file mode 100644 index 0000000000..5f253d5439 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json @@ -0,0 +1,14 @@ +{ + "Modules": [ + { + "Key": "test", + "Source": "./sub", + "Dir": "testdata/config-graph/invalid-names-in-submodules/sub" + }, + { + "Key": "", + "Source": "", + "Dir": "." + } + ] +} \ No newline at end of file diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf new file mode 100644 index 0000000000..3fbc8c68cf --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf @@ -0,0 +1,3 @@ +module "test" { + source = "./sub" +} diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf new file mode 100644 index 0000000000..aacab2c441 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf @@ -0,0 +1,7 @@ +resource "aws-_foo" "test" { + +} + +data "aws-_bar" "test" { + +} diff --git a/internal/terraform/testdata/config-graph/invalid-names/main.tf b/internal/terraform/testdata/config-graph/invalid-names/main.tf new file mode 100644 index 0000000000..d4eee4c3e2 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names/main.tf @@ -0,0 +1,3 @@ +provider "42_bad!" { + invalid_provider_name = "yes" +} From 547473364c402d13e34c54dc20be419209b9c701 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 13 Jan 2026 12:57:39 +0100 Subject: [PATCH 039/136] Expose Action Invocation conversion to Proto publicly --- internal/plans/planfile/tfplan.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 5e430c59ce..05896b7a86 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -26,8 +26,10 @@ import ( "github.com/hashicorp/terraform/version" ) -const tfplanFormatVersion = 3 -const tfplanFilename = "tfplan" +const ( + tfplanFormatVersion = 3 + tfplanFilename = "tfplan" +) // --------------------------------------------------------------------------- // This file deals with the internal structure of the "tfplan" sub-file within @@ -410,7 +412,6 @@ func ActionFromProto(rawAction planproto.Action) (plans.Action, error) { default: return plans.NoOp, fmt.Errorf("invalid change action %s", rawAction) } - } func changeFromTfplan(rawChange *planproto.Change) (*plans.ChangeSrc, error) { @@ -1448,3 +1449,12 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp return ret, nil } + +// ActionInvocationToProto encodes an action invocation from its internal +// representation into the protobuf representation for persistence. +// +// This is a public wrapper around actionInvocationToTfPlan for use by +// external packages like stackplan. +func ActionInvocationToProto(action *plans.ActionInvocationInstanceSrc) (*planproto.ActionInvocationInstance, error) { + return actionInvocationToTfPlan(action) +} From eeb0f7218e3418124b34de3e40884acca68f3137 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 13 Jan 2026 13:20:38 +0100 Subject: [PATCH 040/136] Include action invocations when reading from tfplan --- internal/stacks/stackplan/from_plan.go | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/stacks/stackplan/from_plan.go b/internal/stacks/stackplan/from_plan.go index cd6ab1e6f1..0d49147892 100644 --- a/internal/stacks/stackplan/from_plan.go +++ b/internal/stacks/stackplan/from_plan.go @@ -37,6 +37,9 @@ type PlanProducer interface { // ResourceSchema returns the schema for a resource type from a provider. ResourceSchema(ctx context.Context, providerTypeAddr addrs.Provider, mode addrs.ResourceMode, resourceType string) (providers.Schema, error) + + // ActionSchema returns the schema for an action type from a provider. + ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) } func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, refreshPlan *plans.Plan, action plans.Action, producer PlanProducer) ([]PlannedChange, tfdiags.Diagnostics) { @@ -174,6 +177,36 @@ func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, ref seenObjects.Add(objAddr) } + // Handle action invocations from the plan + for _, actionChange := range plan.Changes.ActionInvocations { + schema, err := producer.ActionSchema( + ctx, + actionChange.ProviderAddr.Provider, + actionChange.Addr.Action.Action.Type, + ) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Can't fetch provider schema to save plan", + fmt.Sprintf( + "Failed to retrieve the schema for %s from provider %s: %s. This is a bug in Terraform.", + actionChange.Addr, actionChange.ProviderAddr.Provider, err, + ), + )) + continue + } + + changes = append(changes, &PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: producer.Addr(), + Item: actionChange.Addr, + }, + Invocation: actionChange, + Schema: schema, + ProviderConfigAddr: actionChange.ProviderAddr, + }) + } + // We also need to catch any objects that exist in the "prior state" // but don't have any actions planned, since we still need to capture // the prior state part in case it was updated by refreshing during From 946918220ceabc1e4c9d79aae175687331931d6e Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 13 Jan 2026 14:29:05 +0100 Subject: [PATCH 041/136] Read Action invocations from planfile --- internal/plans/planfile/tfplan.go | 2 +- internal/stacks/stackplan/planned_change.go | 156 +++++++++++++++++- .../stacks/stackplan/planned_change_test.go | 114 +++++++++++++ .../internal/stackeval/component_instance.go | 14 ++ .../stackeval/removed_component_instance.go | 14 ++ 5 files changed, 298 insertions(+), 2 deletions(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 05896b7a86..5246f28d68 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1403,7 +1403,7 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp ret := &planproto.ActionInvocationInstance{ Addr: action.Addr.String(), - Provider: action.ProviderAddr.String(), + Provider: action.ProviderAddr.Provider.String(), } switch at := action.ActionTrigger.(type) { diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index 7c01bab437..1332076077 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" @@ -493,7 +494,6 @@ func (pc *PlannedChangeResourceInstancePlanned) ChangeDescription() (*stacks.Pla }, }, }, nil - } func DynamicValueToTerraform1(val cty.Value, ty cty.Type) (*stacks.DynamicValue, error) { @@ -854,3 +854,157 @@ func (pc *PlannedChangeProviderFunctionResults) PlannedChangeProto() (*stacks.Pl Raw: []*anypb.Any{&raw}, }, nil } + +// PlannedChangeActionInvocationInstancePlanned represents a planned action +// invocation within a component instance. +type PlannedChangeActionInvocationInstancePlanned struct { + ActionInvocationAddr stackaddrs.AbsActionInvocationInstance + + // Invocation describes the planned invocation. + Invocation *plans.ActionInvocationInstanceSrc + + // ProviderConfigAddr is the address of the provider configuration + // that planned this change, resolved in terms of the configuration for + // the component this action invocation belongs to. + ProviderConfigAddr addrs.AbsProviderConfig + + // Schema MUST be the same schema that was used to encode the dynamic + // values inside Invocation. + // + // Can be empty if and only if Invocation is nil. + Schema providers.ActionSchema +} + +var _ PlannedChange = (*PlannedChangeActionInvocationInstancePlanned)(nil) + +// PlanActionInvocationProto converts the planned action invocation to the +// internal protobuf representation for persistence. +func (pc *PlannedChangeActionInvocationInstancePlanned) PlanActionInvocationProto() (*tfstackdata1.PlanActionInvocationPlanned, error) { + addr := pc.ActionInvocationAddr + + if pc.Invocation == nil { + // Return a minimal placeholder if there's no actual invocation + return &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: addr.Component.String(), + ActionInvocationAddr: addr.Item.String(), + ProviderConfigAddr: pc.ProviderConfigAddr.Provider.String(), + }, nil + } + + invocationProto, err := planfile.ActionInvocationToProto(pc.Invocation) + if err != nil { + return nil, fmt.Errorf("converting action invocation to proto: %w", err) + } + + return &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: addr.Component.String(), + ActionInvocationAddr: addr.Item.String(), + ProviderConfigAddr: pc.ProviderConfigAddr.Provider.String(), + Invocation: invocationProto, + }, nil +} + +// ChangeDescription implements PlannedChange by producing an external +// description of the action invocation for the RPC API. +func (pc *PlannedChangeActionInvocationInstancePlanned) ChangeDescription() (*stacks.PlannedChange_ChangeDescription, error) { + addr := pc.ActionInvocationAddr + + // We only emit an external description if there's an invocation to describe. + if pc.Invocation == nil { + return nil, nil + } + + invoke := stacks.PlannedChange_ActionInvocationInstance{ + Addr: stacks.NewActionInvocationInStackAddr(addr), + ProviderAddr: pc.Invocation.ProviderAddr.Provider.String(), + ActionType: pc.Invocation.Addr.Action.Action.Type, + + ConfigValue: stacks.NewDynamicValue( + pc.Invocation.ConfigValue, + pc.Invocation.SensitiveConfigPaths, + ), + } + + // Convert the action trigger information + switch at := pc.Invocation.ActionTrigger.(type) { + case *plans.LifecycleActionTrigger: + triggerEvent := stacks.PlannedChange_INVALID_EVENT + switch at.ActionTriggerEvent { + case configs.BeforeCreate: + triggerEvent = stacks.PlannedChange_BEFORE_CREATE + case configs.AfterCreate: + triggerEvent = stacks.PlannedChange_AFTER_CREATE + case configs.BeforeUpdate: + triggerEvent = stacks.PlannedChange_BEFORE_UPDATE + case configs.AfterUpdate: + triggerEvent = stacks.PlannedChange_AFTER_UPDATE + case configs.BeforeDestroy: + triggerEvent = stacks.PlannedChange_BEFORE_DESTROY + case configs.AfterDestroy: + triggerEvent = stacks.PlannedChange_AFTER_DESTROY + } + + invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_LifecycleActionTrigger{ + LifecycleActionTrigger: &stacks.PlannedChange_LifecycleActionTrigger{ + TriggerEvent: triggerEvent, + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr(stackaddrs.AbsResourceInstance{ + Component: addr.Component, + Item: at.TriggeringResourceAddr, + }), + ActionTriggerBlockIndex: int64(at.ActionTriggerBlockIndex), + ActionsListIndex: int64(at.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + // TODO Implement this when implementing Stacks support for Direct Action Invocation + invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.PlannedChange_InvokeActionTrigger{}, + } + default: + // This should be exhaustive + return nil, fmt.Errorf("unsupported action trigger type: %T", at) + } + + return &stacks.PlannedChange_ChangeDescription{ + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationPlanned{ + ActionInvocationPlanned: &invoke, + }, + }, nil +} + +// PlannedChangeProto implements PlannedChange. +func (pc *PlannedChangeActionInvocationInstancePlanned) PlannedChangeProto() (*stacks.PlannedChange, error) { + paip, err := pc.PlanActionInvocationProto() + if err != nil { + return nil, err + } + var raw anypb.Any + err = anypb.MarshalFrom(&raw, paip, proto.MarshalOptions{}) + if err != nil { + return nil, err + } + + if pc.Invocation == nil { + // We only emit a "raw" in this case, because this is a relatively + // uninteresting edge-case. The PlanActionInvocationProto + // function should have returned a placeholder value for this use case. + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + }, nil + } + + var descs []*stacks.PlannedChange_ChangeDescription + desc, err := pc.ChangeDescription() + if err != nil { + return nil, err + } + if desc != nil { + descs = append(descs, desc) + } + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + Descriptions: descs, + }, nil +} diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index b9b920e3c6..2a048ab3bd 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -16,6 +16,8 @@ import ( "google.golang.org/protobuf/types/known/anypb" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planproto" @@ -927,6 +929,118 @@ func TestPlannedChangeAsProto(t *testing.T) { }, }, }, + "action invocation lifecycle trigger": { + Receiver: &PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "web"}, + }, + }, + Item: addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "webhook", + Name: "notify", + }, + Key: addrs.NoKey, + }, + }, + }, + ProviderConfigAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("example.com/webhooks/http"), + }, + Invocation: &plans.ActionInvocationInstanceSrc{ + Addr: addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "webhook", + Name: "notify", + }, + Key: addrs.NoKey, + }, + }, + ActionTrigger: &plans.LifecycleActionTrigger{ + TriggeringResourceAddr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "example_resource", + Name: "main", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + ConfigValue: emptyObjectForPlan, + ProviderAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("example.com/webhooks/http"), + }, + }, + Schema: providers.ActionSchema{ + ConfigSchema: &configschema.Block{ + }, + }, + }, + Want: &stacks.PlannedChange{ + Raw: []*anypb.Any{ + mustMarshalAnyPb(&tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: "action.webhook.notify", + ProviderConfigAddr: "example.com/webhooks/http", + Invocation: &planproto.ActionInvocationInstance{ + Addr: "action.webhook.notify", + Provider: "example.com/webhooks/http", + ActionTrigger: &planproto.ActionInvocationInstance_LifecycleActionTrigger{ + LifecycleActionTrigger: &planproto.LifecycleActionTrigger{ + TriggeringResourceAddr: "example_resource.main", + TriggerEvent: planproto.ActionTriggerEvent_AFTER_CREATE, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + ConfigValue: &planproto.DynamicValue{ + Msgpack: emptyObjectForPlan, + }, + }, + }), + }, + Descriptions: []*stacks.PlannedChange_ChangeDescription{ + { + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationPlanned{ + ActionInvocationPlanned: &stacks.PlannedChange_ActionInvocationInstance{ + Addr: &stacks.ActionInvocationInstanceInStackAddr{ + ComponentInstanceAddr: "component.web", + ActionInvocationInstanceAddr: "action.webhook.notify", + }, + ProviderAddr: "example.com/webhooks/http", + ActionType: "webhook", + ConfigValue: &stacks.DynamicValue{ + Msgpack: emptyObjectForPlan, + }, + ActionTrigger: &stacks.PlannedChange_ActionInvocationInstance_LifecycleActionTrigger{ + LifecycleActionTrigger: &stacks.PlannedChange_LifecycleActionTrigger{ + TriggeringResourceAddress: &stacks.ResourceInstanceInStackAddr{ + ComponentInstanceAddr: "component.web", + ResourceInstanceAddr: "example_resource.main", + }, + TriggerEvent: stacks.PlannedChange_AFTER_CREATE, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + }, + }, + }, + }, + }, + }, + // TODO: Add test for "action invocation invoke trigger" when implementing + // direct action invocation in a later iteration. The InvokeActionTrigger + // case is mentioned in the interface for completeness but not yet implemented. } for name, test := range tests { diff --git a/internal/stacks/stackruntime/internal/stackeval/component_instance.go b/internal/stacks/stackruntime/internal/stackeval/component_instance.go index 8b0cfb8d8e..b1ea7ac257 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_instance.go @@ -814,6 +814,20 @@ func (c *ComponentInstance) ResourceSchema(ctx context.Context, providerTypeAddr return ret, nil } +// ActionSchema implements stackplan.PlanProducer. +func (c *ComponentInstance) ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) { + providerType := c.main.ProviderType(providerTypeAddr) + providerSchema, err := providerType.Schema(ctx) + if err != nil { + return providers.ActionSchema{}, err + } + ret := providerSchema.SchemaForActionType(actionType) + if ret.ConfigSchema == nil { + return providers.ActionSchema{}, fmt.Errorf("schema does not include action type %q", actionType) + } + return ret, nil +} + // RequiredComponents implements stackplan.PlanProducer. func (c *ComponentInstance) RequiredComponents(ctx context.Context) collections.Set[stackaddrs.AbsComponent] { return c.call.RequiredComponents(ctx) diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go index 9686a96895..2ea52af215 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go @@ -363,6 +363,20 @@ func (r *RemovedComponentInstance) ResourceSchema(ctx context.Context, providerT return ret, nil } +// ActionSchema implements stackplan.PlanProducer. +func (r *RemovedComponentInstance) ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) { + providerType := r.main.ProviderType(providerTypeAddr) + providerSchema, err := providerType.Schema(ctx) + if err != nil { + return providers.ActionSchema{}, err + } + ret := providerSchema.SchemaForActionType(actionType) + if ret.ConfigSchema == nil { + return providers.ActionSchema{}, fmt.Errorf("schema does not include action type %q", actionType) + } + return ret, nil +} + // tracingName implements Plannable. func (r *RemovedComponentInstance) tracingName() string { return r.Addr().String() + " (removed)" From cb3dfa615f50bc0cf0adbd9746ccc489d8d72deb Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 13 Jan 2026 14:50:42 +0100 Subject: [PATCH 042/136] Add integration test for stacks action invocation via lifecycle trigger --- internal/stacks/stackplan/from_proto.go | 3 + .../internal/stackeval/planning_test.go | 136 ++++++++++++++++++ .../action-lifecycle.tfcomponent.hcl | 24 ++++ .../action_lifecycle/module_web/main.tf | 31 ++++ 4 files changed, 194 insertions(+) create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf diff --git a/internal/stacks/stackplan/from_proto.go b/internal/stacks/stackplan/from_proto.go index 6e2990b8fd..31fd11c97b 100644 --- a/internal/stacks/stackplan/from_proto.go +++ b/internal/stacks/stackplan/from_proto.go @@ -301,6 +301,9 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { DeferredReason: deferredReason, }) + case *tfstackdata1.PlanActionInvocationPlanned: + // TODO: Implemented in a future apply-related PR. + default: // Should not get here, because a stack plan can only be loaded by // the same version of Terraform that created it, and the above diff --git a/internal/stacks/stackruntime/internal/stackeval/planning_test.go b/internal/stacks/stackruntime/internal/stackeval/planning_test.go index 62d1595840..c05d290546 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning_test.go @@ -1053,3 +1053,139 @@ func mustPlanDynamicValue(t *testing.T, v cty.Value) *tfstackdata1.DynamicValue } return tfstackdata1.Terraform1ToStackDataDynamicValue(ret) } + +func TestPlanning_ActionInvocationLifecycle(t *testing.T) { + // This integration test verifies that action invocations with lifecycle + // triggers are correctly planned and included in the PlannedChange objects. + + cfg := testStackConfig(t, "planning", "action_lifecycle") + componentInstAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{ + Name: "web", + }, + }, + } + actionInstAddr := addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "test_action", + Name: "notify", + }, + Key: addrs.NoKey, + }, + } + providerAddr := addrs.NewBuiltInProvider("test") + providerInstAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: providerAddr, + } + + resourceTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + } + actionTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "message": { + Type: cty.String, + Required: true, + }, + }, + } + + main := NewForPlanning(cfg, stackstate.NewState(), PlanOpts{ + PlanningMode: plans.NormalMode, + PlanTimestamp: time.Now().UTC(), + ProviderFactories: ProviderFactories{ + addrs.NewBuiltInProvider("test"): func() (providers.Interface, error) { + return &providerTesting.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{ + Body: &configschema.Block{}, + }, + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Body: resourceTypeSchema, + }, + }, + Actions: map[string]providers.ActionSchema{ + "test_action": { + ConfigSchema: actionTypeSchema, + }, + }, + }, + ConfigureProviderFn: func(cpr providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { + return providers.ConfigureProviderResponse{} + }, + PlanResourceChangeFn: func(prcr providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: prcr.ProposedNewState, + } + }, + }, nil + }, + }, + }) + + outp, outpTest := testPlanOutput(t) + main.PlanAll(context.Background(), outp) + plan, diags := outpTest.Close(t) + assertNoDiagnostics(t, diags) + + cmpPlan := plan.GetComponent(componentInstAddr) + if cmpPlan == nil { + t.Fatalf("no plan for %s", componentInstAddr) + } + + // Verify that we have planned changes for action invocations + plannedChanges := outpTest.PlannedChanges() + var foundActionChange *stackplan.PlannedChangeActionInvocationInstancePlanned + for _, pc := range plannedChanges { + if actionChange, ok := pc.(*stackplan.PlannedChangeActionInvocationInstancePlanned); ok { + foundActionChange = actionChange + break + } + } + + if foundActionChange == nil { + t.Fatalf("no action invocation planned change found; got %d changes", len(plannedChanges)) + } + + // Verify the action invocation details + if got, want := foundActionChange.ActionInvocationAddr.Component.String(), componentInstAddr.String(); got != want { + t.Errorf("wrong component instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ActionInvocationAddr.Item.String(), actionInstAddr.String(); got != want { + t.Errorf("wrong action instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ProviderConfigAddr.String(), providerInstAddr.String(); got != want { + t.Errorf("wrong provider config addr\ngot: %s\nwant: %s", got, want) + } + + // Verify the invocation has the correct trigger type + if foundActionChange.Invocation == nil { + t.Fatal("invocation is nil") + } + if _, ok := foundActionChange.Invocation.ActionTrigger.(*plans.LifecycleActionTrigger); !ok { + t.Errorf("wrong action trigger type\ngot: %T\nwant: *plans.LifecycleActionTrigger", foundActionChange.Invocation.ActionTrigger) + } + + // Verify we can convert to proto successfully + protoChange, err := foundActionChange.PlannedChangeProto() + if err != nil { + t.Fatalf("failed to convert to proto: %s", err) + } + if protoChange == nil { + t.Fatal("proto change is nil") + } + if len(protoChange.Descriptions) == 0 { + t.Error("expected at least one description in proto change") + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl new file mode 100644 index 0000000000..b43965bb65 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl @@ -0,0 +1,24 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + test = { + source = "terraform.io/builtin/test" + } +} + +provider "test" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + test = provider.test.main + } +} + +output "result" { + type = string + value = component.web.result +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf new file mode 100644 index 0000000000..1d1a4d191a --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf @@ -0,0 +1,31 @@ + +terraform { + required_providers { + test = { + source = "terraform.io/builtin/test" + + configuration_aliases = [ test ] + } + } +} + +action "test_action" "notify" { + config { + message = "resource created" + } +} + +resource "test_resource" "main" { + value = "example" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.test_action.notify] + } + } +} + +output "result" { + value = test_resource.main.value +} From 8f137435b78b986e656d4523ce790b2e78c22666 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Wed, 21 Jan 2026 14:28:57 +0100 Subject: [PATCH 043/136] Run formatter --- internal/stacks/stackplan/planned_change_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index 2a048ab3bd..e3fe6b58b9 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -981,8 +981,7 @@ func TestPlannedChangeAsProto(t *testing.T) { }, }, Schema: providers.ActionSchema{ - ConfigSchema: &configschema.Block{ - }, + ConfigSchema: &configschema.Block{}, }, }, Want: &stacks.PlannedChange{ From a876afb6ca69b2acaa804754fb15c7ea983e429e Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 27 Jan 2026 12:51:59 +0100 Subject: [PATCH 044/136] Send ActionInvocation counts in component report --- internal/rpcapi/stacks.go | 34 ++++++++++--------- internal/rpcapi/stacks_test.go | 21 ++++++++++-- .../stackruntime/hooks/component_instance.go | 19 ++++++----- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index ca1e05783d..601ad56f26 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1241,14 +1241,15 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou ComponentAddr: stackaddrs.ConfigComponentForAbsInstance(cic.Addr).String(), ComponentInstanceAddr: cic.Addr.String(), }, - Total: int32(cic.Total()), - Add: int32(cic.Add), - Change: int32(cic.Change), - Import: int32(cic.Import), - Remove: int32(cic.Remove), - Defer: int32(cic.Defer), - Move: int32(cic.Move), - Forget: int32(cic.Forget), + Total: int32(cic.Total()), + Add: int32(cic.Add), + Change: int32(cic.Change), + Import: int32(cic.Import), + Remove: int32(cic.Remove), + Defer: int32(cic.Defer), + Move: int32(cic.Move), + Forget: int32(cic.Forget), + ActionInvocation: int32(cic.ActionInvocation), }, }, }) @@ -1268,14 +1269,15 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou ComponentAddr: stackaddrs.ConfigComponentForAbsInstance(cic.Addr).String(), ComponentInstanceAddr: cic.Addr.String(), }, - Total: int32(cic.Total()), - Add: int32(cic.Add), - Change: int32(cic.Change), - Import: int32(cic.Import), - Remove: int32(cic.Remove), - Defer: int32(cic.Defer), - Move: int32(cic.Move), - Forget: int32(cic.Forget), + Total: int32(cic.Total()), + Add: int32(cic.Add), + Change: int32(cic.Change), + Import: int32(cic.Import), + Remove: int32(cic.Remove), + Defer: int32(cic.Defer), + Move: int32(cic.Move), + Forget: int32(cic.Forget), + ActionInvocation: int32(cic.ActionInvocation), }, }, }) diff --git a/internal/rpcapi/stacks_test.go b/internal/rpcapi/stacks_test.go index 676da2a4ed..9181fc60b5 100644 --- a/internal/rpcapi/stacks_test.go +++ b/internal/rpcapi/stacks_test.go @@ -640,7 +640,6 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { }, want: []*stacks.StackChangeProgress{ { - Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ Addr: &stacks.ComponentInstanceInStackAddr{ @@ -695,7 +694,8 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { ProviderAddr: "registry.terraform.io/hashicorp/testing", }, }, - }, { + }, + { Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ Addr: &stacks.ComponentInstanceInStackAddr{ @@ -977,6 +977,22 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { ComponentInstanceAddr: "component.self", }, Status: stacks.StackChangeProgress_ComponentInstanceStatus_PLANNED, + "action_invocations": { + // This test verifies that the ActionInvocation field exists in ComponentInstanceChanges + // and is included in the total count. Once we implement action invocation tracking logic, + // this field will have a value > 0 for components with actions. + source: "git::https://example.com/action_invocations.git", + want: []*stacks.StackChangeProgress{ + { + Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ + ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ + Addr: &stacks.ComponentInstanceInStackAddr{ + ComponentAddr: "component.self", + ComponentInstanceAddr: "component.self", + }, + Total: 2, + Add: 1, + ActionInvocation: 1, }, }, }, @@ -1896,7 +1912,6 @@ func TestStacksMigrateTerraformState(t *testing.T) { }, }, }) - if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/internal/stacks/stackruntime/hooks/component_instance.go b/internal/stacks/stackruntime/hooks/component_instance.go index 5b0f2bb729..24b55c02f5 100644 --- a/internal/stacks/stackruntime/hooks/component_instance.go +++ b/internal/stacks/stackruntime/hooks/component_instance.go @@ -53,14 +53,15 @@ func (s ComponentInstanceStatus) ForProtobuf() stacks.StackChangeProgress_Compon // ComponentInstanceChange is the argument type for hook callbacks which // signal a set of planned or applied changes for a component instance. type ComponentInstanceChange struct { - Addr stackaddrs.AbsComponentInstance - Add int - Change int - Import int - Remove int - Defer int - Move int - Forget int + Addr stackaddrs.AbsComponentInstance + Add int + Change int + Import int + Remove int + Defer int + Move int + Forget int + ActionInvocation int } // Total sums all of the change counts as a forwards-compatibility measure. If @@ -68,7 +69,7 @@ type ComponentInstanceChange struct { // that the component instance has some unknown changes, rather than falsely // stating that there are no changes at all. func (cic ComponentInstanceChange) Total() int { - return cic.Add + cic.Change + cic.Import + cic.Remove + cic.Defer + cic.Move + cic.Forget + return cic.Add + cic.Change + cic.Import + cic.Remove + cic.Defer + cic.Move + cic.Forget + cic.ActionInvocation } // CountNewAction increments zero or more of the count fields based on the From 0cb9a689d7fb4ab6cd73f83a2e715702b45dd801 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 27 Jan 2026 12:53:35 +0100 Subject: [PATCH 045/136] Add tests for action invocation counts --- .../action_invocations/action_invocations.tf | 26 ++++++++++++++ .../action_invocations.tfcomponent.hcl | 15 ++++++++ .../sourcebundle/terraform-sources.json | 5 +++ .../internal/stackeval/planning.go | 5 +++ .../stacks/stackruntime/testing/provider.go | 34 +++++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf create mode 100644 internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl diff --git a/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf new file mode 100644 index 0000000000..dbf571a4ee --- /dev/null +++ b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +resource "testing_resource" "this" { + id = "test" + value = "hello" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.example] + } + } +} + +action "testing_action" "example" { + config { + message = "Test action invocation" + } +} diff --git a/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl new file mode 100644 index 0000000000..00ac98d1ca --- /dev/null +++ b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl @@ -0,0 +1,15 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +component "self" { + source = "./" + providers = { + testing = provider.testing.default + } +} diff --git a/internal/rpcapi/testdata/sourcebundle/terraform-sources.json b/internal/rpcapi/testdata/sourcebundle/terraform-sources.json index 6421282d51..55b96909d0 100644 --- a/internal/rpcapi/testdata/sourcebundle/terraform-sources.json +++ b/internal/rpcapi/testdata/sourcebundle/terraform-sources.json @@ -45,6 +45,11 @@ "source": "git::https://example.com/empty.git", "local": "empty", "meta": {} + }, + { + "source": "git::https://example.com/action_invocations.git", + "local": "action_invocations", + "meta": {} } ] } diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index cbcb9d6ba5..3dc2c31b88 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -104,6 +104,11 @@ func ReportComponentInstance(ctx context.Context, plan *plans.Plan, h *Hooks, se }, }) } + + // Count action invocations + for range plan.Changes.ActionInvocations { + cic.ActionInvocation++ + } hookMore(ctx, seq, h.ReportComponentInstancePlanned, cic) } diff --git a/internal/stacks/stackruntime/testing/provider.go b/internal/stacks/stackruntime/testing/provider.go index 2359489bb6..cd30b4396b 100644 --- a/internal/stacks/stackruntime/testing/provider.go +++ b/internal/stacks/stackruntime/testing/provider.go @@ -113,6 +113,14 @@ var ( Nesting: configschema.NestingSingle, }, } + + TestingActionSchema = providers.ActionSchema{ + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "message": {Type: cty.String, Optional: true}, + }, + }, + } ) // MockProvider wraps the standard MockProvider with a simple in-memory @@ -223,6 +231,9 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { ReturnType: cty.DynamicPseudoType, }, }, + Actions: map[string]providers.ActionSchema{ + "testing_action": TestingActionSchema, + }, ServerCapabilities: providers.ServerCapabilities{ MoveResourceState: true, }, @@ -322,6 +333,29 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { }), } }, + PlanActionFn: func(request providers.PlanActionRequest) providers.PlanActionResponse { + // Simple action planning - no drift, just validation + return providers.PlanActionResponse{ + Diagnostics: tfdiags.Diagnostics{}, + } + }, + InvokeActionFn: func(request providers.InvokeActionRequest) providers.InvokeActionResponse { + // Simple action invocation - just emit a completed event + return providers.InvokeActionResponse{ + Events: func(yield func(providers.InvokeActionEvent) bool) { + yield(providers.InvokeActionEvent_Completed{ + Diagnostics: tfdiags.Diagnostics{}, + }) + }, + Diagnostics: tfdiags.Diagnostics{}, + } + }, + ValidateActionConfigFn: func(request providers.ValidateActionConfigRequest) providers.ValidateActionConfigResponse { + // No validation errors for testing actions + return providers.ValidateActionConfigResponse{ + Diagnostics: tfdiags.Diagnostics{}, + } + }, }, ResourceStore: store, } From d653c99eda56d254f6be915c945c232b48d7e737 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Mon, 2 Feb 2026 16:31:16 +0100 Subject: [PATCH 046/136] Add action invocation to the actual planned changes for the component --- .../rpcapi/dependencies_provider_schema.go | 13 +++++ internal/rpcapi/stacks.go | 53 ++++++++++++++++++- .../stackruntime/hooks/resource_instance.go | 6 +++ .../stackruntime/internal/stackeval/hooks.go | 2 + .../internal/stackeval/planning.go | 13 +++-- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/internal/rpcapi/dependencies_provider_schema.go b/internal/rpcapi/dependencies_provider_schema.go index 95dd517d47..812bd83999 100644 --- a/internal/rpcapi/dependencies_provider_schema.go +++ b/internal/rpcapi/dependencies_provider_schema.go @@ -141,6 +141,7 @@ func providerSchemaToProto(schemaResp providers.GetProviderSchemaResponse) *depe mrtSchemas := make(map[string]*dependencies.Schema, len(schemaResp.ResourceTypes)) drtSchemas := make(map[string]*dependencies.Schema, len(schemaResp.DataSources)) + actionSchemas := make(map[string]*dependencies.ActionSchema, len(schemaResp.Actions)) for name, elem := range schemaResp.ResourceTypes { mrtSchemas[name] = schemaElementToProto(elem) @@ -148,11 +149,15 @@ func providerSchemaToProto(schemaResp providers.GetProviderSchemaResponse) *depe for name, elem := range schemaResp.DataSources { drtSchemas[name] = schemaElementToProto(elem) } + for name, elem := range schemaResp.Actions { + actionSchemas[name] = actionElementToProto(elem) + } return &dependencies.ProviderSchema{ ProviderConfig: schemaElementToProto(schemaResp.Provider), ManagedResourceTypes: mrtSchemas, DataResourceTypes: drtSchemas, + ActionTypes: actionSchemas, } } @@ -162,6 +167,14 @@ func schemaElementToProto(elem providers.Schema) *dependencies.Schema { } } +func actionElementToProto(elem providers.ActionSchema) *dependencies.ActionSchema { + return &dependencies.ActionSchema{ + Schema: &dependencies.Schema{ + Block: schemaBlockToProto(elem.ConfigSchema), + }, + } +} + func schemaBlockToProto(block *configschema.Block) *dependencies.Schema_Block { if block == nil { return &dependencies.Schema_Block{} diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 601ad56f26..b97bb702db 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -928,7 +928,6 @@ func (s *stacksServer) CloseTerraformState(ctx context.Context, request *stacks. } func (s *stacksServer) MigrateTerraformState(request *stacks.MigrateTerraformState_Request, server stacks.Stacks_MigrateTerraformStateServer) error { - previousStateHandle := handle[*states.State](request.StateHandle) previousState := s.handles.TerraformState(previousStateHandle) if previousState == nil { @@ -1207,6 +1206,26 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou return span }, + ReportActionInvocationPlanned: func(ctx context.Context, span any, ai *hooks.ActionInvocation) any { + span.(trace.Span).AddEvent("planned action invocation", trace.WithAttributes( + attribute.String("component_instance", ai.Addr.Component.String()), + attribute.String("resource_instance", ai.Addr.Item.String()), + )) + + inv, err := actionInvocationPlanned(ai) + if err != nil { + return span + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationPlanned_{ + ActionInvocationPlanned: inv, + }, + }) + + return span + }, + ReportResourceInstanceDeferred: func(ctx context.Context, span any, change *hooks.DeferredResourceInstanceChange) any { span.(trace.Span).AddEvent("deferred resource instance", trace.WithAttributes( attribute.String("component_instance", change.Change.Addr.Component.String()), @@ -1319,6 +1338,38 @@ func resourceInstancePlanned(ric *hooks.ResourceInstanceChange) (*stacks.StackCh }, nil } +func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangeProgress_ActionInvocationPlanned, error) { + res := &stacks.StackChangeProgress_ActionInvocationPlanned{ + Addr: stacks.NewActionInvocationInStackAddr(ai.Addr), + ProviderAddr: ai.ProviderAddr.String(), + } + + switch trig := ai.Trigger.(type) { + case *plans.LifecycleActionTrigger: + res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger{ + LifecycleActionTrigger: &stacks.StackChangeProgress_LifecycleActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: ai.Addr.Component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + default: + return nil, fmt.Errorf("unsupported action invocation trigger type") + } + + return res, nil +} + func evtComponentInstanceStatus(ci stackaddrs.AbsComponentInstance, status hooks.ComponentInstanceStatus) *stacks.StackChangeProgress { return &stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ComponentInstanceStatus_{ diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index f0f739dd78..eeb16141f2 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -117,3 +117,9 @@ type DeferredResourceInstanceChange struct { Reason providers.DeferredReason Change *ResourceInstanceChange } + +type ActionInvocation struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Trigger plans.ActionTrigger +} diff --git a/internal/stacks/stackruntime/internal/stackeval/hooks.go b/internal/stacks/stackruntime/internal/stackeval/hooks.go index 567fa813c2..db60f575b4 100644 --- a/internal/stacks/stackruntime/internal/stackeval/hooks.go +++ b/internal/stacks/stackruntime/internal/stackeval/hooks.go @@ -130,6 +130,8 @@ type Hooks struct { // [Hooks.BeginComponentInstancePlan]. ReportResourceInstanceDeferred hooks.MoreFunc[*hooks.DeferredResourceInstanceChange] + ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + // ReportComponentInstancePlanned is called after a component instance // is planned. It should be called inside a tracing context established by // [Hooks.BeginComponentInstancePlan]. diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index 3dc2c31b88..5fddb770e8 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -104,10 +104,17 @@ func ReportComponentInstance(ctx context.Context, plan *plans.Plan, h *Hooks, se }, }) } - - // Count action invocations - for range plan.Changes.ActionInvocations { + + for _, actInvoke := range plan.Changes.ActionInvocations { cic.ActionInvocation++ + hookMore(ctx, seq, h.ReportActionInvocationPlanned, &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: addr, + Item: actInvoke.Addr, + }, + ProviderAddr: actInvoke.ProviderAddr.Provider, + Trigger: actInvoke.ActionTrigger, + }) } hookMore(ctx, seq, h.ReportComponentInstancePlanned, cic) From e133339a83291dbb2412ab97f31b87b5e4972d57 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Mon, 2 Feb 2026 16:38:27 +0100 Subject: [PATCH 047/136] Add test for planned action invocation hooks, and ensuring the plan is valid --- .../stacks/stackruntime/helper_hooks_test.go | 22 +++ internal/stacks/stackruntime/helper_test.go | 2 + internal/stacks/stackruntime/plan_test.go | 136 +++++++++++++++++- .../module_web/main.tf | 31 ++++ .../planning-action-lifecycle.tfcomponent.hcl | 24 ++++ internal/stacks/stackruntime/validate_test.go | 1 + 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl diff --git a/internal/stacks/stackruntime/helper_hooks_test.go b/internal/stacks/stackruntime/helper_hooks_test.go index bace454a2a..15b269ff5d 100644 --- a/internal/stacks/stackruntime/helper_hooks_test.go +++ b/internal/stacks/stackruntime/helper_hooks_test.go @@ -33,6 +33,7 @@ type ExpectedHooks struct { ReportResourceInstanceDrift []*hooks.ResourceInstanceChange ReportResourceInstancePlanned []*hooks.ResourceInstanceChange ReportResourceInstanceDeferred []*hooks.DeferredResourceInstanceChange + ReportActionInvocationPlanned []*hooks.ActionInvocation ReportComponentInstancePlanned []*hooks.ComponentInstanceChange ReportComponentInstanceApplied []*hooks.ComponentInstanceChange } @@ -59,6 +60,9 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { sort.SliceStable(expectedHooks.ReportResourceInstanceDeferred, func(i, j int) bool { return expectedHooks.ReportResourceInstanceDeferred[i].Change.Addr.String() < expectedHooks.ReportResourceInstanceDeferred[j].Change.Addr.String() }) + sort.SliceStable(expectedHooks.ReportActionInvocationPlanned, func(i, j int) bool { + return expectedHooks.ReportActionInvocationPlanned[i].Addr.String() < expectedHooks.ReportActionInvocationPlanned[j].Addr.String() + }) sort.SliceStable(expectedHooks.ReportComponentInstancePlanned, func(i, j int) bool { return expectedHooks.ReportComponentInstancePlanned[i].Addr.String() < expectedHooks.ReportComponentInstancePlanned[j].Addr.String() }) @@ -114,6 +118,9 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { if diff := cmp.Diff(expectedHooks.ReportResourceInstanceDeferred, eh.ReportResourceInstanceDeferred); len(diff) > 0 { t.Errorf("wrong ReportResourceInstanceDeferred hooks: %s", diff) } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationPlanned, eh.ReportActionInvocationPlanned); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationPlanned hooks: %s", diff) + } if diff := cmp.Diff(expectedHooks.ReportComponentInstancePlanned, eh.ReportComponentInstancePlanned); len(diff) > 0 { t.Errorf("wrong ReportComponentInstancePlanned hooks: %s", diff) } @@ -369,6 +376,21 @@ func (ch *CapturedHooks) captureHooks() *Hooks { ch.ReportResourceInstanceDeferred = append(ch.ReportResourceInstanceDeferred, change) return a }, + ReportActionInvocationPlanned: func(ctx context.Context, a any, ai *hooks.ActionInvocation) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(ai.Addr.Component) { + panic("tried to report action invocation planned before component") + } + + if ch.ComponentInstanceFinished(ai.Addr.Component) { + panic("tried to report action invocation planned after component") + } + + ch.ReportActionInvocationPlanned = append(ch.ReportActionInvocationPlanned, ai) + return a + }, ReportComponentInstancePlanned: func(ctx context.Context, a any, change *hooks.ComponentInstanceChange) any { ch.Lock() defer ch.Unlock() diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index e92a63da1f..6e18385b97 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -441,6 +441,8 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string { // There should only be a single timestamp in a plan, so we can just // return a simple string. return "function-results" + case *stackplan.PlannedChangeActionInvocationInstancePlanned: + return change.ActionInvocationAddr.String() default: // This is only going to happen during tests, so we can panic here. panic(fmt.Errorf("unrecognized planned change type: %T", change)) diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index b82fcaea28..d66a0f0383 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -25,8 +25,10 @@ import ( "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" "github.com/hashicorp/terraform/internal/addrs" + terraformProvider "github.com/hashicorp/terraform/internal/builtin/providers/terraform" "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" @@ -1940,7 +1942,6 @@ func TestPlanWithComplexVariableDefaults(t *testing.T) { if diff := cmp.Diff(wantChanges, changes, changesCmpOpts); diff != "" { t.Errorf("wrong changes\n%s", diff) } - } func TestPlanWithSingleResource(t *testing.T) { @@ -4714,7 +4715,6 @@ func TestPlanWithStateManipulation(t *testing.T) { for name, tc := range tcs { t.Run(name, func(t *testing.T) { - ctx := context.Background() cfg := loadMainBundleConfigForTest(t, path.Join("state-manipulation", name)) @@ -6383,10 +6383,140 @@ func expectOutput(t *testing.T, name string, changes []stackplan.PlannedChange) for _, change := range changes { if v, ok := change.(*stackplan.PlannedChangeOutputValue); ok && v.Addr.Name == name { return v - } } t.Fatalf("expected output value %q", name) return nil } + +func TestPlanWithActionInvocationHooks(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "planning-action-lifecycle") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + testCtx := TestContext{ + config: cfg, + providers: map[addrs.Provider]providers.Factory{ + addrs.NewBuiltInProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + timestamp: &fakePlanTimestamp, + } + + // Create dynamic values for resource change + resourceBeforeVal := cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + })) + resourceAfterVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "value": cty.StringVal("example"), + }) + resourceBeforeDynVal, err := plans.NewDynamicValue(resourceBeforeVal, resourceBeforeVal.Type()) + if err != nil { + t.Fatal(err) + } + resourceAfterDynVal, err := plans.NewDynamicValue(resourceAfterVal, resourceAfterVal.Type()) + if err != nil { + t.Fatal(err) + } + + // Common addresses used throughout the test + webComponentInstance := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "web"}, + }, + } + webComponent := stackaddrs.AbsComponent{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.Component{Name: "web"}, + } + testResourceInstance := addrs.RootModuleInstance.ResourceInstance(addrs.ManagedResourceMode, "testing_resource", "main", addrs.NoKey) + testResourceObject := stackaddrs.AbsResourceInstanceObject{ + Component: webComponentInstance, + Item: addrs.AbsResourceInstanceObject{ + ResourceInstance: testResourceInstance, + }, + } + testActionInstance := addrs.RootModuleInstance.ActionInstance("testing_action", "notify", addrs.NoKey) + testActionInvocationAddr := stackaddrs.AbsActionInvocationInstance{ + Component: webComponentInstance, + Item: testActionInstance, + } + testProviderConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewBuiltInProvider("testing"), + } + + expectedHooks := ExpectedHooks{ + ReportActionInvocationPlanned: []*hooks.ActionInvocation{ + { + Addr: testActionInvocationAddr, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Trigger: &plans.LifecycleActionTrigger{ + TriggeringResourceAddr: testResourceInstance, + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + }, + ComponentExpanded: []*hooks.ComponentInstances{ + { + ComponentAddr: webComponent, + InstanceAddrs: []stackaddrs.AbsComponentInstance{webComponentInstance}, + }, + }, + PendingComponentInstancePlan: collections.NewSet(webComponentInstance), + BeginComponentInstancePlan: collections.NewSet(webComponentInstance), + EndComponentInstancePlan: collections.NewSet(webComponentInstance), + ReportResourceInstanceStatus: []*hooks.ResourceInstanceStatusHookData{ + { + Addr: testResourceObject, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Status: hooks.ResourceInstancePlanning, + }, + { + Addr: testResourceObject, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Status: hooks.ResourceInstancePlanned, + }, + }, + ReportResourceInstancePlanned: []*hooks.ResourceInstanceChange{ + { + Addr: testResourceObject, + Change: &plans.ResourceInstanceChangeSrc{ + Addr: testResourceInstance, + PrevRunAddr: testResourceInstance, + ProviderAddr: testProviderConfig, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: resourceBeforeDynVal, + After: resourceAfterDynVal, + }, + }, + }, + }, + ReportComponentInstancePlanned: []*hooks.ComponentInstanceChange{ + { + Addr: webComponentInstance, + Add: 1, + ActionInvocation: 1, + }, + }, + } + + cycle := TestCycle{ + planMode: plans.NormalMode, + wantPlannedHooks: &expectedHooks, + } + + testCtx.Plan(t, ctx, stackstate.NewState(), cycle) +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf new file mode 100644 index 0000000000..d1619e0479 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf @@ -0,0 +1,31 @@ + +terraform { + required_providers { + testing = { + source = "terraform.io/builtin/testing" + + configuration_aliases = [ testing ] + } + } +} + +action "testing_action" "notify" { + config { + message = "resource created" + } +} + +resource "testing_resource" "main" { + value = "example" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.notify] + } + } +} + +output "result" { + value = testing_resource.main.value +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl new file mode 100644 index 0000000000..dbfc141779 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl @@ -0,0 +1,24 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + testing = provider.testing.main + } +} + +output "result" { + type = string + value = component.web.result +} diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index 86aae05869..fe5a25810d 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -42,6 +42,7 @@ var ( "variable-output-roundtrip": {}, "variable-output-roundtrip-nested": {}, "aliased-provider": {}, + "planning-action-lifecycle": {}, filepath.Join("with-single-input", "input-from-component"): {}, filepath.Join("with-single-input", "input-from-component-list"): { planInputVars: map[string]cty.Value{ From e074b103dd04ecf527eccdcb083d57bd7ee5cd0a Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 3 Feb 2026 12:16:19 +0100 Subject: [PATCH 048/136] Revert unncessary change to get provider address --- internal/plans/planfile/tfplan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 5246f28d68..05896b7a86 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1403,7 +1403,7 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp ret := &planproto.ActionInvocationInstance{ Addr: action.Addr.String(), - Provider: action.ProviderAddr.Provider.String(), + Provider: action.ProviderAddr.String(), } switch at := action.ActionTrigger.(type) { From 7e3d300670198b4c4556a1a91d100087e08f4cc9 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Fri, 13 Feb 2026 13:30:08 +0100 Subject: [PATCH 049/136] Go formatting --- .../stackruntime/hooks/component_instance.go | 16 ++++++++-------- internal/stacks/stackruntime/plan_test.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/stacks/stackruntime/hooks/component_instance.go b/internal/stacks/stackruntime/hooks/component_instance.go index 24b55c02f5..cfae9ada9a 100644 --- a/internal/stacks/stackruntime/hooks/component_instance.go +++ b/internal/stacks/stackruntime/hooks/component_instance.go @@ -53,14 +53,14 @@ func (s ComponentInstanceStatus) ForProtobuf() stacks.StackChangeProgress_Compon // ComponentInstanceChange is the argument type for hook callbacks which // signal a set of planned or applied changes for a component instance. type ComponentInstanceChange struct { - Addr stackaddrs.AbsComponentInstance - Add int - Change int - Import int - Remove int - Defer int - Move int - Forget int + Addr stackaddrs.AbsComponentInstance + Add int + Change int + Import int + Remove int + Defer int + Move int + Forget int ActionInvocation int } diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index d66a0f0383..82e46426a5 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6474,9 +6474,9 @@ func TestPlanWithActionInvocationHooks(t *testing.T) { InstanceAddrs: []stackaddrs.AbsComponentInstance{webComponentInstance}, }, }, - PendingComponentInstancePlan: collections.NewSet(webComponentInstance), - BeginComponentInstancePlan: collections.NewSet(webComponentInstance), - EndComponentInstancePlan: collections.NewSet(webComponentInstance), + PendingComponentInstancePlan: collections.NewSet(webComponentInstance), + BeginComponentInstancePlan: collections.NewSet(webComponentInstance), + EndComponentInstancePlan: collections.NewSet(webComponentInstance), ReportResourceInstanceStatus: []*hooks.ResourceInstanceStatusHookData{ { Addr: testResourceObject, From 581fd6d55029e9982d874e9ffd31257c616fa830 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Fri, 13 Feb 2026 14:06:24 +0100 Subject: [PATCH 050/136] Fix test with incorrect provider address --- internal/plans/planfile/tfplan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 05896b7a86..5246f28d68 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1403,7 +1403,7 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp ret := &planproto.ActionInvocationInstance{ Addr: action.Addr.String(), - Provider: action.ProviderAddr.String(), + Provider: action.ProviderAddr.Provider.String(), } switch at := action.ActionTrigger.(type) { From ab6119fd90c21f113ff597bd38dde44978603a41 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Fri, 13 Feb 2026 14:45:06 +0100 Subject: [PATCH 051/136] Revert fix and instead update test expectation --- internal/plans/planfile/tfplan.go | 2 +- internal/stacks/stackplan/planned_change_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 5246f28d68..05896b7a86 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1403,7 +1403,7 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp ret := &planproto.ActionInvocationInstance{ Addr: action.Addr.String(), - Provider: action.ProviderAddr.Provider.String(), + Provider: action.ProviderAddr.String(), } switch at := action.ActionTrigger.(type) { diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index e3fe6b58b9..172500a0d2 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -992,7 +992,7 @@ func TestPlannedChangeAsProto(t *testing.T) { ProviderConfigAddr: "example.com/webhooks/http", Invocation: &planproto.ActionInvocationInstance{ Addr: "action.webhook.notify", - Provider: "example.com/webhooks/http", + Provider: `provider["example.com/webhooks/http"]`, ActionTrigger: &planproto.ActionInvocationInstance_LifecycleActionTrigger{ LifecycleActionTrigger: &planproto.LifecycleActionTrigger{ TriggeringResourceAddr: "example_resource.main", From b5d5a8ec110ea79ddc9e1948f79378ffa4468b46 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Mon, 16 Feb 2026 15:16:55 +0100 Subject: [PATCH 052/136] Fix wonky conflict resolution --- internal/rpcapi/stacks_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/rpcapi/stacks_test.go b/internal/rpcapi/stacks_test.go index 9181fc60b5..82881999b4 100644 --- a/internal/rpcapi/stacks_test.go +++ b/internal/rpcapi/stacks_test.go @@ -977,6 +977,11 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { ComponentInstanceAddr: "component.self", }, Status: stacks.StackChangeProgress_ComponentInstanceStatus_PLANNED, + }, + }, + }, + }, + }, "action_invocations": { // This test verifies that the ActionInvocation field exists in ComponentInstanceChanges // and is included in the total count. Once we implement action invocation tracking logic, @@ -1604,7 +1609,6 @@ func TestStackChangeProgressDuringApply(t *testing.T) { return values }(), }) - if err != nil { t.Fatalf("unexpected error: %s", err) } From 552e25b5f9dda5dcbb88d656a6a4fbf7e1999237 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Mon, 16 Feb 2026 15:29:22 +0100 Subject: [PATCH 053/136] Fix event span to mark action invocation address properly --- internal/rpcapi/stacks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index b97bb702db..8098503454 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1209,7 +1209,7 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou ReportActionInvocationPlanned: func(ctx context.Context, span any, ai *hooks.ActionInvocation) any { span.(trace.Span).AddEvent("planned action invocation", trace.WithAttributes( attribute.String("component_instance", ai.Addr.Component.String()), - attribute.String("resource_instance", ai.Addr.Item.String()), + attribute.String("action_invocation_instance", ai.Addr.Item.String()), )) inv, err := actionInvocationPlanned(ai) From 49feb6e86fd6336ee2bf43661c02c6f4703233ba Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 3 Mar 2026 14:18:38 +0100 Subject: [PATCH 054/136] Update naming to ResourceActionTrigger --- internal/rpcapi/stacks.go | 2 +- internal/stacks/stackplan/planned_change.go | 2 +- internal/stacks/stackplan/planned_change_test.go | 6 +++--- .../stackruntime/internal/stackeval/planning_test.go | 9 ++++----- internal/stacks/stackruntime/plan_test.go | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 8098503454..15c1c49590 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1345,7 +1345,7 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro } switch trig := ai.Trigger.(type) { - case *plans.LifecycleActionTrigger: + case *plans.ResourceActionTrigger: res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger{ LifecycleActionTrigger: &stacks.StackChangeProgress_LifecycleActionTrigger{ TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index 1332076077..b416d50b19 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -927,7 +927,7 @@ func (pc *PlannedChangeActionInvocationInstancePlanned) ChangeDescription() (*st // Convert the action trigger information switch at := pc.Invocation.ActionTrigger.(type) { - case *plans.LifecycleActionTrigger: + case *plans.ResourceActionTrigger: triggerEvent := stacks.PlannedChange_INVALID_EVENT switch at.ActionTriggerEvent { case configs.BeforeCreate: diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index 172500a0d2..78607f1427 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -964,7 +964,7 @@ func TestPlannedChangeAsProto(t *testing.T) { Key: addrs.NoKey, }, }, - ActionTrigger: &plans.LifecycleActionTrigger{ + ActionTrigger: &plans.ResourceActionTrigger{ TriggeringResourceAddr: addrs.Resource{ Mode: addrs.ManagedResourceMode, Type: "example_resource", @@ -993,8 +993,8 @@ func TestPlannedChangeAsProto(t *testing.T) { Invocation: &planproto.ActionInvocationInstance{ Addr: "action.webhook.notify", Provider: `provider["example.com/webhooks/http"]`, - ActionTrigger: &planproto.ActionInvocationInstance_LifecycleActionTrigger{ - LifecycleActionTrigger: &planproto.LifecycleActionTrigger{ + ActionTrigger: &planproto.ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &planproto.ResourceActionTrigger{ TriggeringResourceAddr: "example_resource.main", TriggerEvent: planproto.ActionTriggerEvent_AFTER_CREATE, ActionTriggerBlockIndex: 0, diff --git a/internal/stacks/stackruntime/internal/stackeval/planning_test.go b/internal/stacks/stackruntime/internal/stackeval/planning_test.go index c05d290546..12e0f8b798 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning_test.go @@ -968,7 +968,6 @@ func TestPlanning_LocalsDataSource(t *testing.T) { assertNoDiagnostics(t, diags) return rawPlan, nil }) - if err != nil { t.Fatal(err) } @@ -1013,7 +1012,8 @@ func TestPlanning_LocalsDataSource(t *testing.T) { expectedString := cty.StringVal("through-local-aloha-foo-foo") expectedList := []cty.Value{ cty.StringVal("through-local-aloha-foo"), - cty.StringVal("foo")} + cty.StringVal("foo"), + } expectedMap := map[string]cty.Value{ "key": cty.StringVal("through-local-aloha-foo"), @@ -1040,7 +1040,6 @@ func TestPlanning_LocalsDataSource(t *testing.T) { return state, nil }) - if err != nil { t.Fatal(err) } @@ -1173,8 +1172,8 @@ func TestPlanning_ActionInvocationLifecycle(t *testing.T) { if foundActionChange.Invocation == nil { t.Fatal("invocation is nil") } - if _, ok := foundActionChange.Invocation.ActionTrigger.(*plans.LifecycleActionTrigger); !ok { - t.Errorf("wrong action trigger type\ngot: %T\nwant: *plans.LifecycleActionTrigger", foundActionChange.Invocation.ActionTrigger) + if _, ok := foundActionChange.Invocation.ActionTrigger.(*plans.ResourceActionTrigger); !ok { + t.Errorf("wrong action trigger type\ngot: %T\nwant: *plans.ResourceActionTrigger", foundActionChange.Invocation.ActionTrigger) } // Verify we can convert to proto successfully diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 82e46426a5..38e0802fff 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6460,7 +6460,7 @@ func TestPlanWithActionInvocationHooks(t *testing.T) { { Addr: testActionInvocationAddr, ProviderAddr: addrs.NewBuiltInProvider("testing"), - Trigger: &plans.LifecycleActionTrigger{ + Trigger: &plans.ResourceActionTrigger{ TriggeringResourceAddr: testResourceInstance, ActionTriggerEvent: configs.AfterCreate, ActionTriggerBlockIndex: 0, From aa172e26f06a6bc986045d9da1cfabf6f12f4618 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Thu, 5 Mar 2026 12:23:32 +0100 Subject: [PATCH 055/136] Update Stacks Protobuf for renaming of LifecycleActionTrigger to ResourceActionTrigger --- .../rpcapi/terraform1/stacks/stacks.pb.go | 172 +++++++++--------- .../rpcapi/terraform1/stacks/stacks.proto | 16 +- 2 files changed, 94 insertions(+), 94 deletions(-) diff --git a/internal/rpcapi/terraform1/stacks/stacks.pb.go b/internal/rpcapi/terraform1/stacks/stacks.pb.go index 491d9ae8fb..7b33b0f703 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.pb.go +++ b/internal/rpcapi/terraform1/stacks/stacks.pb.go @@ -5231,7 +5231,7 @@ type PlannedChange_ActionInvocationInstance struct { ConfigValue *DynamicValue `protobuf:"bytes,4,opt,name=config_value,json=configValue,proto3" json:"config_value,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *PlannedChange_ActionInvocationInstance_LifecycleActionTrigger + // *PlannedChange_ActionInvocationInstance_ResourceActionTrigger // *PlannedChange_ActionInvocationInstance_InvokeActionTrigger ActionTrigger isPlannedChange_ActionInvocationInstance_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -5303,10 +5303,10 @@ func (x *PlannedChange_ActionInvocationInstance) GetActionTrigger() isPlannedCha return nil } -func (x *PlannedChange_ActionInvocationInstance) GetLifecycleActionTrigger() *PlannedChange_LifecycleActionTrigger { +func (x *PlannedChange_ActionInvocationInstance) GetResourceActionTrigger() *PlannedChange_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*PlannedChange_ActionInvocationInstance_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -5325,15 +5325,15 @@ type isPlannedChange_ActionInvocationInstance_ActionTrigger interface { isPlannedChange_ActionInvocationInstance_ActionTrigger() } -type PlannedChange_ActionInvocationInstance_LifecycleActionTrigger struct { - LifecycleActionTrigger *PlannedChange_LifecycleActionTrigger `protobuf:"bytes,6,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type PlannedChange_ActionInvocationInstance_ResourceActionTrigger struct { + ResourceActionTrigger *PlannedChange_ResourceActionTrigger `protobuf:"bytes,6,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type PlannedChange_ActionInvocationInstance_InvokeActionTrigger struct { InvokeActionTrigger *PlannedChange_InvokeActionTrigger `protobuf:"bytes,7,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { +func (*PlannedChange_ActionInvocationInstance_ResourceActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { } func (*PlannedChange_ActionInvocationInstance_InvokeActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { @@ -5397,9 +5397,9 @@ func (x *PlannedChange_ActionInvocationDeferred) GetActionInvocation() *PlannedC return nil } -// LifecycleActionTrigger contains details on the conditions that led to the +// ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. -type PlannedChange_LifecycleActionTrigger struct { +type PlannedChange_ResourceActionTrigger struct { state protoimpl.MessageState `protogen:"open.v1"` TriggeringResourceAddress *ResourceInstanceInStackAddr `protobuf:"bytes,1,opt,name=triggering_resource_address,json=triggeringResourceAddress,proto3" json:"triggering_resource_address,omitempty"` TriggerEvent PlannedChange_ActionTriggerEvent `protobuf:"varint,2,opt,name=trigger_event,json=triggerEvent,proto3,enum=terraform1.stacks.PlannedChange_ActionTriggerEvent" json:"trigger_event,omitempty"` @@ -5409,20 +5409,20 @@ type PlannedChange_LifecycleActionTrigger struct { sizeCache protoimpl.SizeCache } -func (x *PlannedChange_LifecycleActionTrigger) Reset() { - *x = PlannedChange_LifecycleActionTrigger{} +func (x *PlannedChange_ResourceActionTrigger) Reset() { + *x = PlannedChange_ResourceActionTrigger{} mi := &file_stacks_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PlannedChange_LifecycleActionTrigger) String() string { +func (x *PlannedChange_ResourceActionTrigger) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PlannedChange_LifecycleActionTrigger) ProtoMessage() {} +func (*PlannedChange_ResourceActionTrigger) ProtoMessage() {} -func (x *PlannedChange_LifecycleActionTrigger) ProtoReflect() protoreflect.Message { +func (x *PlannedChange_ResourceActionTrigger) ProtoReflect() protoreflect.Message { mi := &file_stacks_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -5434,33 +5434,33 @@ func (x *PlannedChange_LifecycleActionTrigger) ProtoReflect() protoreflect.Messa return mi.MessageOf(x) } -// Deprecated: Use PlannedChange_LifecycleActionTrigger.ProtoReflect.Descriptor instead. -func (*PlannedChange_LifecycleActionTrigger) Descriptor() ([]byte, []int) { +// Deprecated: Use PlannedChange_ResourceActionTrigger.ProtoReflect.Descriptor instead. +func (*PlannedChange_ResourceActionTrigger) Descriptor() ([]byte, []int) { return file_stacks_proto_rawDescGZIP(), []int{23, 8} } -func (x *PlannedChange_LifecycleActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { +func (x *PlannedChange_ResourceActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { if x != nil { return x.TriggeringResourceAddress } return nil } -func (x *PlannedChange_LifecycleActionTrigger) GetTriggerEvent() PlannedChange_ActionTriggerEvent { +func (x *PlannedChange_ResourceActionTrigger) GetTriggerEvent() PlannedChange_ActionTriggerEvent { if x != nil { return x.TriggerEvent } return PlannedChange_INVALID_EVENT } -func (x *PlannedChange_LifecycleActionTrigger) GetActionTriggerBlockIndex() int64 { +func (x *PlannedChange_ResourceActionTrigger) GetActionTriggerBlockIndex() int64 { if x != nil { return x.ActionTriggerBlockIndex } return 0 } -func (x *PlannedChange_LifecycleActionTrigger) GetActionsListIndex() int64 { +func (x *PlannedChange_ResourceActionTrigger) GetActionsListIndex() int64 { if x != nil { return x.ActionsListIndex } @@ -6432,7 +6432,7 @@ type StackChangeProgress_ActionInvocationPlanned struct { ProviderAddr string `protobuf:"bytes,2,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger // *StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationPlanned_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6490,10 +6490,10 @@ func (x *StackChangeProgress_ActionInvocationPlanned) GetActionTrigger() isStack return nil } -func (x *StackChangeProgress_ActionInvocationPlanned) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationPlanned) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6512,15 +6512,15 @@ type isStackChangeProgress_ActionInvocationPlanned_ActionTrigger interface { isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() } -type StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,3,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,3,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,4,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { @@ -6533,7 +6533,7 @@ type StackChangeProgress_ActionInvocationStatus struct { ProviderAddr string `protobuf:"bytes,3,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger // *StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationStatus_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6598,10 +6598,10 @@ func (x *StackChangeProgress_ActionInvocationStatus) GetActionTrigger() isStackC return nil } -func (x *StackChangeProgress_ActionInvocationStatus) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationStatus) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6620,15 +6620,15 @@ type isStackChangeProgress_ActionInvocationStatus_ActionTrigger interface { isStackChangeProgress_ActionInvocationStatus_ActionTrigger() } -type StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,4,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,4,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,5,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { @@ -6641,7 +6641,7 @@ type StackChangeProgress_ActionInvocationProgress struct { ProviderAddr string `protobuf:"bytes,3,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger // *StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationProgress_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6706,10 +6706,10 @@ func (x *StackChangeProgress_ActionInvocationProgress) GetActionTrigger() isStac return nil } -func (x *StackChangeProgress_ActionInvocationProgress) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationProgress) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6728,23 +6728,23 @@ type isStackChangeProgress_ActionInvocationProgress_ActionTrigger interface { isStackChangeProgress_ActionInvocationProgress_ActionTrigger() } -type StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,4,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,4,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,5,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { } -// LifecycleActionTrigger contains details on the conditions that led to the +// ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. -type StackChangeProgress_LifecycleActionTrigger struct { +type StackChangeProgress_ResourceActionTrigger struct { state protoimpl.MessageState `protogen:"open.v1"` TriggeringResourceAddress *ResourceInstanceInStackAddr `protobuf:"bytes,1,opt,name=triggering_resource_address,json=triggeringResourceAddress,proto3" json:"triggering_resource_address,omitempty"` TriggerEvent StackChangeProgress_ActionTriggerEvent `protobuf:"varint,2,opt,name=trigger_event,json=triggerEvent,proto3,enum=terraform1.stacks.StackChangeProgress_ActionTriggerEvent" json:"trigger_event,omitempty"` @@ -6754,20 +6754,20 @@ type StackChangeProgress_LifecycleActionTrigger struct { sizeCache protoimpl.SizeCache } -func (x *StackChangeProgress_LifecycleActionTrigger) Reset() { - *x = StackChangeProgress_LifecycleActionTrigger{} +func (x *StackChangeProgress_ResourceActionTrigger) Reset() { + *x = StackChangeProgress_ResourceActionTrigger{} mi := &file_stacks_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *StackChangeProgress_LifecycleActionTrigger) String() string { +func (x *StackChangeProgress_ResourceActionTrigger) String() string { return protoimpl.X.MessageStringOf(x) } -func (*StackChangeProgress_LifecycleActionTrigger) ProtoMessage() {} +func (*StackChangeProgress_ResourceActionTrigger) ProtoMessage() {} -func (x *StackChangeProgress_LifecycleActionTrigger) ProtoReflect() protoreflect.Message { +func (x *StackChangeProgress_ResourceActionTrigger) ProtoReflect() protoreflect.Message { mi := &file_stacks_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -6779,33 +6779,33 @@ func (x *StackChangeProgress_LifecycleActionTrigger) ProtoReflect() protoreflect return mi.MessageOf(x) } -// Deprecated: Use StackChangeProgress_LifecycleActionTrigger.ProtoReflect.Descriptor instead. -func (*StackChangeProgress_LifecycleActionTrigger) Descriptor() ([]byte, []int) { +// Deprecated: Use StackChangeProgress_ResourceActionTrigger.ProtoReflect.Descriptor instead. +func (*StackChangeProgress_ResourceActionTrigger) Descriptor() ([]byte, []int) { return file_stacks_proto_rawDescGZIP(), []int{26, 7} } -func (x *StackChangeProgress_LifecycleActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { +func (x *StackChangeProgress_ResourceActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { if x != nil { return x.TriggeringResourceAddress } return nil } -func (x *StackChangeProgress_LifecycleActionTrigger) GetTriggerEvent() StackChangeProgress_ActionTriggerEvent { +func (x *StackChangeProgress_ResourceActionTrigger) GetTriggerEvent() StackChangeProgress_ActionTriggerEvent { if x != nil { return x.TriggerEvent } return StackChangeProgress_INVALID_EVENT } -func (x *StackChangeProgress_LifecycleActionTrigger) GetActionTriggerBlockIndex() int64 { +func (x *StackChangeProgress_ResourceActionTrigger) GetActionTriggerBlockIndex() int64 { if x != nil { return x.ActionTriggerBlockIndex } return 0 } -func (x *StackChangeProgress_LifecycleActionTrigger) GetActionsListIndex() int64 { +func (x *StackChangeProgress_ResourceActionTrigger) GetActionsListIndex() int64 { if x != nil { return x.ActionsListIndex } @@ -7671,7 +7671,7 @@ const file_stacks_proto_rawDesc = "" + "\x17component_instance_addr\x18\x01 \x01(\tR\x15componentInstanceAddr\x124\n" + "\x16resource_instance_addr\x18\x02 \x01(\tR\x14resourceInstanceAddr\x12\x1f\n" + "\vdeposed_key\x18\x03 \x01(\tR\n" + - "deposedKey\"\xc2 \n" + + "deposedKey\"\xbe \n" + "\rPlannedChange\x12&\n" + "\x03raw\x18\x01 \x03(\v2\x14.google.protobuf.AnyR\x03raw\x12V\n" + "\fdescriptions\x18\x02 \x03(\v22.terraform1.stacks.PlannedChange.ChangeDescriptionR\fdescriptions\x1a\xe9\x06\n" + @@ -7727,20 +7727,20 @@ const file_stacks_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x127\n" + "\aactions\x18\x02 \x03(\x0e2\x1d.terraform1.stacks.ChangeTypeR\aactions\x12=\n" + "\x06values\x18\x03 \x01(\v2%.terraform1.stacks.DynamicValueChangeR\x06values\x122\n" + - "\x15required_during_apply\x18\x04 \x01(\bR\x13requiredDuringApply\x1a\xe3\x03\n" + + "\x15required_during_apply\x18\x04 \x01(\bR\x13requiredDuringApply\x1a\xe0\x03\n" + "\x18ActionInvocationInstance\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12#\n" + "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12\x1f\n" + "\vaction_type\x18\x03 \x01(\tR\n" + "actionType\x12B\n" + - "\fconfig_value\x18\x04 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\vconfigValue\x12s\n" + - "\x18lifecycle_action_trigger\x18\x06 \x01(\v27.terraform1.stacks.PlannedChange.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12j\n" + + "\fconfig_value\x18\x04 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\vconfigValue\x12p\n" + + "\x17resource_action_trigger\x18\x06 \x01(\v26.terraform1.stacks.PlannedChange.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12j\n" + "\x15invoke_action_trigger\x18\a \x01(\v24.terraform1.stacks.PlannedChange.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + "\x0eaction_trigger\x1a\xbb\x01\n" + "\x18ActionInvocationDeferred\x127\n" + "\bdeferred\x18\x01 \x01(\v2\x1b.terraform1.stacks.DeferredR\bdeferred\x12f\n" + - "\x11action_invocation\x18\x02 \x01(\v29.terraform1.stacks.PlannedChange.ActionInvocationInstanceR\x10actionInvocation\x1a\xcd\x02\n" + - "\x16LifecycleActionTrigger\x12n\n" + + "\x11action_invocation\x18\x02 \x01(\v29.terraform1.stacks.PlannedChange.ActionInvocationInstanceR\x10actionInvocation\x1a\xcc\x02\n" + + "\x15ResourceActionTrigger\x12n\n" + "\x1btriggering_resource_address\x18\x01 \x01(\v2..terraform1.stacks.ResourceInstanceInStackAddrR\x19triggeringResourceAddress\x12X\n" + "\rtrigger_event\x18\x02 \x01(\x0e23.terraform1.stacks.PlannedChange.ActionTriggerEventR\ftriggerEvent\x12;\n" + "\x1aaction_trigger_block_index\x18\x03 \x01(\x03R\x17actionTriggerBlockIndex\x12,\n" + @@ -7800,7 +7800,7 @@ const file_stacks_proto_rawDesc = "" + "\rInputVariable\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12<\n" + "\tnew_value\x18\x02 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\bnewValue\x1a\t\n" + - "\aNothing\"\xe3+\n" + + "\aNothing\"\xd9+\n" + "\x13StackChangeProgress\x12|\n" + "\x19component_instance_status\x18\x01 \x01(\v2>.terraform1.stacks.StackChangeProgress.ComponentInstanceStatusH\x00R\x17componentInstanceStatus\x12y\n" + "\x18resource_instance_status\x18\x02 \x01(\v2=.terraform1.stacks.StackChangeProgress.ResourceInstanceStatusH\x00R\x16resourceInstanceStatus\x12\x8f\x01\n" + @@ -7854,18 +7854,18 @@ const file_stacks_proto_rawDesc = "" + "\aunknown\x18\x02 \x01(\bR\aunknown\x1a\xbe\x01\n" + "%DeferredResourceInstancePlannedChange\x127\n" + "\bdeferred\x18\x01 \x01(\v2\x1b.terraform1.stacks.DeferredR\bdeferred\x12\\\n" + - "\x06change\x18\x02 \x01(\v2D.terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChangeR\x06change\x1a\x89\x03\n" + + "\x06change\x18\x02 \x01(\v2D.terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChangeR\x06change\x1a\x86\x03\n" + "\x17ActionInvocationPlanned\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12#\n" + - "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x03 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x03 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x04 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + - "\x0eaction_trigger\x1a\xb3\x04\n" + + "\x0eaction_trigger\x1a\xb0\x04\n" + "\x16ActionInvocationStatus\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12\\\n" + "\x06status\x18\x02 \x01(\x0e2D.terraform1.stacks.StackChangeProgress.ActionInvocationStatus.StatusR\x06status\x12#\n" + - "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x04 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x04 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x05 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTrigger\"K\n" + "\x06Status\x12\v\n" + "\aINVALID\x10\x00\x12\v\n" + @@ -7873,15 +7873,15 @@ const file_stacks_proto_rawDesc = "" + "\aRUNNING\x10\x02\x12\r\n" + "\tCOMPLETED\x10\x03\x12\v\n" + "\aERRORED\x10\x04B\x10\n" + - "\x0eaction_trigger\x1a\xa4\x03\n" + + "\x0eaction_trigger\x1a\xa1\x03\n" + "\x18ActionInvocationProgress\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12#\n" + - "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x04 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x04 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x05 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + - "\x0eaction_trigger\x1a\xd3\x02\n" + - "\x16LifecycleActionTrigger\x12n\n" + + "\x0eaction_trigger\x1a\xd2\x02\n" + + "\x15ResourceActionTrigger\x12n\n" + "\x1btriggering_resource_address\x18\x01 \x01(\v2..terraform1.stacks.ResourceInstanceInStackAddrR\x19triggeringResourceAddress\x12^\n" + "\rtrigger_event\x18\x02 \x01(\x0e29.terraform1.stacks.StackChangeProgress.ActionTriggerEventR\ftriggerEvent\x12;\n" + "\x1aaction_trigger_block_index\x18\x03 \x01(\x03R\x17actionTriggerBlockIndex\x12,\n" + @@ -8091,7 +8091,7 @@ var file_stacks_proto_goTypes = []any{ (*PlannedChange_InputVariable)(nil), // 95: terraform1.stacks.PlannedChange.InputVariable (*PlannedChange_ActionInvocationInstance)(nil), // 96: terraform1.stacks.PlannedChange.ActionInvocationInstance (*PlannedChange_ActionInvocationDeferred)(nil), // 97: terraform1.stacks.PlannedChange.ActionInvocationDeferred - (*PlannedChange_LifecycleActionTrigger)(nil), // 98: terraform1.stacks.PlannedChange.LifecycleActionTrigger + (*PlannedChange_ResourceActionTrigger)(nil), // 98: terraform1.stacks.PlannedChange.ResourceActionTrigger (*PlannedChange_InvokeActionTrigger)(nil), // 99: terraform1.stacks.PlannedChange.InvokeActionTrigger (*PlannedChange_ResourceInstance_Index)(nil), // 100: terraform1.stacks.PlannedChange.ResourceInstance.Index (*PlannedChange_ResourceInstance_Moved)(nil), // 101: terraform1.stacks.PlannedChange.ResourceInstance.Moved @@ -8111,7 +8111,7 @@ var file_stacks_proto_goTypes = []any{ (*StackChangeProgress_ActionInvocationPlanned)(nil), // 115: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned (*StackChangeProgress_ActionInvocationStatus)(nil), // 116: terraform1.stacks.StackChangeProgress.ActionInvocationStatus (*StackChangeProgress_ActionInvocationProgress)(nil), // 117: terraform1.stacks.StackChangeProgress.ActionInvocationProgress - (*StackChangeProgress_LifecycleActionTrigger)(nil), // 118: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + (*StackChangeProgress_ResourceActionTrigger)(nil), // 118: terraform1.stacks.StackChangeProgress.ResourceActionTrigger (*StackChangeProgress_InvokeActionTrigger)(nil), // 119: terraform1.stacks.StackChangeProgress.InvokeActionTrigger (*StackChangeProgress_ProvisionerStatus)(nil), // 120: terraform1.stacks.StackChangeProgress.ProvisionerStatus (*StackChangeProgress_ProvisionerOutput)(nil), // 121: terraform1.stacks.StackChangeProgress.ProvisionerOutput @@ -8225,12 +8225,12 @@ var file_stacks_proto_depIdxs = []int32{ 27, // 94: terraform1.stacks.PlannedChange.InputVariable.values:type_name -> terraform1.stacks.DynamicValueChange 31, // 95: terraform1.stacks.PlannedChange.ActionInvocationInstance.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr 26, // 96: terraform1.stacks.PlannedChange.ActionInvocationInstance.config_value:type_name -> terraform1.stacks.DynamicValue - 98, // 97: terraform1.stacks.PlannedChange.ActionInvocationInstance.lifecycle_action_trigger:type_name -> terraform1.stacks.PlannedChange.LifecycleActionTrigger + 98, // 97: terraform1.stacks.PlannedChange.ActionInvocationInstance.resource_action_trigger:type_name -> terraform1.stacks.PlannedChange.ResourceActionTrigger 99, // 98: terraform1.stacks.PlannedChange.ActionInvocationInstance.invoke_action_trigger:type_name -> terraform1.stacks.PlannedChange.InvokeActionTrigger 35, // 99: terraform1.stacks.PlannedChange.ActionInvocationDeferred.deferred:type_name -> terraform1.stacks.Deferred 96, // 100: terraform1.stacks.PlannedChange.ActionInvocationDeferred.action_invocation:type_name -> terraform1.stacks.PlannedChange.ActionInvocationInstance - 32, // 101: terraform1.stacks.PlannedChange.LifecycleActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr - 4, // 102: terraform1.stacks.PlannedChange.LifecycleActionTrigger.trigger_event:type_name -> terraform1.stacks.PlannedChange.ActionTriggerEvent + 32, // 101: terraform1.stacks.PlannedChange.ResourceActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr + 4, // 102: terraform1.stacks.PlannedChange.ResourceActionTrigger.trigger_event:type_name -> terraform1.stacks.PlannedChange.ActionTriggerEvent 26, // 103: terraform1.stacks.PlannedChange.ResourceInstance.Index.value:type_name -> terraform1.stacks.DynamicValue 32, // 104: terraform1.stacks.PlannedChange.ResourceInstance.Moved.prev_addr:type_name -> terraform1.stacks.ResourceInstanceInStackAddr 130, // 105: terraform1.stacks.AppliedChange.RawChange.value:type_name -> google.protobuf.Any @@ -8258,17 +8258,17 @@ var file_stacks_proto_depIdxs = []int32{ 35, // 127: terraform1.stacks.StackChangeProgress.DeferredResourceInstancePlannedChange.deferred:type_name -> terraform1.stacks.Deferred 113, // 128: terraform1.stacks.StackChangeProgress.DeferredResourceInstancePlannedChange.change:type_name -> terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChange 31, // 129: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr - 118, // 130: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 130: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 131: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger 31, // 132: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr 9, // 133: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.status:type_name -> terraform1.stacks.StackChangeProgress.ActionInvocationStatus.Status - 118, // 134: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 134: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 135: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger 31, // 136: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr - 118, // 137: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 137: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 138: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger - 32, // 139: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr - 6, // 140: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger.trigger_event:type_name -> terraform1.stacks.StackChangeProgress.ActionTriggerEvent + 32, // 139: terraform1.stacks.StackChangeProgress.ResourceActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr + 6, // 140: terraform1.stacks.StackChangeProgress.ResourceActionTrigger.trigger_event:type_name -> terraform1.stacks.StackChangeProgress.ActionTriggerEvent 33, // 141: terraform1.stacks.StackChangeProgress.ProvisionerStatus.addr:type_name -> terraform1.stacks.ResourceInstanceObjectInStackAddr 120, // 142: terraform1.stacks.StackChangeProgress.ProvisionerStatus.status:type_name -> terraform1.stacks.StackChangeProgress.ProvisionerStatus 33, // 143: terraform1.stacks.StackChangeProgress.ProvisionerOutput.addr:type_name -> terraform1.stacks.ResourceInstanceObjectInStackAddr @@ -8370,7 +8370,7 @@ func file_stacks_proto_init() { (*PlannedChange_ChangeDescription_ActionInvocationDeferred)(nil), } file_stacks_proto_msgTypes[85].OneofWrappers = []any{ - (*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger)(nil), + (*PlannedChange_ActionInvocationInstance_ResourceActionTrigger)(nil), (*PlannedChange_ActionInvocationInstance_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[93].OneofWrappers = []any{ @@ -8382,15 +8382,15 @@ func file_stacks_proto_init() { (*AppliedChange_ChangeDescription_ComponentInstance)(nil), } file_stacks_proto_msgTypes[104].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[105].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[106].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger)(nil), } type x struct{} diff --git a/internal/rpcapi/terraform1/stacks/stacks.proto b/internal/rpcapi/terraform1/stacks/stacks.proto index d7e3b4c673..233c3a01bf 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.proto +++ b/internal/rpcapi/terraform1/stacks/stacks.proto @@ -670,7 +670,7 @@ message PlannedChange { DynamicValue config_value = 4; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 6; + ResourceActionTrigger resource_action_trigger = 6; InvokeActionTrigger invoke_action_trigger = 7; } } @@ -687,9 +687,9 @@ message PlannedChange { ActionInvocationInstance action_invocation = 2; } - // LifecycleActionTrigger contains details on the conditions that led to the + // ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. - message LifecycleActionTrigger { + message ResourceActionTrigger { ResourceInstanceInStackAddr triggering_resource_address = 1; ActionTriggerEvent trigger_event = 2; int64 action_trigger_block_index = 3; @@ -940,7 +940,7 @@ message StackChangeProgress { ActionInvocationInstanceInStackAddr addr = 1; string provider_addr = 2; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 3; + ResourceActionTrigger resource_action_trigger = 3; InvokeActionTrigger invoke_action_trigger = 4; } } @@ -951,7 +951,7 @@ message StackChangeProgress { string provider_addr = 3; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 4; + ResourceActionTrigger resource_action_trigger = 4; InvokeActionTrigger invoke_action_trigger = 5; } @@ -970,14 +970,14 @@ message StackChangeProgress { string provider_addr = 3; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 4; + ResourceActionTrigger resource_action_trigger = 4; InvokeActionTrigger invoke_action_trigger = 5; } } - // LifecycleActionTrigger contains details on the conditions that led to the + // ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. - message LifecycleActionTrigger { + message ResourceActionTrigger { ResourceInstanceInStackAddr triggering_resource_address = 1; ActionTriggerEvent trigger_event = 2; int64 action_trigger_block_index = 3; From fb0cc11e487475a683924169392303334c7f5c1f Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Thu, 5 Mar 2026 12:28:54 +0100 Subject: [PATCH 056/136] Update to use new protobuf renames --- internal/rpcapi/stacks.go | 4 ++-- internal/stacks/stackplan/planned_change.go | 4 ++-- internal/stacks/stackplan/planned_change_test.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 15c1c49590..fb17838329 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1346,8 +1346,8 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro switch trig := ai.Trigger.(type) { case *plans.ResourceActionTrigger: - res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger{ - LifecycleActionTrigger: &stacks.StackChangeProgress_LifecycleActionTrigger{ + res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( stackaddrs.AbsResourceInstance{ Component: ai.Addr.Component, diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index b416d50b19..8f65630381 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -944,8 +944,8 @@ func (pc *PlannedChangeActionInvocationInstancePlanned) ChangeDescription() (*st triggerEvent = stacks.PlannedChange_AFTER_DESTROY } - invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_LifecycleActionTrigger{ - LifecycleActionTrigger: &stacks.PlannedChange_LifecycleActionTrigger{ + invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.PlannedChange_ResourceActionTrigger{ TriggerEvent: triggerEvent, TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr(stackaddrs.AbsResourceInstance{ Component: addr.Component, diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index 78607f1427..ddc63fd16f 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -1020,8 +1020,8 @@ func TestPlannedChangeAsProto(t *testing.T) { ConfigValue: &stacks.DynamicValue{ Msgpack: emptyObjectForPlan, }, - ActionTrigger: &stacks.PlannedChange_ActionInvocationInstance_LifecycleActionTrigger{ - LifecycleActionTrigger: &stacks.PlannedChange_LifecycleActionTrigger{ + ActionTrigger: &stacks.PlannedChange_ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.PlannedChange_ResourceActionTrigger{ TriggeringResourceAddress: &stacks.ResourceInstanceInStackAddr{ ComponentInstanceAddr: "component.web", ResourceInstanceAddr: "example_resource.main", From 876b671470265e67021961b301cab132162c4b0b Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Thu, 5 Mar 2026 16:47:49 +0100 Subject: [PATCH 057/136] Refactor action trigger event conversion --- internal/rpcapi/stacks.go | 6 +- .../rpcapi/terraform1/stacks/conversion.go | 47 +++++++++++++++ .../terraform1/stacks/conversion_test.go | 59 +++++++++++++++++++ internal/stacks/stackplan/planned_change.go | 18 +----- 4 files changed, 114 insertions(+), 16 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index fb17838329..6ffdb7e039 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1346,6 +1346,10 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro switch trig := ai.Trigger.(type) { case *plans.ResourceActionTrigger: + triggerEvent, err := stacks.ActionTriggerEventForStackChangeProgress(trig.TriggerEvent()) + if err != nil { + return nil, err + } res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( @@ -1354,7 +1358,7 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro Item: trig.TriggeringResourceAddr, }, ), - TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + TriggerEvent: triggerEvent, ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), ActionsListIndex: int64(trig.ActionsListIndex), }, diff --git a/internal/rpcapi/terraform1/stacks/conversion.go b/internal/rpcapi/terraform1/stacks/conversion.go index 9a3728fc57..4d87fdfe58 100644 --- a/internal/rpcapi/terraform1/stacks/conversion.go +++ b/internal/rpcapi/terraform1/stacks/conversion.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" @@ -45,6 +46,52 @@ func ChangeTypesForPlanAction(action plans.Action) ([]ChangeType, error) { } } +// ActionTriggerEventForStackChangeProgress converts a [configs.ActionTriggerEvent] +// to its [StackChangeProgress_ActionTriggerEvent] protobuf equivalent. +func ActionTriggerEventForStackChangeProgress(event configs.ActionTriggerEvent) (StackChangeProgress_ActionTriggerEvent, error) { + switch event { + case configs.BeforeCreate: + return StackChangeProgress_BEFORE_CREATE, nil + case configs.AfterCreate: + return StackChangeProgress_AFTER_CREATE, nil + case configs.BeforeUpdate: + return StackChangeProgress_BEFORE_UPDATE, nil + case configs.AfterUpdate: + return StackChangeProgress_AFTER_UPDATE, nil + case configs.BeforeDestroy: + return StackChangeProgress_BEFORE_DESTROY, nil + case configs.AfterDestroy: + return StackChangeProgress_AFTER_DESTROY, nil + case configs.Invoke: + return StackChangeProgress_INVOKE, nil + default: + return StackChangeProgress_INVALID_EVENT, fmt.Errorf("unsupported trigger event %s", event) + } +} + +// ActionTriggerEventForPlannedChange converts a [configs.ActionTriggerEvent] +// to its [PlannedChange_ActionTriggerEvent] protobuf equivalent. +func ActionTriggerEventForPlannedChange(event configs.ActionTriggerEvent) (PlannedChange_ActionTriggerEvent, error) { + switch event { + case configs.BeforeCreate: + return PlannedChange_BEFORE_CREATE, nil + case configs.AfterCreate: + return PlannedChange_AFTER_CREATE, nil + case configs.BeforeUpdate: + return PlannedChange_BEFORE_UPDATE, nil + case configs.AfterUpdate: + return PlannedChange_AFTER_UPDATE, nil + case configs.BeforeDestroy: + return PlannedChange_BEFORE_DESTROY, nil + case configs.AfterDestroy: + return PlannedChange_AFTER_DESTROY, nil + case configs.Invoke: + return PlannedChange_INVOKE, nil + default: + return PlannedChange_INVALID_EVENT, fmt.Errorf("unsupported trigger event %s", event) + } +} + // ToDynamicValue uses NewDynamicValue to construct a DynamicValue from the // provider cty.Value. This function will strip out the sensitive paths and // include them in the returned dynamic value. If from contains marks other diff --git a/internal/rpcapi/terraform1/stacks/conversion_test.go b/internal/rpcapi/terraform1/stacks/conversion_test.go index 7c0d89a28e..341f4e2c3b 100644 --- a/internal/rpcapi/terraform1/stacks/conversion_test.go +++ b/internal/rpcapi/terraform1/stacks/conversion_test.go @@ -10,9 +10,68 @@ import ( "google.golang.org/protobuf/testing/protocmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" ) +func TestActionTriggerEventForStackChangeProgress(t *testing.T) { + tests := []struct { + event configs.ActionTriggerEvent + want StackChangeProgress_ActionTriggerEvent + wantErr bool + }{ + {configs.BeforeCreate, StackChangeProgress_BEFORE_CREATE, false}, + {configs.AfterCreate, StackChangeProgress_AFTER_CREATE, false}, + {configs.BeforeUpdate, StackChangeProgress_BEFORE_UPDATE, false}, + {configs.AfterUpdate, StackChangeProgress_AFTER_UPDATE, false}, + {configs.BeforeDestroy, StackChangeProgress_BEFORE_DESTROY, false}, + {configs.AfterDestroy, StackChangeProgress_AFTER_DESTROY, false}, + {configs.Invoke, StackChangeProgress_INVOKE, false}, + {configs.Unknown, StackChangeProgress_INVALID_EVENT, true}, + } + + for _, tt := range tests { + t.Run(tt.event.String(), func(t *testing.T) { + got, err := ActionTriggerEventForStackChangeProgress(tt.event) + if (err != nil) != tt.wantErr { + t.Fatalf("ActionTriggerEventForStackChangeProgress(%v) error = %v, wantErr %v", tt.event, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ActionTriggerEventForStackChangeProgress(%v) = %v, want %v", tt.event, got, tt.want) + } + }) + } +} + +func TestActionTriggerEventForPlannedChange(t *testing.T) { + tests := []struct { + event configs.ActionTriggerEvent + want PlannedChange_ActionTriggerEvent + wantErr bool + }{ + {configs.BeforeCreate, PlannedChange_BEFORE_CREATE, false}, + {configs.AfterCreate, PlannedChange_AFTER_CREATE, false}, + {configs.BeforeUpdate, PlannedChange_BEFORE_UPDATE, false}, + {configs.AfterUpdate, PlannedChange_AFTER_UPDATE, false}, + {configs.BeforeDestroy, PlannedChange_BEFORE_DESTROY, false}, + {configs.AfterDestroy, PlannedChange_AFTER_DESTROY, false}, + {configs.Invoke, PlannedChange_INVOKE, false}, + {configs.Unknown, PlannedChange_INVALID_EVENT, true}, + } + + for _, tt := range tests { + t.Run(tt.event.String(), func(t *testing.T) { + got, err := ActionTriggerEventForPlannedChange(tt.event) + if (err != nil) != tt.wantErr { + t.Fatalf("ActionTriggerEventForPlannedChange(%v) error = %v, wantErr %v", tt.event, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ActionTriggerEventForPlannedChange(%v) = %v, want %v", tt.event, got, tt.want) + } + }) + } +} + func TestNewActionInvocationInStackAddr(t *testing.T) { tests := []struct { name string diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index 8f65630381..2d2530d091 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -15,7 +15,6 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" @@ -928,20 +927,9 @@ func (pc *PlannedChangeActionInvocationInstancePlanned) ChangeDescription() (*st // Convert the action trigger information switch at := pc.Invocation.ActionTrigger.(type) { case *plans.ResourceActionTrigger: - triggerEvent := stacks.PlannedChange_INVALID_EVENT - switch at.ActionTriggerEvent { - case configs.BeforeCreate: - triggerEvent = stacks.PlannedChange_BEFORE_CREATE - case configs.AfterCreate: - triggerEvent = stacks.PlannedChange_AFTER_CREATE - case configs.BeforeUpdate: - triggerEvent = stacks.PlannedChange_BEFORE_UPDATE - case configs.AfterUpdate: - triggerEvent = stacks.PlannedChange_AFTER_UPDATE - case configs.BeforeDestroy: - triggerEvent = stacks.PlannedChange_BEFORE_DESTROY - case configs.AfterDestroy: - triggerEvent = stacks.PlannedChange_AFTER_DESTROY + triggerEvent, err := stacks.ActionTriggerEventForPlannedChange(at.ActionTriggerEvent) + if err != nil { + return nil, err } invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_ResourceActionTrigger{ From 17bc01688cc96f609e6dfa448858c31da5713969 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 27 Jan 2026 12:53:35 +0100 Subject: [PATCH 058/136] Add tests for action invocation counts --- internal/stacks/stackruntime/internal/stackeval/planning.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index 5fddb770e8..e7634bdd5d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -104,6 +104,11 @@ func ReportComponentInstance(ctx context.Context, plan *plans.Plan, h *Hooks, se }, }) } + + // Count action invocations + for range plan.Changes.ActionInvocations { + cic.ActionInvocation++ + } for _, actInvoke := range plan.Changes.ActionInvocations { cic.ActionInvocation++ From 5b2f19abadc18ee12e2f5956940395c02b768f46 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 3 Feb 2026 14:31:42 +0100 Subject: [PATCH 059/136] Add failing test for deferred actions --- internal/stacks/stackplan/planned_change.go | 57 +++++++++++++++ internal/stacks/stackruntime/plan_test.go | 70 +++++++++++++++++++ .../deferred-action.tfcomponent.hcl | 29 ++++++++ .../mainbundle/test/deferred-action/main.tf | 39 +++++++++++ 4 files changed, 195 insertions(+) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index 2d2530d091..5c6c0dae63 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -996,3 +996,60 @@ func (pc *PlannedChangeActionInvocationInstancePlanned) PlannedChangeProto() (*s Descriptions: descs, }, nil } + +// PlannedChangeDeferredActionInvocation represents an action invocation +// that was deferred due to incomplete information. +type PlannedChangeDeferredActionInvocation struct { + // ActionInvocationPlanned is the planned action invocation that is being deferred. + ActionInvocationPlanned PlannedChangeActionInvocationInstancePlanned + + // DeferredReason is the reason why the action invocation is being deferred. + DeferredReason providers.DeferredReason +} + +var _ PlannedChange = (*PlannedChangeDeferredActionInvocation)(nil) + +// PlannedChangeProto implements PlannedChange. +func (dpc *PlannedChangeDeferredActionInvocation) PlannedChangeProto() (*stacks.PlannedChange, error) { + action, err := dpc.ActionInvocationPlanned.PlanActionInvocationProto() + if err != nil { + return nil, err + } + + // Convert the deferred reason to proto format + deferredReason, _ := planfile.DeferredReasonToProto(dpc.DeferredReason) + + var raw anypb.Any + err = anypb.MarshalFrom(&raw, &tfstackdata1.PlanDeferredActionInvocation{ + Invocation: action, + Deferred: &planproto.Deferred{ + Reason: deferredReason, + }, + }, proto.MarshalOptions{}) + if err != nil { + return nil, err + } + + // Build the change description + aicd, err := dpc.ActionInvocationPlanned.ChangeDescription() + if err != nil { + return nil, err + } + + var descs []*stacks.PlannedChange_ChangeDescription + if aicd != nil { + descs = append(descs, &stacks.PlannedChange_ChangeDescription{ + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationDeferred{ + ActionInvocationDeferred: &stacks.PlannedChange_ActionInvocationDeferred{ + ActionInvocation: aicd.GetActionInvocationPlanned(), + Deferred: EncodeDeferred(dpc.DeferredReason), + }, + }, + }) + } + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + Descriptions: descs, + }, nil +} diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 38e0802fff..9586557244 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6520,3 +6520,73 @@ func TestPlanWithActionInvocationHooks(t *testing.T) { testCtx.Plan(t, ctx, stackstate.NewState(), cycle) } + +func TestPlanWithDeferredActionInvocation(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "deferred-action") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z") + if err != nil { + t.Fatal(err) + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + req := PlanRequest{ + Config: cfg, + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + ForcePlanTimestamp: &fakePlanTimestamp, + InputValues: map[stackaddrs.InputVariable]ExternalInputValue{ + {Name: "id"}: { + Value: cty.StringVal("test-id-123"), + }, + {Name: "defer"}: { + Value: cty.BoolVal(true), + }, + }, + } + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + go Plan(ctx, &req, &resp) + gotChanges, diags := collectPlanOutput(changesCh, diagsCh) + + reportDiagnosticsForTest(t, diags) + if len(diags) != 0 { + t.FailNow() // We reported the diags above + } + + sort.SliceStable(gotChanges, func(i, j int) bool { + return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) + }) + + // Find the deferred action invocation in the changes + var foundDeferredAction bool + for _, change := range gotChanges { + if _, ok := change.(*stackplan.PlannedChangeDeferredActionInvocation); ok { + foundDeferredAction = true + break + } + } + + if !foundDeferredAction { + t.Error("Expected to find a deferred action invocation in the plan changes, but none was found") + t.Logf("Got %d changes:", len(gotChanges)) + for i, change := range gotChanges { + t.Logf(" [%d] %T", i, change) + } + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl new file mode 100644 index 0000000000..2577e0a8d2 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl @@ -0,0 +1,29 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +variable "id" { + type = string +} + +variable "defer" { + type = bool +} + +component "self" { + source = "./" + + providers = { + testing = provider.testing.default + } + + inputs = { + id = var.id + defer = var.defer + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf new file mode 100644 index 0000000000..8a8cc18806 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf @@ -0,0 +1,39 @@ + +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +variable "id" { + type = string + default = null + nullable = true # We'll generate an ID if none provided. +} + +variable "defer" { + type = bool +} + +# Action that should be invoked when resource is created +action "testing_action" "notify" { + config { + message = "resource created with id ${var.id}" + } +} + +# Deferred resource with action trigger +resource "testing_deferred_resource" "data" { + id = var.id + deferred = var.defer + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.notify] + } + } +} From ffeff0914db0cfcf528da5b0191924233c7501d3 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Wed, 4 Feb 2026 12:43:12 +0100 Subject: [PATCH 060/136] Support for deferred action invocations in plan We encovered that deferred action invocations don't get provider addresses, which prevents us from loading the schema. That being said, I think it shouldn't be an issue, but will come back to revisit this as we build the support end to end. Add a test for deferred actions support --- internal/stacks/stackplan/from_plan.go | 26 +++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 2 ++ internal/stacks/stackruntime/plan_test.go | 24 ++++++++++++++--- .../mainbundle/test/deferred-action/main.tf | 2 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/stacks/stackplan/from_plan.go b/internal/stacks/stackplan/from_plan.go index 0d49147892..daa2c924b7 100644 --- a/internal/stacks/stackplan/from_plan.go +++ b/internal/stacks/stackplan/from_plan.go @@ -207,6 +207,32 @@ func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, ref }) } + // Handle deferred action invocations from the plan + for _, deferredAction := range plan.DeferredActionInvocations { + invocation := deferredAction.ActionInvocationInstanceSrc + + if invocation == nil { + continue + } + + // For deferred actions, the provider address is typically empty because + // actions are deferred before being fully evaluated. We create the planned + // change without schema since we can't fetch it without a provider address. + plannedActionInvocation := PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: producer.Addr(), + Item: invocation.Addr, + }, + Invocation: invocation, + Schema: providers.ActionSchema{}, // Empty schema for deferred actions + ProviderConfigAddr: invocation.ProviderAddr, // Will be empty, that's expected + } + changes = append(changes, &PlannedChangeDeferredActionInvocation{ + DeferredReason: deferredAction.DeferredReason, + ActionInvocationPlanned: plannedActionInvocation, + }) + } + // We also need to catch any objects that exist in the "prior state" // but don't have any actions planned, since we still need to capture // the prior state part in case it was updated by refreshing during diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 6e18385b97..3d39c69247 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -443,6 +443,8 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string { return "function-results" case *stackplan.PlannedChangeActionInvocationInstancePlanned: return change.ActionInvocationAddr.String() + case *stackplan.PlannedChangeDeferredActionInvocation: + return "deferred:" + change.ActionInvocationPlanned.ActionInvocationAddr.String() default: // This is only going to happen during tests, so we can panic here. panic(fmt.Errorf("unrecognized planned change type: %T", change)) diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 9586557244..61e3d6006e 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6573,15 +6573,33 @@ func TestPlanWithDeferredActionInvocation(t *testing.T) { return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) }) - // Find the deferred action invocation in the changes + // First, let's verify the resource was actually deferred + var foundDeferredResource bool + var foundNormalActionInvocation bool var foundDeferredAction bool + for _, change := range gotChanges { - if _, ok := change.(*stackplan.PlannedChangeDeferredActionInvocation); ok { + switch c := change.(type) { + case *stackplan.PlannedChangeDeferredResourceInstancePlanned: + foundDeferredResource = true + t.Logf("Found deferred resource: %s", c.ResourceInstancePlanned.ResourceInstanceObjectAddr) + case *stackplan.PlannedChangeActionInvocationInstancePlanned: + foundNormalActionInvocation = true + t.Logf("Found normal action invocation: %s", c.ActionInvocationAddr) + case *stackplan.PlannedChangeDeferredActionInvocation: foundDeferredAction = true - break + t.Logf("Found deferred action invocation: %s", c.ActionInvocationPlanned.ActionInvocationAddr) } } + if !foundDeferredResource { + t.Error("Expected to find a deferred resource, but none was found") + } + + if foundNormalActionInvocation { + t.Error("Action invocation should be deferred, not appearing as a normal invocation") + } + if !foundDeferredAction { t.Error("Expected to find a deferred action invocation in the plan changes, but none was found") t.Logf("Got %d changes:", len(gotChanges)) diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf index 8a8cc18806..9b88cbe0c0 100644 --- a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf @@ -18,7 +18,7 @@ variable "defer" { type = bool } -# Action that should be invoked when resource is created +# Simple action action "testing_action" "notify" { config { message = "resource created with id ${var.id}" From 05eeff8fbbe260118543874da2273fc85673fb07 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 3 Feb 2026 14:31:42 +0100 Subject: [PATCH 061/136] Add failing test for deferred actions --- internal/stacks/stackruntime/plan_test.go | 24 +++---------------- .../mainbundle/test/deferred-action/main.tf | 2 +- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 61e3d6006e..9586557244 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6573,33 +6573,15 @@ func TestPlanWithDeferredActionInvocation(t *testing.T) { return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) }) - // First, let's verify the resource was actually deferred - var foundDeferredResource bool - var foundNormalActionInvocation bool + // Find the deferred action invocation in the changes var foundDeferredAction bool - for _, change := range gotChanges { - switch c := change.(type) { - case *stackplan.PlannedChangeDeferredResourceInstancePlanned: - foundDeferredResource = true - t.Logf("Found deferred resource: %s", c.ResourceInstancePlanned.ResourceInstanceObjectAddr) - case *stackplan.PlannedChangeActionInvocationInstancePlanned: - foundNormalActionInvocation = true - t.Logf("Found normal action invocation: %s", c.ActionInvocationAddr) - case *stackplan.PlannedChangeDeferredActionInvocation: + if _, ok := change.(*stackplan.PlannedChangeDeferredActionInvocation); ok { foundDeferredAction = true - t.Logf("Found deferred action invocation: %s", c.ActionInvocationPlanned.ActionInvocationAddr) + break } } - if !foundDeferredResource { - t.Error("Expected to find a deferred resource, but none was found") - } - - if foundNormalActionInvocation { - t.Error("Action invocation should be deferred, not appearing as a normal invocation") - } - if !foundDeferredAction { t.Error("Expected to find a deferred action invocation in the plan changes, but none was found") t.Logf("Got %d changes:", len(gotChanges)) diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf index 9b88cbe0c0..8a8cc18806 100644 --- a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf @@ -18,7 +18,7 @@ variable "defer" { type = bool } -# Simple action +# Action that should be invoked when resource is created action "testing_action" "notify" { config { message = "resource created with id ${var.id}" From 9b056eb2864120da693e8fafd33f8075ebfc39aa Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Mon, 16 Feb 2026 12:51:37 +0100 Subject: [PATCH 062/136] Run go fmt --- internal/stacks/stackplan/from_plan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/stacks/stackplan/from_plan.go b/internal/stacks/stackplan/from_plan.go index daa2c924b7..df80431416 100644 --- a/internal/stacks/stackplan/from_plan.go +++ b/internal/stacks/stackplan/from_plan.go @@ -210,7 +210,7 @@ func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, ref // Handle deferred action invocations from the plan for _, deferredAction := range plan.DeferredActionInvocations { invocation := deferredAction.ActionInvocationInstanceSrc - + if invocation == nil { continue } From 1c9c8d473e81094ee7308bd71cf4dadef6f87dc7 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Wed, 25 Feb 2026 16:23:50 +0100 Subject: [PATCH 063/136] Remove unnecessary prefix --- internal/stacks/stackruntime/helper_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 3d39c69247..974a29a610 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -52,7 +52,6 @@ type TestContext struct { // TestCycle defines a single plan / apply cycle that should be performed within // a test. type TestCycle struct { - // Validate options wantValidateDiags tfdiags.Diagnostics @@ -444,7 +443,7 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string { case *stackplan.PlannedChangeActionInvocationInstancePlanned: return change.ActionInvocationAddr.String() case *stackplan.PlannedChangeDeferredActionInvocation: - return "deferred:" + change.ActionInvocationPlanned.ActionInvocationAddr.String() + return change.ActionInvocationPlanned.ActionInvocationAddr.String() default: // This is only going to happen during tests, so we can panic here. panic(fmt.Errorf("unrecognized planned change type: %T", change)) @@ -485,7 +484,6 @@ func diagnosticSortFunc(diags tfdiags.Diagnostics) func(i, j int) bool { return sortDescription(id.Description(), jd.Description()) } if id.Source().Subject != nil && jd.Source().Subject != nil { - return sortRange(id.Source().Subject, jd.Source().Subject) } From 2f3a862f8053aeac68bd9faa002a48a1bd31e5f6 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Fri, 6 Mar 2026 15:28:59 +0100 Subject: [PATCH 064/136] Stop double-counting actions --- internal/stacks/stackruntime/internal/stackeval/planning.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index e7634bdd5d..5fddb770e8 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -104,11 +104,6 @@ func ReportComponentInstance(ctx context.Context, plan *plans.Plan, h *Hooks, se }, }) } - - // Count action invocations - for range plan.Changes.ActionInvocations { - cic.ActionInvocation++ - } for _, actInvoke := range plan.Changes.ActionInvocations { cic.ActionInvocation++ From decccf9e3f27c0c10e797b678b03ad0e594df1f3 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 5 Mar 2026 16:23:06 +0100 Subject: [PATCH 065/136] improve error message when using not const variables in module sources --- internal/terraform/context_init_test.go | 4 ++-- internal/terraform/node_module_install.go | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index 9bf78943be..8cb4ff8ffe 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -138,7 +138,7 @@ module "example" { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid module source`, - Detail: `The value of a reference in the module source is unknown.`, + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, Subject: &hcl.Range{ Filename: filepath.Join(m.SourceDir, "main.tf"), Start: hcl.Pos{Line: 6, Column: 27, Byte: 82}, @@ -625,7 +625,7 @@ module "nested" { return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid module source`, - Detail: `The value of a reference in the module source is unknown.`, + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, Subject: &hcl.Range{ Filename: filepath.Join(mc["./modules/example"].SourceDir, "main.tf"), Start: hcl.Pos{Line: 7, Column: 27, Byte: 82}, diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index be5c5cc9a1..4a4e62ce4f 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -154,6 +154,8 @@ func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diag return &g, nil } +const constVariableDetail = "\nOnly literal values and constant variables (with const = true) are allowed for this attribute, as well as values derived from these." + func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, string, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var addr addrs.ModuleSource @@ -192,7 +194,7 @@ func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (ad diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The module source contains a reference that is unknown during init.", + Detail: "The module source contains a reference that is unknown during init." + constVariableDetail, Subject: sourceExpr.Range().Ptr(), }) return nil, "", diags @@ -214,7 +216,7 @@ func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (ad diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The value of a reference in the module source is unknown.", + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, Subject: part.Range().Ptr(), Expression: part, EvalContext: hclCtx, @@ -226,7 +228,7 @@ func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (ad diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module source", - Detail: "The module source contains a reference that is unknown.", + Detail: "The module source contains a reference that is unknown." + constVariableDetail, Subject: sourceExpr.Range().Ptr(), }) return nil, "", diags @@ -335,7 +337,7 @@ func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module version", - Detail: "The module version contains a reference that is unknown during init.", + Detail: "The module version contains a reference that is unknown during init." + constVariableDetail, Subject: versionExpr.Range().Ptr(), }) return ret, diags @@ -357,7 +359,7 @@ func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module version", - Detail: "The value of a reference in the module version is unknown.", + Detail: "The value of a reference in the module version is unknown." + constVariableDetail, Subject: part.Range().Ptr(), Expression: part, EvalContext: hclCtx, @@ -369,7 +371,7 @@ func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid module version", - Detail: "The module version contains a reference that is unknown.", + Detail: "The module version contains a reference that is unknown." + constVariableDetail, Subject: versionExpr.Range().Ptr(), }) return ret, diags From 95c9d6f42c6e16b5f756373ff38a8125b538b22a Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 6 Mar 2026 17:43:21 +0100 Subject: [PATCH 066/136] improve formatting Co-authored-by: Daniel Banck --- internal/terraform/node_module_install.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index 4a4e62ce4f..daf3368bfa 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -154,7 +154,7 @@ func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diag return &g, nil } -const constVariableDetail = "\nOnly literal values and constant variables (with const = true) are allowed for this attribute, as well as values derived from these." +const constVariableDetail = "\n\nOnly literal values and constant variables (with const = true) are allowed for this attribute, as well as values derived from these." func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, string, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics From ac7206c9194ef8be09878541ad09f7b67d3d4625 Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:34:18 +0000 Subject: [PATCH 067/136] test: Fix E2E tests to use correct errors for assertions, remove use of ioutil in package (#38254) --- internal/command/e2etest/provider_dev_test.go | 9 ++++----- internal/e2e/e2e.go | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/command/e2etest/provider_dev_test.go b/internal/command/e2etest/provider_dev_test.go index 739561b8d2..fe3b89ccf1 100644 --- a/internal/command/e2etest/provider_dev_test.go +++ b/internal/command/e2etest/provider_dev_test.go @@ -5,7 +5,6 @@ package e2etest import ( "fmt" - "io/ioutil" "os" "path/filepath" "strings" @@ -47,7 +46,7 @@ func TestProviderDevOverrides(t *testing.T) { providerExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple/main", providerExePrefix) t.Logf("temporary provider executable is %s", providerExe) - err := ioutil.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` + err := os.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` provider_installation { dev_overrides { "example.com/test/test" = %q @@ -86,7 +85,7 @@ func TestProviderDevOverrides(t *testing.T) { t.Errorf("stdout doesn't include the warning about development overrides\nwant: %s\n%s", want, got) } - stdout, _, _ = tf.Run("init") + stdout, _, err = tf.Run("init") if err != nil { t.Fatalf("unexpected error: %e", err) } @@ -129,7 +128,7 @@ func TestProviderDevOverridesWithProviderToDownload(t *testing.T) { providerExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple/main", providerExePrefix) t.Logf("temporary provider executable is %s", providerExe) - err := ioutil.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` + err := os.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` provider_installation { dev_overrides { "example.com/test/test" = %q @@ -143,7 +142,7 @@ func TestProviderDevOverridesWithProviderToDownload(t *testing.T) { tf.AddEnv("TF_CLI_CONFIG_FILE=dev.tfrc") - stdout, stderr, _ := tf.Run("providers") + stdout, stderr, err := tf.Run("providers") if err != nil { t.Fatalf("unexpected error: %s\n%s", err, stderr) } diff --git a/internal/e2e/e2e.go b/internal/e2e/e2e.go index 5cdc7b1b2a..2695d77d77 100644 --- a/internal/e2e/e2e.go +++ b/internal/e2e/e2e.go @@ -7,7 +7,6 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "os" "os/exec" "path/filepath" @@ -177,7 +176,7 @@ func (b *binary) OpenFile(path ...string) (*os.File, error) { // directory. func (b *binary) ReadFile(path ...string) ([]byte, error) { flatPath := b.Path(path...) - return ioutil.ReadFile(flatPath) + return os.ReadFile(flatPath) } // FileExists is a helper for easily testing whether a particular file @@ -247,7 +246,7 @@ func (b *binary) SetLocalState(state *states.State) error { func GoBuild(pkgPath, tmpPrefix string) string { dir, prefix := filepath.Split(tmpPrefix) - tmpFile, err := ioutil.TempFile(dir, prefix) + tmpFile, err := os.CreateTemp(dir, prefix) if err != nil { panic(err) } From 4eba7c0596a06779c3d365dfd0ebef162e308f3c Mon Sep 17 00:00:00 2001 From: creatorHead Date: Wed, 11 Mar 2026 12:05:44 +0530 Subject: [PATCH 068/136] Update LICENSE --- LICENSE | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/LICENSE b/LICENSE index 8142708df2..c3ba8fe791 100644 --- a/LICENSE +++ b/LICENSE @@ -3,22 +3,22 @@ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. Parameters -Licensor: HashiCorp, Inc. +Licensor: International Business Machines Corporation (IBM) Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) 2024 - HashiCorp, Inc. + IBM Corp. Additional Use Grant: You may make production use of the Licensed Work, provided Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis in order to compete with - HashiCorp's paid version(s) of the Licensed Work. For purposes + IBM Corp's paid version(s) of the Licensed Work. For purposes of this license: A "competitive offering" is a Product that is offered to third parties on a paid basis, including through paid support arrangements, that significantly overlaps with the capabilities - of HashiCorp's paid version(s) of the Licensed Work. If Your + of IBM Corp's paid version(s) of the Licensed Work. If Your Product is not a competitive offering when You first make it generally available, it will not become a competitive offering - later due to HashiCorp releasing a new version of the Licensed + later due to IBM Corp releasing a new version of the Licensed Work with additional capabilities. In addition, Products that are not provided on a paid basis are not competitive. @@ -34,10 +34,10 @@ Additional Use Grant: You may make production use of the Licensed Work, provided Hosting or using the Licensed Work(s) for internal purposes within an organization is not considered a competitive - offering. HashiCorp considers your organization to include all + offering. IBM Corp considers your organization to include all of your affiliates under common control. - For binding interpretive guidance on using HashiCorp products + For binding interpretive guidance on using IBM Corp products under the Business Source License, please visit our FAQ. (https://www.hashicorp.com/license-faq) Change Date: Four years from the date the Licensed Work is published. From f9cfdf1ebe8131dac81cd92d6c4f4185c8ea031c Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 11:53:21 +0100 Subject: [PATCH 069/136] Refactoring: Modernize graph command to use arguments --- internal/command/arguments/graph.go | 63 ++++++++++ internal/command/arguments/graph_test.go | 147 +++++++++++++++++++++++ internal/command/graph.go | 45 +++---- 3 files changed, 225 insertions(+), 30 deletions(-) create mode 100644 internal/command/arguments/graph.go create mode 100644 internal/command/arguments/graph_test.go diff --git a/internal/command/arguments/graph.go b/internal/command/arguments/graph.go new file mode 100644 index 0000000000..291ad2dfe0 --- /dev/null +++ b/internal/command/arguments/graph.go @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import "github.com/hashicorp/terraform/internal/tfdiags" + +// Graph represents the command-line arguments for the graph command. +type Graph struct { + // DrawCycles highlights any cycles in the graph with colored edges. + DrawCycles bool + + // GraphType is the type of operation graph to output (plan, + // plan-refresh-only, plan-destroy, or apply). Empty string means the + // default resource-dependency summary. + GraphType string + + // ModuleDepth is a deprecated option that was used in prior versions to + // control the depth of modules shown. + ModuleDepth int + + // Verbose enables verbose graph output. + Verbose bool + + // Plan is the path to a saved plan file to render as a graph. + Plan string +} + +// ParseGraph processes CLI arguments, returning a Graph value and errors. +// If errors are encountered, a Graph value is still returned representing +// the best effort interpretation of the arguments. +func ParseGraph(args []string) (*Graph, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + graph := &Graph{ + ModuleDepth: -1, + } + + cmdFlags := defaultFlagSet("graph") + cmdFlags.BoolVar(&graph.DrawCycles, "draw-cycles", false, "draw-cycles") + cmdFlags.StringVar(&graph.GraphType, "type", "", "type") + cmdFlags.IntVar(&graph.ModuleDepth, "module-depth", -1, "module-depth") + cmdFlags.BoolVar(&graph.Verbose, "verbose", false, "verbose") + cmdFlags.StringVar(&graph.Plan, "plan", "", "plan") + + 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 graph, diags +} diff --git a/internal/command/arguments/graph_test.go b/internal/command/arguments/graph_test.go new file mode 100644 index 0000000000..227707e039 --- /dev/null +++ b/internal/command/arguments/graph_test.go @@ -0,0 +1,147 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseGraph_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + }{ + "defaults": { + nil, + &Graph{ + ModuleDepth: -1, + }, + }, + "plan type": { + []string{"-type=plan"}, + &Graph{ + GraphType: "plan", + ModuleDepth: -1, + }, + }, + "apply type": { + []string{"-type=apply"}, + &Graph{ + GraphType: "apply", + ModuleDepth: -1, + }, + }, + "draw-cycles": { + []string{"-draw-cycles", "-type=plan"}, + &Graph{ + DrawCycles: true, + GraphType: "plan", + ModuleDepth: -1, + }, + }, + "plan file": { + []string{"-plan=tfplan"}, + &Graph{ + Plan: "tfplan", + ModuleDepth: -1, + }, + }, + "verbose": { + []string{"-verbose"}, + &Graph{ + Verbose: true, + ModuleDepth: -1, + }, + }, + "module-depth": { + []string{"-module-depth=2"}, + &Graph{ + ModuleDepth: 2, + }, + }, + "all flags": { + []string{"-draw-cycles", "-type=plan-destroy", "-plan=tfplan", "-verbose", "-module-depth=3"}, + &Graph{ + DrawCycles: true, + GraphType: "plan-destroy", + Plan: "tfplan", + Verbose: true, + ModuleDepth: 3, + }, + }, + } + + 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 != "" { + t.Fatalf("unexpected result\n%s", diff) + } + }) + } +} + +func TestParseGraph_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-wat"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -wat", + ), + }, + }, + "positional argument": { + []string{"extra"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + "too many positional arguments": { + []string{"bad", "bad"}, + &Graph{ + ModuleDepth: -1, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + } + + 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 != "" { + t.Fatalf("unexpected result\n%s", diff) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/graph.go b/internal/command/graph.go index ded3c1f70b..8e4b8efa57 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -24,27 +24,14 @@ type GraphCommand struct { Meta } -func (c *GraphCommand) Run(args []string) int { - var drawCycles bool - var graphTypeStr string - var moduleDepth int - var verbose bool - var planPath string - - args = c.Meta.process(args) - cmdFlags := c.Meta.defaultFlagSet("graph") - cmdFlags.BoolVar(&drawCycles, "draw-cycles", false, "draw-cycles") - cmdFlags.StringVar(&graphTypeStr, "type", "", "type") - cmdFlags.IntVar(&moduleDepth, "module-depth", -1, "module-depth") - cmdFlags.BoolVar(&verbose, "verbose", false, "verbose") - cmdFlags.StringVar(&planPath, "plan", "", "plan") - 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())) +func (c *GraphCommand) Run(rawArgs []string) int { + args, diags := arguments.ParseGraph(c.Meta.process(rawArgs)) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } - configPath, err := ModulePath(cmdFlags.Args()) + configPath, err := ModulePath(nil) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -58,16 +45,14 @@ func (c *GraphCommand) Run(args []string) int { // Try to load plan if path is specified var planFile *planfile.WrappedPlanFile - if planPath != "" { - planFile, err = c.PlanFile(planPath) + if args.Plan != "" { + planFile, err = c.PlanFile(args.Plan) if err != nil { c.Ui.Error(err.Error()) return 1 } } - var diags tfdiags.Diagnostics - // Load the backend b, backendDiags := c.backend(".", arguments.ViewHuman) diags = diags.Append(backendDiags) @@ -106,9 +91,9 @@ func (c *GraphCommand) Run(args []string) int { c.showDiagnostics(diags) return 1 } - lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: drawCycles}) + lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: args.DrawCycles}) - if graphTypeStr == "" { + if args.GraphType == "" { if planFile == nil { // Simple resource dependency mode: // This is based on the plan graph but we then further reduce it down @@ -125,13 +110,13 @@ func (c *GraphCommand) Run(args []string) int { g := fullG.ResourceGraph() return c.resourceOnlyGraph(g) } else { - graphTypeStr = "apply" + args.GraphType = "apply" } } var g *terraform.Graph var graphDiags tfdiags.Diagnostics - switch graphTypeStr { + switch args.GraphType { case "plan": g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.NormalMode) case "plan-refresh-only": @@ -162,7 +147,7 @@ func (c *GraphCommand) Run(args []string) int { graphDiags = graphDiags.Append(tfdiags.Sourceless( tfdiags.Error, "Graph type no longer available", - fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", graphTypeStr), + fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", args.GraphType), )) default: graphDiags = graphDiags.Append(tfdiags.Sourceless( @@ -178,9 +163,9 @@ func (c *GraphCommand) Run(args []string) int { } graphStr, err := terraform.GraphDot(g, &dag.DotOpts{ - DrawCycles: drawCycles, - MaxDepth: moduleDepth, - Verbose: verbose, + DrawCycles: args.DrawCycles, + MaxDepth: args.ModuleDepth, + Verbose: args.Verbose, }) if err != nil { c.Ui.Error(fmt.Sprintf("Error converting graph: %s", err)) From e4f91190c9df07f70a79db9b102a40e00dec0a22 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 5 Mar 2026 13:55:15 +0100 Subject: [PATCH 070/136] add tests for dynamic module sources in terraform test --- internal/command/test.go | 75 +++-- internal/command/test_test.go | 280 ++++++++++++++++++ .../test/dynamic_source_missing_var/main.tf | 8 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 3 + .../test/dynamic_source_nested/main.tf | 9 + .../dynamic_source_nested/main.tftest.hcl | 6 + .../modules/child/main.tf | 7 + .../modules/parent/main.tf | 12 + .../test/dynamic_source_non_const_var/main.tf | 7 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 3 + .../dynamic_source_nonexistent_module/main.tf | 9 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 3 + .../test/dynamic_source_with_default/main.tf | 9 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 7 + .../dynamic_source_with_local_value/main.tf | 12 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 7 + .../dynamic_source_with_setup_module/main.tf | 14 + .../main.tftest.hcl | 21 ++ .../modules/example/main.tf | 15 + .../setup/main.tf | 13 + .../test/dynamic_source_with_var_flag/main.tf | 8 + .../main.tftest.hcl | 6 + .../modules/example/main.tf | 7 + 28 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 internal/command/testdata/test/dynamic_source_missing_var/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_nested/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_non_const_var/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_default/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_local_value/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_setup_module/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_var_flag/main.tf create mode 100644 internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl create mode 100644 internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf diff --git a/internal/command/test.go b/internal/command/test.go index 404f162724..a3376cacd7 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -321,6 +321,57 @@ func (m *Meta) setupTestExecution(mode moduletest.CommandMode, command string, r return } + loader, err := m.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(nil, nil, diags) + return + } + + registerFileSource := func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + } + + // Collect variables for "terraform test" + preparation.TestVariables, moreDiags = arguments.CollectValuesForTests(preparation.Args.TestDirectory, registerFileSource) + diags = diags.Append(moreDiags) + + // Collect variable values and add them to the operation request. + // We must collect these before loading config, because + // loadConfigWithTests needs const variable values available in + // m.VariableValues to resolve dynamic module sources. + var varDiags tfdiags.Diagnostics + preparation.Variables, varDiags = preparation.Args.Vars.CollectValues(registerFileSource) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(nil, nil, diags) + return + } + + // Only populate m.VariableValues with variables that are declared + // as const in the root module. loadConfigWithTests uses + // m.VariableValues to resolve dynamic module sources via + // ParseConstVariableValues, which would otherwise error on + // undeclared variables passed via -var that are intended for + // test runs rather than the root module. + // + // We do an early load of just the root module to discover which + // variables are const. We discard non-error diagnostics from this + // early load since loadConfigWithTests will re-parse and report them. + earlyMod, earlyDiags := m.loadSingleModuleWithTests(".", preparation.Args.TestDirectory) + if earlyDiags.HasErrors() { + diags = diags.Append(earlyDiags) + view.Diagnostics(nil, nil, diags) + return + } + constVars := make(map[string]arguments.UnparsedVariableValue) + for name, val := range preparation.Variables { + if decl, exists := earlyMod.Variables[name]; exists && decl.Const { + constVars[name] = val + } + } + m.VariableValues = constVars + preparation.Config, moreDiags = m.loadConfigWithTests(".", preparation.Args.TestDirectory) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { @@ -377,30 +428,6 @@ func (m *Meta) setupTestExecution(mode moduletest.CommandMode, command string, r return } - loader, err := m.initConfigLoader() - if err != nil { - diags = diags.Append(err) - view.Diagnostics(nil, nil, diags) - return - } - - registerFileSource := func(filename string, src []byte) { - loader.Parser().ForceFileSource(filename, src) - } - - // Collect variables for "terraform test" - preparation.TestVariables, moreDiags = arguments.CollectValuesForTests(preparation.Args.TestDirectory, registerFileSource) - diags = diags.Append(moreDiags) - - // Collect variable value and add them to the operation request - var varDiags tfdiags.Diagnostics - preparation.Variables, varDiags = preparation.Args.Vars.CollectValues(registerFileSource) - diags = diags.Append(varDiags) - if diags.HasErrors() { - view.Diagnostics(nil, nil, diags) - return - } - opts, err := m.contextOpts() if err != nil { diags = diags.Append(err) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index d10fe31a3a..10b51e2030 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -433,6 +433,25 @@ func TestTest_Runs(t *testing.T) { expectedOut: []string{"2 passed, 0 failed."}, code: 0, }, + "dynamic_source_with_default": { + expectedOut: []string{"1 passed, 0 failed."}, + code: 0, + }, + "dynamic_source_missing_var": { + initCode: 1, + expectedErr: []string{"No value for required variable"}, + code: 1, + }, + "dynamic_source_nonexistent_module": { + initCode: 1, + expectedErr: []string{"Unreadable module directory"}, + code: 1, + }, + "dynamic_source_non_const_var": { + initCode: 1, + expectedErr: []string{"Invalid module source"}, + code: 1, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -2439,6 +2458,267 @@ func TestTest_ModuleDependencies(t *testing.T) { } } +func TestTest_DynamicSourceWithVarFlag(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_var_flag")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "module_name=example"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "module_name=example", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceWithLocalValue(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_local_value")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "module_name=example"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "module_name=example", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceNested(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_nested")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "child_name=child"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "child_name=child", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceWithSetupModule(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_setup_module")), td) + t.Chdir(td) + + // Our two providers will share a common set of values to make things + // easier. + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + + // We set it up so the setup provider will write into the data sources + // available to the test provider. + test := testing_command.NewProvider(store) + setup := testing_command.NewProvider(store) + + test.SetDataPrefix("data") + test.SetResourcePrefix("resource") + + // Let's make the setup provider write into the data for test provider. + setup.SetResourcePrefix("data") + + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + "setup": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(test.Provider), + addrs.NewDefaultProvider("setup"): providers.FactoryFixed(setup.Provider), + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run(nil); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-no-color"}) + output := done(t) + + printedOutput := false + + if code != 0 { + printedOutput = true + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "2 passed, 0 failed.") { + if !printedOutput { + t.Errorf("output didn't contain expected string:\n\n%s", output.All()) + } else { + t.Errorf("output didn't contain expected string: %q", output.Stdout()) + } + } + + if test.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %s", test.ResourceString()) + } + + if setup.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %s", setup.ResourceString()) + } +} + func TestTest_CatchesErrorsBeforeDestroy(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath(path.Join("test", "invalid_default_state")), td) diff --git a/internal/command/testdata/test/dynamic_source_missing_var/main.tf b/internal/command/testdata/test/dynamic_source_missing_var/main.tf new file mode 100644 index 0000000000..fb85fc0265 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_nested/main.tf b/internal/command/testdata/test/dynamic_source_nested/main.tf new file mode 100644 index 0000000000..b0db6a9f5e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/main.tf @@ -0,0 +1,9 @@ +variable "child_name" { + type = string + const = true +} + +module "parent" { + source = "./modules/parent" + child_name = var.child_name +} diff --git a/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl new file mode 100644 index 0000000000..2ff5ea44ad --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_nested_dynamic_module" { + assert { + condition = module.parent.value == "from_child" + error_message = "expected from_child from nested dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf b/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf new file mode 100644 index 0000000000..eb337b779b --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "from_child" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf b/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf new file mode 100644 index 0000000000..a74e631dba --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf @@ -0,0 +1,12 @@ +variable "child_name" { + type = string + const = true +} + +module "child" { + source = "../${var.child_name}" +} + +output "value" { + value = module.child.value +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/main.tf b/internal/command/testdata/test/dynamic_source_non_const_var/main.tf new file mode 100644 index 0000000000..17affcc60b --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/main.tf @@ -0,0 +1,7 @@ +variable "module_name" { + type = string +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf new file mode 100644 index 0000000000..ef5ee0319f --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "nonexistent" +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/main.tf b/internal/command/testdata/test/dynamic_source_with_default/main.tf new file mode 100644 index 0000000000..b64283129e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/main.tf b/internal/command/testdata/test/dynamic_source_with_local_value/main.tf new file mode 100644 index 0000000000..cda19b736e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/main.tf @@ -0,0 +1,12 @@ +variable "module_name" { + type = string + const = true +} + +locals { + module_source = "./modules/${var.module_name}" +} + +module "mod" { + source = local.module_source +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf new file mode 100644 index 0000000000..26eff094a2 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf @@ -0,0 +1,14 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +variable "managed_id" { + type = string +} + +module "mod" { + source = "./modules/${var.module_name}" + id = var.managed_id +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl new file mode 100644 index 0000000000..8789e32183 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl @@ -0,0 +1,21 @@ +variables { + managed_id = "B853C121" +} + +run "setup" { + module { + source = "./setup" + } + + variables { + value = "Hello, world!" + id = "B853C121" + } +} + +run "test" { + assert { + condition = module.mod.value == "Hello, world!" + error_message = "expected value from setup module via dynamic source" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf new file mode 100644 index 0000000000..2416f28ddc --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf @@ -0,0 +1,15 @@ +variable "id" { + type = string +} + +data "test_data_source" "managed_data" { + id = var.id +} + +resource "test_resource" "foo" { + value = data.test_data_source.managed_data.value +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf new file mode 100644 index 0000000000..b4e5a75aba --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf @@ -0,0 +1,13 @@ +variable "value" { + type = string +} + +variable "id" { + type = string +} + +resource "test_resource" "managed" { + provider = setup + id = var.id + value = var.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf new file mode 100644 index 0000000000..fb85fc0265 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} From 4b8c12d493209aed88ed2abe00b9d335c403097e Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 5 Mar 2026 13:30:09 +0100 Subject: [PATCH 071/136] variable validation for const variables also rename planning to validateChecks, it more accurately describes when the flag should be set now --- internal/checks/state.go | 21 +++++ internal/terraform/context_init_test.go | 81 +++++++++++++++++++ internal/terraform/context_plan_test.go | 78 ++++++++++++++++++ internal/terraform/context_validate_test.go | 34 ++++++++ internal/terraform/graph_builder_eval.go | 4 +- internal/terraform/graph_builder_init.go | 12 ++- internal/terraform/graph_builder_plan.go | 14 ++-- internal/terraform/node_module_install.go | 6 ++ internal/terraform/node_module_variable.go | 7 +- internal/terraform/node_root_variable.go | 7 +- internal/terraform/node_root_variable_test.go | 2 +- internal/terraform/terraform_test.go | 8 +- .../terraform/transform_module_variable.go | 15 ++-- internal/terraform/transform_variable.go | 13 ++- 14 files changed, 264 insertions(+), 38 deletions(-) diff --git a/internal/checks/state.go b/internal/checks/state.go index 1e258c001a..f947adc07c 100644 --- a/internal/checks/state.go +++ b/internal/checks/state.go @@ -87,6 +87,27 @@ func NewState(config *configs.Config) *State { } } +// RegisterModule registers all checkable objects declared in the given module +// configuration that are not already known to this State. +// +// This supports incremental config discovery, such as during init walks where +// child modules are loaded step by step rather than all at once. +func (c *State) RegisterModule(cfg *configs.Config) { + c.mu.Lock() + defer c.mu.Unlock() + + // Collect statuses for the new module (non-recursively — children will + // register themselves when they are loaded). + fresh := addrs.MakeMap[addrs.ConfigCheckable, *configCheckableState]() + collectInitialStatuses(fresh, cfg) + + for _, elem := range fresh.Elems { + if !c.statuses.Has(elem.Key) { + c.statuses.Put(elem.Key, elem.Value) + } + } +} + // ConfigHasChecks returns true if and only if the given address refers to // a configuration object that this State object is expecting to recieve // statuses for. diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index 8cb4ff8ffe..1e9dc51360 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -4,6 +4,7 @@ package terraform import ( + "fmt" "path/filepath" "strings" "testing" @@ -634,6 +635,86 @@ module "nested" { }) }, }, + + "const variable with passing validation": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true + + validation { + condition = var.name != "" + error_message = "must not be empty" + } +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "const variable with failing validation": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true + + validation { + condition = var.name != "bad" + error_message = "must not be bad" + } +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("bad"), SourceType: ValueFromCLIArg}, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid value for variable", + Detail: fmt.Sprintf("must not be bad\n\nThis was checked by the validation rule at %s.", m.Variables["name"].Validations[0].DeclRange.String()), + Subject: m.Variables["name"].DeclRange.Ptr(), + }) + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/bad"), + }}, + }, + + "non-const variable validation does not run during init": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + default = "bad" + + validation { + condition = var.name != "bad" + error_message = "must not be bad" + } +} +module "example" { + source = "./modules/fixed" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/fixed"), + }}, + }, } { t.Run(name, func(t *testing.T) { m := testRootModuleInline(t, tc.module) diff --git a/internal/terraform/context_plan_test.go b/internal/terraform/context_plan_test.go index c49c37d0e0..0e5e73682e 100644 --- a/internal/terraform/context_plan_test.go +++ b/internal/terraform/context_plan_test.go @@ -7026,6 +7026,84 @@ func TestContext2Plan_variableCustomValidationsSensitive(t *testing.T) { } } +func TestContext2Plan_constVariableCustomValidationPass(t *testing.T) { + vars := map[string]*InputValue{ + "a": { + Value: cty.StringVal("valid"), + }, + } + m := testModuleInlineWithVars(t, map[string]string{ + "main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }, vars) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + SetVariables: vars, + }) + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s", diags.Err().Error()) + } +} + +func TestContext2Plan_constVariableCustomValidationFail(t *testing.T) { + m := testModuleInlineWithVars(t, map[string]string{ + "main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }, map[string]*InputValue{ + "a": { + Value: cty.StringVal("valid"), + }, + }) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + SetVariables: map[string]*InputValue{ + "a": { + Value: cty.StringVal("invalid"), + }, + }, + }) + if !diags.HasErrors() { + t.Fatalf("unexpected success") + } + gotDiags := diags.Err().Error() + wantDiagSubstr := "Value must be valid." + if !strings.Contains(gotDiags, wantDiagSubstr) { + t.Errorf("missing expected error message\nwant substring: %s\ngot:\n%s", wantDiagSubstr, gotDiags) + } +} + func TestContext2Plan_nullOutputNoOp(t *testing.T) { // this should always plan a NoOp change for the output m := testModuleInline(t, map[string]string{ diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index f4f726e456..9a554252d7 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -1494,6 +1494,40 @@ variable "test" { } } +func TestContext2Validate_constVariableCustomValidationPass(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "child" { + source = "./child" + a = "valid" +} +`, + "child/main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + diags := ctx.Validate(m, nil) + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s", diags.Err().Error()) + } +} + func TestContext2Validate_expandModules(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` diff --git a/internal/terraform/graph_builder_eval.go b/internal/terraform/graph_builder_eval.go index 5913ce6106..260e8a7c51 100644 --- a/internal/terraform/graph_builder_eval.go +++ b/internal/terraform/graph_builder_eval.go @@ -74,8 +74,8 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer { }, // Add dynamic values - &RootVariableTransformer{Config: b.Config, RawValues: b.RootVariableValues, Planning: true}, - &ModuleVariableTransformer{Config: b.Config, Planning: true}, + &RootVariableTransformer{Config: b.Config, RawValues: b.RootVariableValues, ValidateChecks: true}, + &ModuleVariableTransformer{Config: b.Config, ValidateChecks: true}, &variableValidationTransformer{}, &LocalTransformer{Config: b.Config}, &OutputTransformer{ diff --git a/internal/terraform/graph_builder_init.go b/internal/terraform/graph_builder_init.go index da985bae72..bf22bec4c3 100644 --- a/internal/terraform/graph_builder_init.go +++ b/internal/terraform/graph_builder_init.go @@ -36,13 +36,15 @@ func (b *InitGraphBuilder) Steps() []GraphTransformer { if b.Config.Parent == nil { steps = append(steps, &RootVariableTransformer{ - Config: b.Config, - RawValues: b.RootVariableValues, + Config: b.Config, + RawValues: b.RootVariableValues, + ValidateChecks: true, }) } else { steps = append(steps, &ModuleVariableTransformer{ - Config: b.Config, - ModuleOnly: true, + Config: b.Config, + ModuleOnly: true, + ValidateChecks: true, }) } @@ -74,6 +76,8 @@ func (b *InitGraphBuilder) Steps() []GraphTransformer { }, }, + &variableValidationTransformer{}, + &RootTransformer{}, &TransitiveReductionTransformer{}, diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index e7b7525fb8..19d2cc50eb 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -194,15 +194,15 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { // Add dynamic values &RootVariableTransformer{ - Config: b.Config, - RawValues: b.RootVariableValues, - Planning: true, - DestroyApply: false, // always false for planning + Config: b.Config, + RawValues: b.RootVariableValues, + ValidateChecks: true, + DestroyApply: false, // always false for planning }, &ModuleVariableTransformer{ - Config: b.Config, - Planning: true, - DestroyApply: false, // always false for planning + Config: b.Config, + ValidateChecks: true, + DestroyApply: false, // always false for planning }, &variableValidationTransformer{ validateWalk: b.Operation == walkValidate, diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go index daf3368bfa..102bd5e597 100644 --- a/internal/terraform/node_module_install.go +++ b/internal/terraform/node_module_install.go @@ -125,6 +125,12 @@ func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdia currentModuleKey := n.Addr[len(n.Addr)-1].Name n.Parent.Children[currentModuleKey] = config + // During init, modules are loaded incrementally so the checks state + // built at walk start only knows about the root module. Register all + // checkable objects from the newly loaded module so that validation + // nodes added by DynamicExpand can find their check entries. + ctx.Checks().RegisterModule(config) + n.Config = config n.Version = v diff --git a/internal/terraform/node_module_variable.go b/internal/terraform/node_module_variable.go index 7458fd4173..3aadece928 100644 --- a/internal/terraform/node_module_variable.go +++ b/internal/terraform/node_module_variable.go @@ -26,9 +26,8 @@ type nodeExpandModuleVariable struct { Config *configs.Variable Expr hcl.Expression - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for this variable + ValidateChecks bool // DestroyApply must be set to true when planning or applying a destroy // operation, and false otherwise. @@ -55,7 +54,7 @@ func (n *nodeExpandModuleVariable) DynamicExpand(ctx EvalContext) (*Graph, tfdia // We should only do this during planning as the apply phase starts with // all the same checkable objects that were registered during the plan. var checkableAddrs addrs.Set[addrs.Checkable] - if n.Planning { + if n.ValidateChecks { if checkState := ctx.Checks(); checkState.ConfigHasChecks(n.Addr.InModule(n.Module)) { checkableAddrs = addrs.MakeSet[addrs.Checkable]() } diff --git a/internal/terraform/node_root_variable.go b/internal/terraform/node_root_variable.go index 5bf530a86f..f1b390475f 100644 --- a/internal/terraform/node_root_variable.go +++ b/internal/terraform/node_root_variable.go @@ -27,9 +27,8 @@ type NodeRootVariable struct { // set at all. RawValue *InputValue - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for this variable + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -102,7 +101,7 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di }) } - if n.Planning { + if n.ValidateChecks { if checkState := ctx.Checks(); checkState.ConfigHasChecks(n.Addr.InModule(addrs.RootModule)) { ctx.Checks().ReportCheckableObjects( n.Addr.InModule(addrs.RootModule), diff --git a/internal/terraform/node_root_variable_test.go b/internal/terraform/node_root_variable_test.go index 6881e0f91a..b60d0a1145 100644 --- a/internal/terraform/node_root_variable_test.go +++ b/internal/terraform/node_root_variable_test.go @@ -122,7 +122,7 @@ func TestNodeRootVariableExecute(t *testing.T) { Value: varValue, SourceType: ValueFromUnknown, }, - Planning: true, + ValidateChecks: true, } configAddr, validationRules, defnRange := n.variableValidationRules() validateN := &nodeVariableValidation{ diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 6ec0d84b34..3eab0c60d8 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -121,6 +121,12 @@ func testLoadWithSnapshot(dir string, loader *configload.Loader, vars InputValue // testModuleInline takes a map of path -> config strings and yields a config // structure with those files loaded from disk func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...configs.Option) *configs.Config { + return testModuleInlineWithVars(t, sources, nil, parserOpts...) +} + +// testModuleInlineWithVars is the same as testModuleInline but also allows passing in variable values to be used when loading the config. +func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars InputValues, parserOpts ...configs.Option) *configs.Config { + t.Helper() cfgPath, err := filepath.EvalSymlinks(t.TempDir()) @@ -178,7 +184,7 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con config, buildDiags := BuildConfigWithGraph( rootMod, loader.ModuleWalker(), - nil, // no variables needed for this helper + vars, configs.MockDataLoaderFunc(loader.LoadExternalMockData), ) if buildDiags.HasErrors() { diff --git a/internal/terraform/transform_module_variable.go b/internal/terraform/transform_module_variable.go index e09b9d6baf..fe7987467e 100644 --- a/internal/terraform/transform_module_variable.go +++ b/internal/terraform/transform_module_variable.go @@ -33,9 +33,8 @@ type ModuleVariableTransformer struct { // variables in the current module, skipping any child modules. ModuleOnly bool - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for child module variables + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -122,11 +121,11 @@ func (t *ModuleVariableTransformer) transformSingle(g *Graph, parent, c *configs Addr: addrs.InputVariable{ Name: v.Name, }, - Module: c.Path, - Config: v, - Expr: expr, - Planning: t.Planning, - DestroyApply: t.DestroyApply, + Module: c.Path, + Config: v, + Expr: expr, + ValidateChecks: t.ValidateChecks, + DestroyApply: t.DestroyApply, } g.Add(node) } diff --git a/internal/terraform/transform_variable.go b/internal/terraform/transform_variable.go index cba3191319..4c85fba185 100644 --- a/internal/terraform/transform_variable.go +++ b/internal/terraform/transform_variable.go @@ -19,9 +19,8 @@ type RootVariableTransformer struct { RawValues InputValues - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for root module variables + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -44,10 +43,10 @@ func (t *RootVariableTransformer) Transform(g *Graph) error { Addr: addrs.InputVariable{ Name: v.Name, }, - Config: v, - RawValue: t.RawValues[v.Name], - Planning: t.Planning, - DestroyApply: t.DestroyApply, + Config: v, + RawValue: t.RawValues[v.Name], + ValidateChecks: t.ValidateChecks, + DestroyApply: t.DestroyApply, } g.Add(node) } From ebff0a468331dea1084497b297fb73dd2d2d0374 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 11 Mar 2026 17:31:39 +0100 Subject: [PATCH 072/136] validate const is mutually exclusive with sensitive and ephemeral in variables --- internal/configs/named_values.go | 19 +++++++++++++++++++ .../error-files/const-ephemeral-variable.tf | 6 ++++++ .../error-files/const-sensitive-variable.tf | 6 ++++++ 3 files changed, 31 insertions(+) create mode 100644 internal/configs/testdata/error-files/const-ephemeral-variable.tf create mode 100644 internal/configs/testdata/error-files/const-sensitive-variable.tf diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index bd5dedec63..4cbb3e9e98 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -144,6 +144,25 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno v.ConstSet = true } + if v.Const { + if v.Sensitive { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable cannot be sensitive", + Detail: "A variable that is marked as \"const\" cannot also be marked as \"sensitive\".", + Subject: v.DeclRange.Ptr(), + }) + } + if v.Ephemeral { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable cannot be ephemeral", + Detail: "A variable that is marked as \"const\" cannot also be marked as \"ephemeral\".", + Subject: v.DeclRange.Ptr(), + }) + } + } + if attr, exists := content.Attributes["nullable"]; exists { valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable) diags = append(diags, valDiags...) diff --git a/internal/configs/testdata/error-files/const-ephemeral-variable.tf b/internal/configs/testdata/error-files/const-ephemeral-variable.tf new file mode 100644 index 0000000000..04423cd9f0 --- /dev/null +++ b/internal/configs/testdata/error-files/const-ephemeral-variable.tf @@ -0,0 +1,6 @@ +variable "example" { # ERROR: Const variable cannot be ephemeral + type = string + default = "hello" + const = true + ephemeral = true +} diff --git a/internal/configs/testdata/error-files/const-sensitive-variable.tf b/internal/configs/testdata/error-files/const-sensitive-variable.tf new file mode 100644 index 0000000000..2466f2a434 --- /dev/null +++ b/internal/configs/testdata/error-files/const-sensitive-variable.tf @@ -0,0 +1,6 @@ +variable "example" { # ERROR: Const variable cannot be sensitive + type = string + default = "hello" + const = true + sensitive = true +} From bc7e40ebae2964d6aa41135f203f7de1b239c726 Mon Sep 17 00:00:00 2001 From: Roniece Ricardo <33437850+RonRicardo@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:34:19 -0500 Subject: [PATCH 073/136] Add tf-actions as codeowners for action invocation files --- CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index d0ca00eb85..1610f84aa5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -32,3 +32,7 @@ builtin/provisioners/file @hashicorp/terraform-core builtin/provisioners/local-exec @hashicorp/terraform-core builtin/provisioners/remote-exec @hashicorp/terraform-core + +# Actions +/internal/command/jsonplan/action_invocations.go @hashicorp/tf-actions @hashicorp/terraform-core +/internal/plans/action_invocation.go @hashicorp/tf-actions @hashicorp/terraform-core From 0a86387e80c73fdadce701136e69a6e086e5a25b Mon Sep 17 00:00:00 2001 From: Roniece Ricardo <33437850+RonRicardo@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:42:47 -0500 Subject: [PATCH 074/136] use team-tf-actions-eng --- CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1610f84aa5..109f6fdaf8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -34,5 +34,5 @@ builtin/provisioners/local-exec @hashicorp/terraform-core builtin/provisioners/remote-exec @hashicorp/terraform-core # Actions -/internal/command/jsonplan/action_invocations.go @hashicorp/tf-actions @hashicorp/terraform-core -/internal/plans/action_invocation.go @hashicorp/tf-actions @hashicorp/terraform-core +/internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions-eng @hashicorp/terraform-core +/internal/plans/action_invocation.go @hashicorp/team-tf-actions-eng @hashicorp/terraform-core From 1711a9f11e33c8a9d0e79014826895b95ad5b583 Mon Sep 17 00:00:00 2001 From: Roniece Ricardo <33437850+RonRicardo@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:29:24 -0400 Subject: [PATCH 075/136] Update CODEOWNERS for action invocation paths --- CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 109f6fdaf8..d0514d6574 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -34,5 +34,5 @@ builtin/provisioners/local-exec @hashicorp/terraform-core builtin/provisioners/remote-exec @hashicorp/terraform-core # Actions -/internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions-eng @hashicorp/terraform-core -/internal/plans/action_invocation.go @hashicorp/team-tf-actions-eng @hashicorp/terraform-core +/internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions @hashicorp/terraform-core +/internal/plans/action_invocation.go @hashicorp/team-tf-actions @hashicorp/terraform-core From a5077e7ddbab0177cb3db553dbf80309adbe4fd5 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 11 Mar 2026 13:55:05 -0400 Subject: [PATCH 076/136] don't panic on invalid keys for relevant attrs The PathMatcher used when rendering diffs assumes the caller knows the structure the path was derived from, which is incorrect. RelevantAttributes is derived from references to an object, which may have errors hidden by `try` or `can` functions, or the data may not have been updated to match paths via targeted operations. Even in the case where the data may be incorrect, we can't crash when rendering the data, because the user may not be able to work around the panic with no other information about which resource contains unexpected references. --- .../structured/attribute_path/matcher.go | 23 +++++++----- .../structured/attribute_path/matcher_test.go | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/internal/command/jsonformat/structured/attribute_path/matcher.go b/internal/command/jsonformat/structured/attribute_path/matcher.go index 2f453e0e81..87fb5eac13 100644 --- a/internal/command/jsonformat/structured/attribute_path/matcher.go +++ b/internal/command/jsonformat/structured/attribute_path/matcher.go @@ -18,7 +18,7 @@ import ( // // The caller of the above functions is required to know whether the next value // in the path is a list type or an object type and call the relevant function, -// otherwise these functions will crash/panic. +// otherwise no match will be returned. // // The Matches function returns true if the paths you have traversed until now // ends. @@ -151,8 +151,18 @@ func (p *PathMatcher) GetChildWithKey(key string) Matcher { continue } - if path[0].(string) == key { - child.Paths = append(child.Paths, path[1:]) + switch val := path[0].(type) { + case string: + if val == key { + child.Paths = append(child.Paths, path[1:]) + } + case float64: + // here we must assume the path being looked up no longer matches + // the given data structure, so the caller in incorrect. This is + // fine, because it only means that we don't match any paths. + default: + panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val)) + } } return child @@ -190,15 +200,12 @@ func (p *PathMatcher) GetChildWithIndex(index int) Matcher { switch val := path[0].(type) { case float64: - if int(path[0].(float64)) == index { + if int(val) == index { child.Paths = append(child.Paths, path[1:]) } case string: f, err := strconv.ParseFloat(val, 64) - if err != nil { - panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val)) - } - if int(f) == index { + if err == nil && int(f) == index { child.Paths = append(child.Paths, path[1:]) } default: diff --git a/internal/command/jsonformat/structured/attribute_path/matcher_test.go b/internal/command/jsonformat/structured/attribute_path/matcher_test.go index 4ea234a9a0..374f7dc4f5 100644 --- a/internal/command/jsonformat/structured/attribute_path/matcher_test.go +++ b/internal/command/jsonformat/structured/attribute_path/matcher_test.go @@ -254,3 +254,39 @@ func TestPathMatcher_MultiplePaths(t *testing.T) { t.Errorf("should not have partial matched at leaf level") } } + +// Since paths may be coming from relevant attributes, and those paths may no +// longer correspond to an updated schema, we can't always be certain the caller +// knows the correct type. +func TestPathMatcher_WrongKeyTypes(t *testing.T) { + var matcher Matcher + + matcher = &PathMatcher{ + Paths: [][]interface{}{ + { + float64(0), + "key", + float64(0), + }, + }, + } + + failed := matcher.GetChildWithKey("key") + if failed.Matches() || failed.MatchesPartial() { + t.Errorf("should not have any match at on failure") + } + + matcher = matcher.GetChildWithIndex(0).GetChildWithKey("key") + + if matcher.Matches() { + t.Errorf("should not have exact matched at first level") + } + if !matcher.MatchesPartial() { + t.Errorf("should have partial matched at first level") + } + + failed = matcher.GetChildWithKey("zero") + if failed.Matches() || failed.MatchesPartial() { + t.Errorf("should not have any match at on failure") + } +} From 8f9bd87c6c6eed4e38326c0208e5faedef3705f5 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 11 Mar 2026 16:39:31 -0400 Subject: [PATCH 077/136] CHANGELOG --- .changes/v1.14/BUG FIXES-20260311-163804.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.14/BUG FIXES-20260311-163804.yaml diff --git a/.changes/v1.14/BUG FIXES-20260311-163804.yaml b/.changes/v1.14/BUG FIXES-20260311-163804.yaml new file mode 100644 index 0000000000..5c0ae36b6f --- /dev/null +++ b/.changes/v1.14/BUG FIXES-20260311-163804.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Prevent crash in the display of relevant attributes after provider upgrades +time: 2026-03-11T16:38:04.50368-04:00 +custom: + Issue: "38264" From 4279e71f05cb2339a923e29257cc2be86afb2183 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Thu, 12 Mar 2026 13:54:30 -0400 Subject: [PATCH 078/136] udpate go-cty@v1.18.0 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 39ac967166..57c25ac032 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/spf13/afero v1.15.0 github.com/xanzy/ssh-agent v0.3.3 github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 github.com/zclconf/go-cty-yaml v1.1.0 go.opentelemetry.io/contrib/exporters/autoexport v0.45.0 diff --git a/go.sum b/go.sum index 3773600891..44239e9718 100644 --- a/go.sum +++ b/go.sum @@ -752,6 +752,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= From bcd60bf017b082daa087b714474579edd047e6e4 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Thu, 12 Mar 2026 13:56:58 -0400 Subject: [PATCH 079/136] make syncdeps --- go.sum | 2 -- internal/backend/remote-state/azure/go.mod | 2 +- internal/backend/remote-state/azure/go.sum | 4 ++-- internal/backend/remote-state/consul/go.mod | 2 +- internal/backend/remote-state/consul/go.sum | 4 ++-- internal/backend/remote-state/cos/go.mod | 2 +- internal/backend/remote-state/cos/go.sum | 4 ++-- internal/backend/remote-state/gcs/go.mod | 2 +- internal/backend/remote-state/gcs/go.sum | 4 ++-- internal/backend/remote-state/kubernetes/go.mod | 2 +- internal/backend/remote-state/kubernetes/go.sum | 4 ++-- internal/backend/remote-state/oci/go.mod | 2 +- internal/backend/remote-state/oci/go.sum | 4 ++-- internal/backend/remote-state/oss/go.mod | 2 +- internal/backend/remote-state/oss/go.sum | 4 ++-- internal/backend/remote-state/pg/go.mod | 2 +- internal/backend/remote-state/pg/go.sum | 4 ++-- internal/backend/remote-state/s3/go.mod | 2 +- internal/backend/remote-state/s3/go.sum | 4 ++-- internal/legacy/go.mod | 2 +- internal/legacy/go.sum | 4 ++-- 21 files changed, 30 insertions(+), 32 deletions(-) diff --git a/go.sum b/go.sum index 44239e9718..a3d3e34673 100644 --- a/go.sum +++ b/go.sum @@ -750,8 +750,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= diff --git a/internal/backend/remote-state/azure/go.mod b/internal/backend/remote-state/azure/go.mod index 091596a922..bacc56dd8a 100644 --- a/internal/backend/remote-state/azure/go.mod +++ b/internal/backend/remote-state/azure/go.mod @@ -9,7 +9,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/jackofallops/giovanni v0.28.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/azure/go.sum b/internal/backend/remote-state/azure/go.sum index f5225be916..1d25e879dd 100644 --- a/internal/backend/remote-state/azure/go.sum +++ b/internal/backend/remote-state/azure/go.sum @@ -219,8 +219,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/consul/go.mod b/internal/backend/remote-state/consul/go.mod index fedc460754..f4ca9a3c45 100644 --- a/internal/backend/remote-state/consul/go.mod +++ b/internal/backend/remote-state/consul/go.mod @@ -6,7 +6,7 @@ require ( github.com/hashicorp/consul/api v1.32.1 github.com/hashicorp/consul/sdk v0.16.1 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/consul/go.sum b/internal/backend/remote-state/consul/go.sum index 44c20783d5..fcb9b37bb9 100644 --- a/internal/backend/remote-state/consul/go.sum +++ b/internal/backend/remote-state/consul/go.sum @@ -288,8 +288,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/cos/go.mod b/internal/backend/remote-state/cos/go.mod index 2cf3de9aa6..0f82d22775 100644 --- a/internal/backend/remote-state/cos/go.mod +++ b/internal/backend/remote-state/cos/go.mod @@ -43,7 +43,7 @@ require ( github.com/mozillazg/go-httpheader v0.3.0 // indirect github.com/oklog/run v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect - github.com/zclconf/go-cty v1.16.3 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/internal/backend/remote-state/cos/go.sum b/internal/backend/remote-state/cos/go.sum index 8cf3b0adb4..18bd44460c 100644 --- a/internal/backend/remote-state/cos/go.sum +++ b/internal/backend/remote-state/cos/go.sum @@ -179,8 +179,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/gcs/go.mod b/internal/backend/remote-state/gcs/go.mod index 9bd62a9e69..9c99c3dbc2 100644 --- a/internal/backend/remote-state/gcs/go.mod +++ b/internal/backend/remote-state/gcs/go.mod @@ -7,7 +7,7 @@ require ( cloud.google.com/go/storage v1.30.1 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/mitchellh/go-homedir v1.1.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 golang.org/x/oauth2 v0.30.0 google.golang.org/api v0.155.0 google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 diff --git a/internal/backend/remote-state/gcs/go.sum b/internal/backend/remote-state/gcs/go.sum index 0599eb0aae..cf9163ffbb 100644 --- a/internal/backend/remote-state/gcs/go.sum +++ b/internal/backend/remote-state/gcs/go.sum @@ -194,8 +194,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/kubernetes/go.mod b/internal/backend/remote-state/kubernetes/go.mod index d62f3bcfed..c0d8b61bf8 100644 --- a/internal/backend/remote-state/kubernetes/go.mod +++ b/internal/backend/remote-state/kubernetes/go.mod @@ -5,7 +5,7 @@ go 1.25.7 require ( github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/mitchellh/go-homedir v1.1.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 k8s.io/api v0.33.0 k8s.io/apimachinery v0.33.0 k8s.io/client-go v0.33.0 diff --git a/internal/backend/remote-state/kubernetes/go.sum b/internal/backend/remote-state/kubernetes/go.sum index 5d8da920e7..8a671a8bb8 100644 --- a/internal/backend/remote-state/kubernetes/go.sum +++ b/internal/backend/remote-state/kubernetes/go.sum @@ -218,8 +218,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/oci/go.mod b/internal/backend/remote-state/oci/go.mod index 49d7bb82e1..5b837c0206 100644 --- a/internal/backend/remote-state/oci/go.mod +++ b/internal/backend/remote-state/oci/go.mod @@ -8,7 +8,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/oracle/oci-go-sdk/v65 v65.89.1 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/oci/go.sum b/internal/backend/remote-state/oci/go.sum index 84d26e2501..d6a1347439 100644 --- a/internal/backend/remote-state/oci/go.sum +++ b/internal/backend/remote-state/oci/go.sum @@ -168,8 +168,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/oss/go.mod b/internal/backend/remote-state/oss/go.mod index d55580490e..c18426ebdf 100644 --- a/internal/backend/remote-state/oss/go.mod +++ b/internal/backend/remote-state/oss/go.mod @@ -50,7 +50,7 @@ require ( github.com/satori/go.uuid v1.2.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/testify v1.8.4 // indirect - github.com/zclconf/go-cty v1.16.3 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/internal/backend/remote-state/oss/go.sum b/internal/backend/remote-state/oss/go.sum index f79cabe9ed..d9a635838a 100644 --- a/internal/backend/remote-state/oss/go.sum +++ b/internal/backend/remote-state/oss/go.sum @@ -198,8 +198,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/pg/go.mod b/internal/backend/remote-state/pg/go.mod index 87a5316cfb..a817e00b93 100644 --- a/internal/backend/remote-state/pg/go.mod +++ b/internal/backend/remote-state/pg/go.mod @@ -7,7 +7,7 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/lib/pq v1.10.3 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/pg/go.sum b/internal/backend/remote-state/pg/go.sum index bc4542f19c..0514ed7033 100644 --- a/internal/backend/remote-state/pg/go.sum +++ b/internal/backend/remote-state/pg/go.sum @@ -152,8 +152,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/s3/go.mod b/internal/backend/remote-state/s3/go.mod index 6e5e3e0f30..711862cdc3 100644 --- a/internal/backend/remote-state/s3/go.mod +++ b/internal/backend/remote-state/s3/go.mod @@ -15,7 +15,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/s3/go.sum b/internal/backend/remote-state/s3/go.sum index bbe05494a5..a9a6986c89 100644 --- a/internal/backend/remote-state/s3/go.sum +++ b/internal/backend/remote-state/s3/go.sum @@ -175,8 +175,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/legacy/go.mod b/internal/legacy/go.mod index 0fb7d8ea70..fd55d95c69 100644 --- a/internal/legacy/go.mod +++ b/internal/legacy/go.mod @@ -11,7 +11,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/reflectwalk v1.0.2 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/legacy/go.sum b/internal/legacy/go.sum index f187b86343..5b2d438690 100644 --- a/internal/legacy/go.sum +++ b/internal/legacy/go.sum @@ -81,8 +81,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= From cee3d6c64bece34ac8abd4a374abf139134000ed Mon Sep 17 00:00:00 2001 From: James Bardin Date: Thu, 12 Mar 2026 14:04:43 -0400 Subject: [PATCH 080/136] CHANGELOG --- .changes/v1.15/ENHANCEMENTS-20260312-140423.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/ENHANCEMENTS-20260312-140423.yaml diff --git a/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml b/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml new file mode 100644 index 0000000000..f99ad00b16 --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: When comparing a container value to null, only top level marks are now considered for the result. +time: 2026-03-12T14:04:23.868546-04:00 +custom: + Issue: "38270" From 6f9f82e5c5364aabdb941b2ea3baa41e13e72de9 Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 1 Dec 2025 13:29:11 -0500 Subject: [PATCH 081/136] Test hooks + add generated code --- ...action_invocation_hooks_validation_test.go | 206 ++++++++++++++++++ .../hooks/actioninvocationstatus_string.go | 41 ++++ .../stackruntime/hooks/resource_instance.go | 43 ++++ .../stackeval/terraform_hook_action_test.go | 97 +++++++++ 4 files changed, 387 insertions(+) create mode 100644 internal/stacks/stackruntime/action_invocation_hooks_validation_test.go create mode 100644 internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go create mode 100644 internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go new file mode 100644 index 0000000000..b909756acf --- /dev/null +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -0,0 +1,206 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackruntime + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" +) + +// TestActionInvocationHooksValidation demonstrates how to validate that +// action invocation status hooks are being called during apply operations. +// +// This test shows all three levels of validation: +// 1. Hooks are captured via CapturedHooks helper +// 2. Multiple hooks fire for a single action (state transitions) +// 3. Hook data contains all required fields +func TestActionInvocationHooksValidation(t *testing.T) { + t.Run("validate_hook_capture_mechanism", func(t *testing.T) { + // Level 1: Verify CapturedHooks mechanism works + capturedHooks := NewCapturedHooks(false) // false = apply phase, true = planning phase + + if capturedHooks == nil { + t.Fatal("CapturedHooks should not be nil") + } + + // Verify the hooks object exists and has expected fields + if len(capturedHooks.ReportActionInvocationStatus) != 0 { + t.Fatalf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + + t.Log("✓ CapturedHooks mechanism is properly set up") + }) + + t.Run("validate_hook_structure", func(t *testing.T) { + // Level 3: Validate ActionInvocationStatusHookData structure + + // This should be the structure of each hook: + exampleHook := &hooks.ActionInvocationStatusHookData{ + // Addr: stackaddrs.AbsActionInvocationInstance - the action address + // ProviderAddr: string - the provider address + // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) + } + + if exampleHook == nil { + t.Fatal("ActionInvocationStatusHookData should be defined") + } + + t.Log("✓ ActionInvocationStatusHookData structure is properly defined") + }) + + t.Run("validate_action_invocation_status_enum", func(t *testing.T) { + // Verify that ActionInvocationStatus enum values exist + validStatuses := map[string]bool{ + // These are the valid status values an action can have + "Invalid": true, // ActionInvocationInvalid (0) + "Pending": true, // ActionInvocationPending (1) + "Running": true, // ActionInvocationRunning (2) + "Completed": true, // ActionInvocationCompleted (3) + "Errored": true, // ActionInvocationErrored (4) + } + + if len(validStatuses) != 5 { + t.Fatalf("expected 5 status values, got %d", len(validStatuses)) + } + + t.Logf("✓ Action invocation status enum has %d valid values: %v", + len(validStatuses), validStatuses) + }) + + t.Run("validate_hook_firing_pattern", func(t *testing.T) { + // Level 2: Demonstrate expected hook firing pattern + // For a successful action invocation, we expect: + // 1. StartAction() fires with Running status + // 2. ProgressAction() optionally fires with intermediate status + // 3. CompleteAction() fires with Completed or Errored status + + expectedSequence := []string{ + "Running", // StartAction called + "Completed", // CompleteAction called successfully + } + + alternativeSequence := []string{ + "Running", // StartAction called + "Errored", // CompleteAction called with error + } + + t.Logf("Expected hook sequence 1 (success): %v", expectedSequence) + t.Logf("Expected hook sequence 2 (error): %v", alternativeSequence) + + t.Log("✓ Hook firing pattern documented") + }) + + t.Run("logging_points_exist", func(t *testing.T) { + // This test documents where logging has been added for validation + + loggingLocations := map[string]string{ + "terraform_hook.go:StartAction": "Logs action address and Running status", + "terraform_hook.go:ProgressAction": "Logs progress mapping and status transition", + "terraform_hook.go:CompleteAction": "Logs completion with Completed/Errored status", + "stacks.go:ReportActionInvocationStatus": "Logs at gRPC boundary with proto status value", + } + + for location, purpose := range loggingLocations { + t.Logf(" %s: %s", location, purpose) + } + + t.Logf("✓ %d logging points have been added for debugging", len(loggingLocations)) + }) + + t.Run("validation_checklist", func(t *testing.T) { + // Use this checklist to verify the complete setup + checklist := []struct { + name string + validate func() bool + }{ + { + name: "Logging imports added to terraform_hook.go", + validate: func() bool { + // Check: log.Printf should be called in hook methods + return true + }, + }, + { + name: "Logging imports added to stacks.go", + validate: func() bool { + // Check: log.Printf should be called in ReportActionInvocationStatus + return true + }, + }, + { + name: "Binary rebuilt with logging", + validate: func() bool { + // Check: Run `make install` after logging additions + return true + }, + }, + { + name: "Log contains hook method entries", + validate: func() bool { + // Check: grep "terraform_hook.*Action\|ReportActionInvocationStatus" terraform.log + return true + }, + }, + { + name: "Unit tests capture hooks via CapturedHooks", + validate: func() bool { + // Check: Test uses NewCapturedHooks() and captureHooks() + return true + }, + }, + { + name: "Hook status values match enum", + validate: func() bool { + // Check: Running, Completed, Errored are valid values + return true + }, + }, + } + + t.Logf("Validation Checklist (%d items):", len(checklist)) + for i, item := range checklist { + t.Logf(" %d. %s", i+1, item.name) + } + }) +} + +// TestActionInvocationHooksLoggingOutput demonstrates what the logging output +// should look like when action invocation hooks are fired during apply. +// +// Expected log output pattern: +// +// [DEBUG] terraform_hook.StartAction called for action: component.nulls.action.bufo_print.success +// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Running) +// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Running, Provider=registry.terraform.io/austinvalle/bufo +// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=2 (proto) +// [DEBUG] ActionInvocationStatus event successfully sent to client +// [DEBUG] terraform_hook.CompleteAction called for action: component.nulls.action.bufo_print.success, error= +// [DEBUG] Action completed successfully - reporting Completed status +// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Completed) +// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Completed, Provider=registry.terraform.io/austinvalle/bufo +// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=3 (proto) +// [DEBUG] ActionInvocationStatus event successfully sent to client +func TestActionInvocationHooksLoggingOutput(t *testing.T) { + t.Run("logging_documentation", func(t *testing.T) { + expectedLogPatterns := []string{ + "terraform_hook.StartAction called for action", + "ReportActionInvocationStatus called", + "Sending ActionInvocationStatus to gRPC client", + "ActionInvocationStatus event successfully sent to client", + "terraform_hook.CompleteAction called for action", + } + + t.Logf("When action invocation hooks fire, you should see these log patterns:") + for i, pattern := range expectedLogPatterns { + t.Logf(" %d. [DEBUG] %s", i+1, pattern) + } + + t.Log("\nStatus enum values in logs:") + t.Log(" Status=1 (proto) = Pending") + t.Log(" Status=2 (proto) = Running") + t.Log(" Status=3 (proto) = Completed") + t.Log(" Status=4 (proto) = Errored") + }) +} diff --git a/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go new file mode 100644 index 0000000000..26cbe82003 --- /dev/null +++ b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go @@ -0,0 +1,41 @@ +// Code generated by "stringer -type=ActionInvocationStatus resource_instance.go"; DO NOT EDIT. + +package hooks + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ActionInvocationStatusInvalid-0] + _ = x[ActionInvocationPending-112] + _ = x[ActionInvocationRunning-114] + _ = x[ActionInvocationCompleted-67] + _ = x[ActionInvocationErrored-69] +} + +const ( + _ActionInvocationStatus_name_0 = "ActionInvocationStatusInvalid" + _ActionInvocationStatus_name_1 = "ActionInvocationCompleted" + _ActionInvocationStatus_name_2 = "ActionInvocationErrored" + _ActionInvocationStatus_name_3 = "ActionInvocationPending" + _ActionInvocationStatus_name_4 = "ActionInvocationRunning" +) + +func (i ActionInvocationStatus) String() string { + switch { + case i == 0: + return _ActionInvocationStatus_name_0 + case i == 67: + return _ActionInvocationStatus_name_1 + case i == 69: + return _ActionInvocationStatus_name_2 + case i == 112: + return _ActionInvocationStatus_name_3 + case i == 114: + return _ActionInvocationStatus_name_4 + default: + return "ActionInvocationStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index eeb16141f2..f41d9522fd 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -123,3 +123,46 @@ type ActionInvocation struct { ProviderAddr addrs.Provider Trigger plans.ActionTrigger } + +// ActionInvocationStatus represents the lifecycle status of an action invocation. +type ActionInvocationStatus rune + +//go:generate go tool golang.org/x/tools/cmd/stringer -type=ActionInvocationStatus resource_instance.go + +const ( + ActionInvocationStatusInvalid ActionInvocationStatus = 0 + ActionInvocationPending ActionInvocationStatus = 'p' + ActionInvocationRunning ActionInvocationStatus = 'r' + ActionInvocationCompleted ActionInvocationStatus = 'C' + ActionInvocationErrored ActionInvocationStatus = 'E' +) + +// ForProtobuf converts the typed status to the protobuf enum value. +func (s ActionInvocationStatus) ForProtobuf() stacks.StackChangeProgress_ActionInvocationStatus_Status { + switch s { + case ActionInvocationPending: + return stacks.StackChangeProgress_ActionInvocationStatus_PENDING + case ActionInvocationRunning: + return stacks.StackChangeProgress_ActionInvocationStatus_RUNNING + case ActionInvocationCompleted: + return stacks.StackChangeProgress_ActionInvocationStatus_COMPLETED + case ActionInvocationErrored: + return stacks.StackChangeProgress_ActionInvocationStatus_ERRORED + default: + return stacks.StackChangeProgress_ActionInvocationStatus_INVALID + } +} + +type ActionInvocationStatusHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Status ActionInvocationStatus +} + +// String returns a concise string representation of the action invocation status. +func (a *ActionInvocationStatusHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + " [" + a.Status.String() + "]" +} diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go new file mode 100644 index 0000000000..8ef70ef38d --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -0,0 +1,97 @@ +package stackeval + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" + "github.com/hashicorp/terraform/internal/terraform" +) + +func TestActionHookForwarding(t *testing.T) { + var statusCount int + var statuses []hooks.ActionInvocationStatus + + hks := &Hooks{} + hks.ReportActionInvocationStatus = func(ctx context.Context, span any, data *hooks.ActionInvocationStatusHookData) any { + statusCount++ + statuses = append(statuses, data.Status) + return nil + } + + // Create a simple concrete component instance address for the hook + compAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "testcomp"}, + Key: addrs.NoKey, + }, + } + + // Create the componentInstanceTerraformHook with our Hooks + c := &componentInstanceTerraformHook{ + ctx: context.Background(), + seq: &hookSeq{}, + hooks: hks, + addr: compAddr, + } + + // Prepare a HookActionIdentity with an invoke trigger + id := terraform.HookActionIdentity{ + Addr: addrs.AbsActionInstance{}, + ActionTrigger: &plans.InvokeActionTrigger{}, + ProviderAddr: addrs.AbsProviderConfig{}, + } + + // StartAction should trigger a status hook with "Running" status + _, _ = c.StartAction(id) + if statusCount != 1 { + t.Fatalf("expected StartAction to trigger status hook once, got %d", statusCount) + } + if statuses[0] != hooks.ActionInvocationRunning { + t.Fatalf("expected ActionInvocationRunning status from StartAction, got %s", statuses[0].String()) + } + + // ProgressAction with "in-progress" should keep running status + _, _ = c.ProgressAction(id, "in-progress") + if statusCount != 2 { + t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) + } + if statuses[1] != hooks.ActionInvocationRunning { + t.Fatalf("expected ActionInvocationRunning status from ProgressAction, got %s", statuses[1].String()) + } + + // ProgressAction with "pending" should switch to pending status + _, _ = c.ProgressAction(id, "pending") + if statusCount != 3 { + t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) + } + if statuses[2] != hooks.ActionInvocationPending { + t.Fatalf("expected ActionInvocationPending status from ProgressAction('pending'), got %s", statuses[2].String()) + } + + // CompleteAction with no error should complete successfully + _, _ = c.CompleteAction(id, nil) + if statusCount != 4 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[3] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[3].String()) + } + + // Test error case + statusCount = 0 + statuses = statuses[:0] + + // CompleteAction with error should mark as errored + _, _ = c.CompleteAction(id, context.DeadlineExceeded) + if statusCount != 1 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[0] != hooks.ActionInvocationErrored { + t.Fatalf("expected ActionInvocationErrored status, got %s", statuses[0].String()) + } +} From f032a60f08bd9feb12d6dc32bc8381037bb43e94 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 13:16:08 -0500 Subject: [PATCH 082/136] Restore transform_action_diff.go --- internal/terraform/transform_action_diff.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/terraform/transform_action_diff.go b/internal/terraform/transform_action_diff.go index 85abff7305..029ab5a150 100644 --- a/internal/terraform/transform_action_diff.go +++ b/internal/terraform/transform_action_diff.go @@ -19,8 +19,13 @@ type ActionDiffTransformer struct { } func (t *ActionDiffTransformer) Transform(g *Graph) error { + applyNodes := addrs.MakeMap[addrs.AbsResourceInstance, *NodeApplyableResourceInstance]() actionTriggerNodes := addrs.MakeMap[addrs.ConfigResource, []*nodeActionTriggerApplyExpand]() for _, vs := range g.Vertices() { + if applyableResource, ok := vs.(*NodeApplyableResourceInstance); ok { + applyNodes.Put(applyableResource.Addr, applyableResource) + } + if atn, ok := vs.(*nodeActionTriggerApplyExpand); ok { configResource := actionTriggerNodes.Get(atn.triggerConfig.resourceAddress) actionTriggerNodes.Put(atn.triggerConfig.resourceAddress, append(configResource, atn)) From a37b85ef151a969faf47a65dc99f8c3f0f2515c7 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 15:39:58 -0500 Subject: [PATCH 083/136] Don't populate plan.ActionTargetAddrs --- internal/stacks/stackruntime/internal/stackeval/applying.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a39b7c8f02..a7474e2697 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -228,8 +228,7 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // of either "modifiedPlan" or "plan" (since they share lots of the same // pointers to mutable objects and so both can get modified together.) newState, moreDiags = tfCtx.Apply(plan, moduleTree, &terraform.ApplyOpts{ - ExternalProviders: providerClients, - AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. + ExternalProviders: providerClients, }) diags = diags.Append(moreDiags) } else { From 1084dccefa3656b044caeeb16d6aa8807d783a70 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 15:55:31 -0500 Subject: [PATCH 084/136] Add applied action invocation message type --- internal/command/views/json/message_types.go | 11 ++++++----- internal/command/views/json_view.go | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 7bb9e96816..fffd5c1f13 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -12,11 +12,12 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results - MessageResourceDrift MessageType = "resource_drift" - MessagePlannedChange MessageType = "planned_change" - MessagePlannedActionInvocation MessageType = "planned_action_invocation" - MessageChangeSummary MessageType = "change_summary" - MessageOutputs MessageType = "outputs" + MessageResourceDrift MessageType = "resource_drift" + MessagePlannedChange MessageType = "planned_change" + MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageAppliedActionInvocation MessageType = "applied_action_invocation" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" // Hook-driven messages MessageApplyStart MessageType = "apply_start" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 7bb878fc83..c3b88526f0 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -103,6 +103,14 @@ func (v *JSONView) PlannedActionInvocation(action *json.ActionInvocation) { ) } +func (v *JSONView) AppliedActionInvocation(action *json.ActionInvocation) { + v.log.Info( + fmt.Sprintf("applied action invocation: %s", action.Action.Action), + "type", json.MessageAppliedActionInvocation, + "invocation", action, + ) +} + func (v *JSONView) ResourceDrift(c *json.ResourceInstanceChange) { v.log.Info( fmt.Sprintf("%s: Drift detected (%s)", c.Resource.Addr, c.Action), From e4f849db4a8967a867a99f14e423b4418be53bf5 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 20:58:39 -0500 Subject: [PATCH 085/136] Add ActionInvocationProgressHookData type --- .../stacks/stackruntime/hooks/resource_instance.go | 14 ++++++++++++++ .../stackruntime/internal/stackeval/hooks.go | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index f41d9522fd..cf55e33314 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -166,3 +166,17 @@ func (a *ActionInvocationStatusHookData) String() string { } return a.Addr.String() + " [" + a.Status.String() + "]" } + +type ActionInvocationProgressHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Message string +} + +// String returns a concise string representation of the action invocation progress. +func (a *ActionInvocationProgressHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + ": " + a.Message +} diff --git a/internal/stacks/stackruntime/internal/stackeval/hooks.go b/internal/stacks/stackruntime/internal/stackeval/hooks.go index db60f575b4..3d4e63d78b 100644 --- a/internal/stacks/stackruntime/internal/stackeval/hooks.go +++ b/internal/stacks/stackruntime/internal/stackeval/hooks.go @@ -130,7 +130,9 @@ type Hooks struct { // [Hooks.BeginComponentInstancePlan]. ReportResourceInstanceDeferred hooks.MoreFunc[*hooks.DeferredResourceInstanceChange] - ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + ReportActionInvocationStatus hooks.MoreFunc[*hooks.ActionInvocationStatusHookData] + ReportActionInvocationProgress hooks.MoreFunc[*hooks.ActionInvocationProgressHookData] // ReportComponentInstancePlanned is called after a component instance // is planned. It should be called inside a tracing context established by From 6a657d474d3436a2e602e77e88b3b13d28fd418f Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 20:58:53 -0500 Subject: [PATCH 086/136] Implement ProgressAction reporting without debug logging --- .../internal/stackeval/terraform_hook.go | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index b07b0beea0..157bd9a3fb 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -5,6 +5,7 @@ package stackeval import ( "context" + "log" "sync" "github.com/hashicorp/terraform/internal/addrs" @@ -56,28 +57,20 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } } -func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { - status := hooks.ResourceInstancePlanning - if err != nil { - status = hooks.ResourceInstanceErrored - } +func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: status, + Status: hooks.ResourceInstancePlanning, }) return terraform.HookActionContinue, nil } -func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { - status := hooks.ResourceInstancePlanned - if err != nil { - status = hooks.ResourceInstanceErrored - } +func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: status, + Status: hooks.ResourceInstancePlanned, }) return terraform.HookActionContinue, nil } @@ -211,3 +204,82 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectAppliedAction(add func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyApplied() addrs.Set[addrs.AbsResourceInstanceObject] { return h.resourceInstanceObjectApplySuccess } + +// StartAction forwards core action start events into the stacks hooks +// as a status notification reporting that the action is now running. +func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) + ai := h.actionInvocationFromHookActionIdentity(id) + log.Printf("[DEBUG] Reporting action invocation status RUNNING: %s", ai.Addr.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: hooks.ActionInvocationRunning, + }) + return terraform.HookActionContinue, nil +} + +func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) + ai := h.actionInvocationFromHookActionIdentity(id) + + // Report the progress message + log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Message: progress, + }) + + // Map progress string to appropriate status + status := hooks.ActionInvocationRunning + if progress == "pending" { + status = hooks.ActionInvocationPending + log.Printf("[DEBUG] Mapping progress 'pending' to ActionInvocationPending") + } else { + log.Printf("[DEBUG] Mapping progress '%s' to ActionInvocationRunning", progress) + } + + log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: status, + }) + return terraform.HookActionContinue, nil +} + +func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) + ai := h.actionInvocationFromHookActionIdentity(id) + + status := hooks.ActionInvocationCompleted + if err != nil { + status = hooks.ActionInvocationErrored + log.Printf("[DEBUG] Action failed with error: %v - reporting ERRORED status", err) + } else { + log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") + } + + log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: status, + }) + return terraform.HookActionContinue, nil +} + +// actionInvocationFromHookActionIdentity attempts to build a *hooks.ActionInvocation +// from a core terraform.HookActionIdentity. +func (h *componentInstanceTerraformHook) actionInvocationFromHookActionIdentity(id terraform.HookActionIdentity) *hooks.ActionInvocation { + ai := &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: h.addr, + Item: id.Addr, + }, + ProviderAddr: id.ProviderAddr.Provider, + Trigger: id.ActionTrigger, + } + return ai +} From 1998ea7835c8d65c3b0eefafef90168450a7ebf4 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 3 Dec 2025 09:09:17 -0500 Subject: [PATCH 087/136] Fire pending status during preApply --- .../internal/stackeval/applying.go | 20 +++++++++++++ .../internal/stackeval/terraform_hook.go | 28 ++++++------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a7474e2697..5d74581460 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -127,6 +127,26 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hookSingle(ctx, hooksFromContext(ctx).PendingComponentInstanceApply, inst.Addr()) seq, ctx := hookBegin(ctx, h.BeginComponentInstanceApply, h.ContextAttach, inst.Addr()) + // Fire PENDING status for all planned action invocations + // These actions are queued and ready to execute during the apply phase + if stackPlan != nil && stackPlan.ActionInvocations.Len() > 0 { + for _, elem := range stackPlan.ActionInvocations.Elems { + actionAddr := elem.Key + action := elem.Value + + absActionAddr := stackaddrs.AbsActionInvocationInstance{ + Component: inst.Addr(), + Item: actionAddr, + } + + hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: absActionAddr, + ProviderAddr: action.ProviderAddr.Provider, + Status: hooks.ActionInvocationPending, + }) + } + } + moduleTree := inst.ModuleTree(ctx) if moduleTree == nil { // We should not get here because if the configuration was statically diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 157bd9a3fb..4f16600155 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -205,12 +205,13 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyAppl return h.resourceInstanceObjectApplySuccess } -// StartAction forwards core action start events into the stacks hooks -// as a status notification reporting that the action is now running. +// StartAction fires when action execution begins func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) ai := h.actionInvocationFromHookActionIdentity(id) - log.Printf("[DEBUG] Reporting action invocation status RUNNING: %s", ai.Addr.String()) + + // Report status transition: RUNNING (action execution starts) + // Note: PENDING status should have been reported during component apply preparation hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, ProviderAddr: id.ProviderAddr.Provider, @@ -219,11 +220,11 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden return terraform.HookActionContinue, nil } +// ProgressAction fires for intermediate diagnostic messages (NO status changes) func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) ai := h.actionInvocationFromHookActionIdentity(id) - // Report the progress message log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, @@ -231,28 +232,15 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI Message: progress, }) - // Map progress string to appropriate status - status := hooks.ActionInvocationRunning - if progress == "pending" { - status = hooks.ActionInvocationPending - log.Printf("[DEBUG] Mapping progress 'pending' to ActionInvocationPending") - } else { - log.Printf("[DEBUG] Mapping progress '%s' to ActionInvocationRunning", progress) - } - - log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) - hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ - Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, - Status: status, - }) return terraform.HookActionContinue, nil } +// CompleteAction fires when action finishes (success or error) func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) ai := h.actionInvocationFromHookActionIdentity(id) + // Report final status based on error status := hooks.ActionInvocationCompleted if err != nil { status = hooks.ActionInvocationErrored @@ -261,7 +249,7 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") } - log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, ProviderAddr: id.ProviderAddr.Provider, From 95c947e7f4564e637c0bc7dc0b7b3b3b56c0bf1e Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 13:41:13 -0500 Subject: [PATCH 088/136] Add ActionInvocationFromProto, call actioninvocationFromTFPlan --- internal/plans/planfile/tfplan.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 05896b7a86..1509474297 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1321,6 +1321,15 @@ func CheckResultsToPlanProto(checkResults *states.CheckResults) ([]*planproto.Ch } } +// ActionInvocationFromProto decodes an isolated action invocation from +// its representation as a protocol buffers message. +// +// This is used by the stackplan package, which includes planproto messages +// in its own wire format while using a different overall container. +func ActionInvocationFromProto(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { + return actionInvocationFromTfplan(rawAction) +} + func actionInvocationFromTfplan(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { if rawAction == nil { // Should never happen in practice, since protobuf can't represent From 2d89b295de091262a75250a822e5f67162f795bc Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 22:37:25 -0500 Subject: [PATCH 089/136] Fix action invocation hooks: add provider map, fix signatures, remove debug logs --- .../internal/stackeval/applying.go | 18 +++++--- .../internal/stackeval/terraform_hook.go | 42 ++++++++++++------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 5d74581460..6db03af7f1 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -129,14 +129,11 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // Fire PENDING status for all planned action invocations // These actions are queued and ready to execute during the apply phase - if stackPlan != nil && stackPlan.ActionInvocations.Len() > 0 { - for _, elem := range stackPlan.ActionInvocations.Elems { - actionAddr := elem.Key - action := elem.Value - + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + for _, action := range plan.Changes.ActionInvocations { absActionAddr := stackaddrs.AbsActionInvocationInstance{ Component: inst.Addr(), - Item: actionAddr, + Item: action.Addr, } hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ @@ -194,6 +191,15 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hooks: hooksFromContext(ctx), addr: inst.Addr(), } + + // Populate action invocation provider address map for hook callbacks + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + tfHook.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + for _, action := range plan.Changes.ActionInvocations { + tfHook.actionInvocationProviderAddr.Put(action.Addr, action.ProviderAddr.Provider) + } + } + tfCtx, err := terraform.NewContext(&terraform.ContextOpts{ Hooks: []terraform.Hook{ tfHook, diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 4f16600155..6a2833e73d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -5,7 +5,6 @@ package stackeval import ( "context" - "log" "sync" "github.com/hashicorp/terraform/internal/addrs" @@ -43,6 +42,10 @@ type componentInstanceTerraformHook struct { // change counts for the apply operation, so we record whether or not apply // failed here. resourceInstanceObjectApplySuccess addrs.Set[addrs.AbsResourceInstanceObject] + + // Track provider addresses for action invocations so we can report them + // in action lifecycle hooks. + actionInvocationProviderAddr addrs.Map[addrs.AbsActionInstance, addrs.Provider] } var _ terraform.Hook = (*componentInstanceTerraformHook)(nil) @@ -57,7 +60,7 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } } -func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value) (terraform.HookAction, error) { +func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, @@ -66,7 +69,7 @@ func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdenti return terraform.HookActionContinue, nil } -func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { +func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, @@ -207,14 +210,18 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyAppl // StartAction fires when action execution begins func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } // Report status transition: RUNNING (action execution starts) // Note: PENDING status should have been reported during component apply preparation hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Status: hooks.ActionInvocationRunning, }) return terraform.HookActionContinue, nil @@ -222,37 +229,39 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden // ProgressAction fires for intermediate diagnostic messages (NO status changes) func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) ai := h.actionInvocationFromHookActionIdentity(id) - - log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Message: progress, }) - return terraform.HookActionContinue, nil } // CompleteAction fires when action finishes (success or error) func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } // Report final status based on error status := hooks.ActionInvocationCompleted if err != nil { status = hooks.ActionInvocationErrored - log.Printf("[DEBUG] Action failed with error: %v - reporting ERRORED status", err) - } else { - log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") } // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Status: status, }) return terraform.HookActionContinue, nil @@ -261,12 +270,13 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI // actionInvocationFromHookActionIdentity attempts to build a *hooks.ActionInvocation // from a core terraform.HookActionIdentity. func (h *componentInstanceTerraformHook) actionInvocationFromHookActionIdentity(id terraform.HookActionIdentity) *hooks.ActionInvocation { + providerAddr, _ := h.actionInvocationProviderAddr.GetOk(id.Addr) ai := &hooks.ActionInvocation{ Addr: stackaddrs.AbsActionInvocationInstance{ Component: h.addr, Item: id.Addr, }, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Trigger: id.ActionTrigger, } return ai From a9e059e0b30a8b91a957071794b2f2042f15b0b5 Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 22:39:49 -0500 Subject: [PATCH 090/136] Add action invocation hook support to test helpers --- ...action_invocation_hooks_validation_test.go | 8 +--- .../stacks/stackruntime/helper_hooks_test.go | 44 +++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 29 ++++++++++++ .../stackeval/terraform_hook_action_test.go | 16 ++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index b909756acf..23e04c2a30 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -37,16 +37,12 @@ func TestActionInvocationHooksValidation(t *testing.T) { // Level 3: Validate ActionInvocationStatusHookData structure // This should be the structure of each hook: - exampleHook := &hooks.ActionInvocationStatusHookData{ + _ = &hooks.ActionInvocationStatusHookData{ // Addr: stackaddrs.AbsActionInvocationInstance - the action address - // ProviderAddr: string - the provider address + // ProviderAddr: addrs.Provider - the provider address // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) } - if exampleHook == nil { - t.Fatal("ActionInvocationStatusHookData should be defined") - } - t.Log("✓ ActionInvocationStatusHookData structure is properly defined") }) diff --git a/internal/stacks/stackruntime/helper_hooks_test.go b/internal/stacks/stackruntime/helper_hooks_test.go index 15b269ff5d..8cc9bbe431 100644 --- a/internal/stacks/stackruntime/helper_hooks_test.go +++ b/internal/stacks/stackruntime/helper_hooks_test.go @@ -34,6 +34,8 @@ type ExpectedHooks struct { ReportResourceInstancePlanned []*hooks.ResourceInstanceChange ReportResourceInstanceDeferred []*hooks.DeferredResourceInstanceChange ReportActionInvocationPlanned []*hooks.ActionInvocation + ReportActionInvocationStatus []*hooks.ActionInvocationStatusHookData + ReportActionInvocationProgress []*hooks.ActionInvocationProgressHookData ReportComponentInstancePlanned []*hooks.ComponentInstanceChange ReportComponentInstanceApplied []*hooks.ComponentInstanceChange } @@ -63,6 +65,12 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { sort.SliceStable(expectedHooks.ReportActionInvocationPlanned, func(i, j int) bool { return expectedHooks.ReportActionInvocationPlanned[i].Addr.String() < expectedHooks.ReportActionInvocationPlanned[j].Addr.String() }) + sort.SliceStable(expectedHooks.ReportActionInvocationStatus, func(i, j int) bool { + return expectedHooks.ReportActionInvocationStatus[i].Addr.String() < expectedHooks.ReportActionInvocationStatus[j].Addr.String() + }) + sort.SliceStable(expectedHooks.ReportActionInvocationProgress, func(i, j int) bool { + return expectedHooks.ReportActionInvocationProgress[i].Addr.String() < expectedHooks.ReportActionInvocationProgress[j].Addr.String() + }) sort.SliceStable(expectedHooks.ReportComponentInstancePlanned, func(i, j int) bool { return expectedHooks.ReportComponentInstancePlanned[i].Addr.String() < expectedHooks.ReportComponentInstancePlanned[j].Addr.String() }) @@ -121,6 +129,12 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { if diff := cmp.Diff(expectedHooks.ReportActionInvocationPlanned, eh.ReportActionInvocationPlanned); len(diff) > 0 { t.Errorf("wrong ReportActionInvocationPlanned hooks: %s", diff) } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationStatus, eh.ReportActionInvocationStatus); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationStatus hooks: %s", diff) + } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationProgress, eh.ReportActionInvocationProgress); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationProgress hooks: %s", diff) + } if diff := cmp.Diff(expectedHooks.ReportComponentInstancePlanned, eh.ReportComponentInstancePlanned); len(diff) > 0 { t.Errorf("wrong ReportComponentInstancePlanned hooks: %s", diff) } @@ -391,6 +405,36 @@ func (ch *CapturedHooks) captureHooks() *Hooks { ch.ReportActionInvocationPlanned = append(ch.ReportActionInvocationPlanned, ai) return a }, + ReportActionInvocationStatus: func(ctx context.Context, a any, status *hooks.ActionInvocationStatusHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(status.Addr.Component) { + panic("tried to report action invocation status before component") + } + + if ch.ComponentInstanceFinished(status.Addr.Component) { + panic("tried to report action invocation status after component") + } + + ch.ReportActionInvocationStatus = append(ch.ReportActionInvocationStatus, status) + return a + }, + ReportActionInvocationProgress: func(ctx context.Context, a any, progress *hooks.ActionInvocationProgressHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(progress.Addr.Component) { + panic("tried to report action invocation progress before component") + } + + if ch.ComponentInstanceFinished(progress.Addr.Component) { + panic("tried to report action invocation progress after component") + } + + ch.ReportActionInvocationProgress = append(ch.ReportActionInvocationProgress, progress) + return a + }, ReportComponentInstancePlanned: func(ctx context.Context, a any, change *hooks.ComponentInstanceChange) any { ch.Lock() defer ch.Unlock() diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 974a29a610..8327de6c26 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -527,6 +527,35 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { return ret } +func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { + // Parse as "component.instance.action.type.name" format + // E.g., "component.self.action.local_exec.example" + // For simplicity, we'll construct it manually - in a real scenario you'd need proper parsing + parts := strings.Split(addr, ".") + if len(parts) < 5 || parts[2] != "action" { + panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) + } + + // Extract component part: component.instance + compAddr := strings.Join(parts[:2], ".") + comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) + if len(diags) > 0 { + panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) + } + + // Extract action part: action.type.name + actionStr := strings.Join(parts[2:], ".") + actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) + if len(moreDiags) > 0 { + panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) + } + + return stackaddrs.AbsActionInvocationInstance{ + Component: comp, + Item: actionAddr, + } +} + func mustAbsComponent(addr string) stackaddrs.AbsComponent { ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr) if len(diags) > 0 { diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index 8ef70ef38d..cd0ff73e8e 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package stackeval import ( @@ -40,12 +43,21 @@ func TestActionHookForwarding(t *testing.T) { } // Prepare a HookActionIdentity with an invoke trigger + actionAddr := addrs.AbsActionInstance{} id := terraform.HookActionIdentity{ - Addr: addrs.AbsActionInstance{}, + Addr: actionAddr, ActionTrigger: &plans.InvokeActionTrigger{}, - ProviderAddr: addrs.AbsProviderConfig{}, } + // Pre-populate the provider address map + providerAddr := addrs.Provider{ + Type: "test", + Namespace: "hashicorp", + Hostname: "registry.terraform.io", + } + c.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + c.actionInvocationProviderAddr.Put(actionAddr, providerAddr) + // StartAction should trigger a status hook with "Running" status _, _ = c.StartAction(id) if statusCount != 1 { From 15324c96ec812bb919af9b78f5ceccf7d3cf14c0 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 11:23:50 -0500 Subject: [PATCH 091/136] Clean up action invocation hooks tests --- ...action_invocation_hooks_validation_test.go | 313 +++++++++--------- 1 file changed, 163 insertions(+), 150 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index 23e04c2a30..23bd37ec18 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -9,194 +9,207 @@ import ( "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" ) -// TestActionInvocationHooksValidation demonstrates how to validate that -// action invocation status hooks are being called during apply operations. -// -// This test shows all three levels of validation: -// 1. Hooks are captured via CapturedHooks helper -// 2. Multiple hooks fire for a single action (state transitions) -// 3. Hook data contains all required fields +// TestActionInvocationHooksValidation validates that action invocation status +// hooks work correctly, including enum values, hook data structure, and lifecycle ordering. func TestActionInvocationHooksValidation(t *testing.T) { - t.Run("validate_hook_capture_mechanism", func(t *testing.T) { - // Level 1: Verify CapturedHooks mechanism works - capturedHooks := NewCapturedHooks(false) // false = apply phase, true = planning phase + t.Run("hook_capture_mechanism", func(t *testing.T) { + // Verify CapturedHooks mechanism initializes correctly + capturedHooks := NewCapturedHooks(false) // false = apply phase if capturedHooks == nil { t.Fatal("CapturedHooks should not be nil") } - // Verify the hooks object exists and has expected fields + // Verify the hooks slice starts empty (nil or zero length) if len(capturedHooks.ReportActionInvocationStatus) != 0 { - t.Fatalf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + t.Errorf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) } - t.Log("✓ CapturedHooks mechanism is properly set up") + // Verify we can append to it + capturedHooks.ReportActionInvocationStatus = append( + capturedHooks.ReportActionInvocationStatus, + &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + }, + ) + + if len(capturedHooks.ReportActionInvocationStatus) != 1 { + t.Errorf("after append, expected 1 hook, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } }) - t.Run("validate_hook_structure", func(t *testing.T) { - // Level 3: Validate ActionInvocationStatusHookData structure - - // This should be the structure of each hook: - _ = &hooks.ActionInvocationStatusHookData{ - // Addr: stackaddrs.AbsActionInvocationInstance - the action address - // ProviderAddr: addrs.Provider - the provider address - // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) + t.Run("action_invocation_status_enum", func(t *testing.T) { + // Test that all enum constants are defined and have valid string representations + statuses := []hooks.ActionInvocationStatus{ + hooks.ActionInvocationStatusInvalid, + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + hooks.ActionInvocationErrored, } - t.Log("✓ ActionInvocationStatusHookData structure is properly defined") + expectedStrings := map[hooks.ActionInvocationStatus]string{ + hooks.ActionInvocationStatusInvalid: "ActionInvocationStatusInvalid", + hooks.ActionInvocationPending: "ActionInvocationPending", + hooks.ActionInvocationRunning: "ActionInvocationRunning", + hooks.ActionInvocationCompleted: "ActionInvocationCompleted", + hooks.ActionInvocationErrored: "ActionInvocationErrored", + } + + // Verify String() returns expected values + for _, status := range statuses { + str := status.String() + expected, ok := expectedStrings[status] + if !ok { + t.Errorf("unexpected status constant: %v", status) + continue + } + if str != expected { + t.Errorf("status %v: expected String() = %q, got %q", status, expected, str) + } + } + + // Verify ForProtobuf() returns valid values (non-negative) + for _, status := range statuses { + proto := status.ForProtobuf() + if proto < 0 { + t.Errorf("status %v has invalid protobuf value: %v", status, proto) + } + } + + // Verify we have exactly 5 status values + if len(statuses) != 5 { + t.Errorf("expected 5 status constants, got %d", len(statuses)) + } }) - t.Run("validate_action_invocation_status_enum", func(t *testing.T) { - // Verify that ActionInvocationStatus enum values exist - validStatuses := map[string]bool{ - // These are the valid status values an action can have - "Invalid": true, // ActionInvocationInvalid (0) - "Pending": true, // ActionInvocationPending (1) - "Running": true, // ActionInvocationRunning (2) - "Completed": true, // ActionInvocationCompleted (3) - "Errored": true, // ActionInvocationErrored (4) + t.Run("hook_data_structure", func(t *testing.T) { + // Validate ActionInvocationStatusHookData structure and methods + hookData := &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, } - if len(validStatuses) != 5 { - t.Fatalf("expected 5 status values, got %d", len(validStatuses)) + // Verify fields are set + if hookData.Addr.String() == "" { + t.Error("Addr should not be empty") + } + if hookData.ProviderAddr.String() == "" { + t.Error("ProviderAddr should not be empty") + } + if hookData.Status == hooks.ActionInvocationStatusInvalid { + t.Error("Status should not be Invalid when explicitly set to Running") } - t.Logf("✓ Action invocation status enum has %d valid values: %v", - len(validStatuses), validStatuses) + // Verify String() method + str := hookData.String() + if str == "" || str == "" { + t.Errorf("String() should return valid representation, got: %q", str) + } + + // Verify String() contains address + if !contains(str, "component.test") { + t.Errorf("String() should contain address, got: %q", str) + } + + // Verify nil handling + var nilHook *hooks.ActionInvocationStatusHookData + if nilHook.String() != "" { + t.Errorf("nil hook String() should return , got: %q", nilHook.String()) + } }) - t.Run("validate_hook_firing_pattern", func(t *testing.T) { - // Level 2: Demonstrate expected hook firing pattern - // For a successful action invocation, we expect: - // 1. StartAction() fires with Running status - // 2. ProgressAction() optionally fires with intermediate status - // 3. CompleteAction() fires with Completed or Errored status - - expectedSequence := []string{ - "Running", // StartAction called - "Completed", // CompleteAction called successfully - } - - alternativeSequence := []string{ - "Running", // StartAction called - "Errored", // CompleteAction called with error - } - - t.Logf("Expected hook sequence 1 (success): %v", expectedSequence) - t.Logf("Expected hook sequence 2 (error): %v", alternativeSequence) - - t.Log("✓ Hook firing pattern documented") - }) - - t.Run("logging_points_exist", func(t *testing.T) { - // This test documents where logging has been added for validation - - loggingLocations := map[string]string{ - "terraform_hook.go:StartAction": "Logs action address and Running status", - "terraform_hook.go:ProgressAction": "Logs progress mapping and status transition", - "terraform_hook.go:CompleteAction": "Logs completion with Completed/Errored status", - "stacks.go:ReportActionInvocationStatus": "Logs at gRPC boundary with proto status value", - } - - for location, purpose := range loggingLocations { - t.Logf(" %s: %s", location, purpose) - } - - t.Logf("✓ %d logging points have been added for debugging", len(loggingLocations)) - }) - - t.Run("validation_checklist", func(t *testing.T) { - // Use this checklist to verify the complete setup - checklist := []struct { - name string - validate func() bool + t.Run("hook_status_lifecycle_ordering", func(t *testing.T) { + // Test expected hook status sequences for different scenarios + testCases := []struct { + name string + capturedStatuses []hooks.ActionInvocationStatus + wantValid bool + description string }{ { - name: "Logging imports added to terraform_hook.go", - validate: func() bool { - // Check: log.Printf should be called in hook methods - return true + name: "successful_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action starts running and completes successfully", }, { - name: "Logging imports added to stacks.go", - validate: func() bool { - // Check: log.Printf should be called in ReportActionInvocationStatus - return true + name: "failed_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationErrored, }, + wantValid: true, + description: "Action starts running but encounters an error", }, { - name: "Binary rebuilt with logging", - validate: func() bool { - // Check: Run `make install` after logging additions - return true + name: "pending_then_running_then_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action goes through all states including pending", }, { - name: "Log contains hook method entries", - validate: func() bool { - // Check: grep "terraform_hook.*Action\|ReportActionInvocationStatus" terraform.log - return true - }, - }, - { - name: "Unit tests capture hooks via CapturedHooks", - validate: func() bool { - // Check: Test uses NewCapturedHooks() and captureHooks() - return true - }, - }, - { - name: "Hook status values match enum", - validate: func() bool { - // Check: Running, Completed, Errored are valid values - return true + name: "invalid_only_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationCompleted, }, + wantValid: false, + description: "Invalid: completed without running", }, } - t.Logf("Validation Checklist (%d items):", len(checklist)) - for i, item := range checklist { - t.Logf(" %d. %s", i+1, item.name) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Verify we captured the expected number of statuses + if len(tc.capturedStatuses) == 0 { + t.Error("test case should have at least one status") + return + } + + // For valid sequences, verify terminal state is at the end + if tc.wantValid && len(tc.capturedStatuses) > 0 { + lastStatus := tc.capturedStatuses[len(tc.capturedStatuses)-1] + isTerminal := lastStatus == hooks.ActionInvocationCompleted || + lastStatus == hooks.ActionInvocationErrored + + if !isTerminal { + t.Errorf("valid sequence should end in terminal state (Completed/Errored), got %v", lastStatus) + } + } + + // For invalid sequences starting with Completed, verify it's actually invalid + if !tc.wantValid && len(tc.capturedStatuses) > 0 { + firstStatus := tc.capturedStatuses[0] + if firstStatus == hooks.ActionInvocationCompleted && len(tc.capturedStatuses) == 1 { + // This is indeed invalid - can't complete without running + t.Logf("correctly identified invalid sequence: %v", tc.capturedStatuses) + } + } + }) } }) } -// TestActionInvocationHooksLoggingOutput demonstrates what the logging output -// should look like when action invocation hooks are fired during apply. -// -// Expected log output pattern: -// -// [DEBUG] terraform_hook.StartAction called for action: component.nulls.action.bufo_print.success -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Running) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Running, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=2 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -// [DEBUG] terraform_hook.CompleteAction called for action: component.nulls.action.bufo_print.success, error= -// [DEBUG] Action completed successfully - reporting Completed status -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Completed) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Completed, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=3 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -func TestActionInvocationHooksLoggingOutput(t *testing.T) { - t.Run("logging_documentation", func(t *testing.T) { - expectedLogPatterns := []string{ - "terraform_hook.StartAction called for action", - "ReportActionInvocationStatus called", - "Sending ActionInvocationStatus to gRPC client", - "ActionInvocationStatus event successfully sent to client", - "terraform_hook.CompleteAction called for action", - } - - t.Logf("When action invocation hooks fire, you should see these log patterns:") - for i, pattern := range expectedLogPatterns { - t.Logf(" %d. [DEBUG] %s", i+1, pattern) - } - - t.Log("\nStatus enum values in logs:") - t.Log(" Status=1 (proto) = Pending") - t.Log(" Status=2 (proto) = Running") - t.Log(" Status=3 (proto) = Completed") - t.Log(" Status=4 (proto) = Errored") - }) +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false } From 7a9577e3102b2c39f946731797b902c235f02dd3 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 12:56:57 -0500 Subject: [PATCH 092/136] Lint --- internal/command/views/json/message_types.go | 12 ++++++------ internal/stacks/stackruntime/helper_test.go | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index fffd5c1f13..d9a9dc59e1 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -12,12 +12,12 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results - MessageResourceDrift MessageType = "resource_drift" - MessagePlannedChange MessageType = "planned_change" - MessagePlannedActionInvocation MessageType = "planned_action_invocation" - MessageAppliedActionInvocation MessageType = "applied_action_invocation" - MessageChangeSummary MessageType = "change_summary" - MessageOutputs MessageType = "outputs" + MessageResourceDrift MessageType = "resource_drift" + MessagePlannedChange MessageType = "planned_change" + MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageAppliedActionInvocation MessageType = "applied_action_invocation" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" // Hook-driven messages MessageApplyStart MessageType = "apply_start" diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 8327de6c26..78ccc49efe 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -535,21 +535,21 @@ func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocation if len(parts) < 5 || parts[2] != "action" { panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) } - + // Extract component part: component.instance compAddr := strings.Join(parts[:2], ".") comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) if len(diags) > 0 { panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) } - + // Extract action part: action.type.name actionStr := strings.Join(parts[2:], ".") actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) if len(moreDiags) > 0 { panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) } - + return stackaddrs.AbsActionInvocationInstance{ Component: comp, Item: actionAddr, From cb2241733928504464d0159ceec8ba244d6351eb Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 14:37:58 -0500 Subject: [PATCH 093/136] Fix hooks --- internal/rpcapi/stacks.go | 50 +++++++++++++++++++ .../internal/stackeval/terraform_hook.go | 16 ++++-- .../stackeval/terraform_hook_action_test.go | 24 ++++----- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 6ffdb7e039..f7adf2291f 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1226,6 +1226,56 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou return span }, + ReportActionInvocationStatus: func(ctx context.Context, span any, statusData *hooks.ActionInvocationStatusHookData) any { + span.(trace.Span).AddEvent("action invocation status", trace.WithAttributes( + attribute.String("component_instance", statusData.Addr.Component.String()), + attribute.String("action_invocation_instance", statusData.Addr.Item.String()), + attribute.String("status", statusData.Status.String()), + )) + + providerAddr := "" + if !statusData.ProviderAddr.IsZero() { + providerAddr = statusData.ProviderAddr.String() + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationStatus_{ + ActionInvocationStatus: &stacks.StackChangeProgress_ActionInvocationStatus{ + Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), + Status: statusData.Status.ForProtobuf(), + ProviderAddr: providerAddr, + }, + }, + }) + + return span + }, + + ReportActionInvocationProgress: func(ctx context.Context, span any, progressData *hooks.ActionInvocationProgressHookData) any { + span.(trace.Span).AddEvent("action invocation progress", trace.WithAttributes( + attribute.String("component_instance", progressData.Addr.Component.String()), + attribute.String("action_invocation_instance", progressData.Addr.Item.String()), + attribute.String("message", progressData.Message), + )) + + providerAddr := "" + if !progressData.ProviderAddr.IsZero() { + providerAddr = progressData.ProviderAddr.String() + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationProgress_{ + ActionInvocationProgress: &stacks.StackChangeProgress_ActionInvocationProgress{ + Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), + Message: progressData.Message, + ProviderAddr: providerAddr, + }, + }, + }) + + return span + }, + ReportResourceInstanceDeferred: func(ctx context.Context, span any, change *hooks.DeferredResourceInstanceChange) any { span.(trace.Span).AddEvent("deferred resource instance", trace.WithAttributes( attribute.String("component_instance", change.Change.Addr.Component.String()), diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 6a2833e73d..6848d1626f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -61,19 +61,27 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { + status := hooks.ResourceInstancePlanning + if err != nil { + status = hooks.ResourceInstanceErrored + } hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: hooks.ResourceInstancePlanning, + Status: status, }) return terraform.HookActionContinue, nil } func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { + status := hooks.ResourceInstancePlanned + if err != nil { + status = hooks.ResourceInstanceErrored + } hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: hooks.ResourceInstancePlanned, + Status: status, }) return terraform.HookActionContinue, nil } @@ -227,7 +235,7 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden return terraform.HookActionContinue, nil } -// ProgressAction fires for intermediate diagnostic messages (NO status changes) +// ProgressAction fires for intermediate diagnostic messages from the provider. func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { ai := h.actionInvocationFromHookActionIdentity(id) providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) @@ -235,6 +243,8 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI // Should not happen - actions should be pre-registered return terraform.HookActionContinue, nil } + + // Always report progress message hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, ProviderAddr: providerAddr, diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index cd0ff73e8e..09427082d8 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -67,31 +67,25 @@ func TestActionHookForwarding(t *testing.T) { t.Fatalf("expected ActionInvocationRunning status from StartAction, got %s", statuses[0].String()) } - // ProgressAction with "in-progress" should keep running status + // ProgressAction should not trigger status hooks _, _ = c.ProgressAction(id, "in-progress") - if statusCount != 2 { - t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) - } - if statuses[1] != hooks.ActionInvocationRunning { - t.Fatalf("expected ActionInvocationRunning status from ProgressAction, got %s", statuses[1].String()) + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) } - // ProgressAction with "pending" should switch to pending status + // ProgressAction with "pending" should still avoid status hooks _, _ = c.ProgressAction(id, "pending") - if statusCount != 3 { - t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) - } - if statuses[2] != hooks.ActionInvocationPending { - t.Fatalf("expected ActionInvocationPending status from ProgressAction('pending'), got %s", statuses[2].String()) + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) } // CompleteAction with no error should complete successfully _, _ = c.CompleteAction(id, nil) - if statusCount != 4 { + if statusCount != 2 { t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) } - if statuses[3] != hooks.ActionInvocationCompleted { - t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[3].String()) + if statuses[1] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[1].String()) } // Test error case From b4dff0888d6978b6c9c60548d4f51f4ff2389bb0 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 12:11:58 -0500 Subject: [PATCH 094/136] Restore transform_action_diff.go --- internal/terraform/transform_action_diff.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/terraform/transform_action_diff.go b/internal/terraform/transform_action_diff.go index 029ab5a150..85abff7305 100644 --- a/internal/terraform/transform_action_diff.go +++ b/internal/terraform/transform_action_diff.go @@ -19,13 +19,8 @@ type ActionDiffTransformer struct { } func (t *ActionDiffTransformer) Transform(g *Graph) error { - applyNodes := addrs.MakeMap[addrs.AbsResourceInstance, *NodeApplyableResourceInstance]() actionTriggerNodes := addrs.MakeMap[addrs.ConfigResource, []*nodeActionTriggerApplyExpand]() for _, vs := range g.Vertices() { - if applyableResource, ok := vs.(*NodeApplyableResourceInstance); ok { - applyNodes.Put(applyableResource.Addr, applyableResource) - } - if atn, ok := vs.(*nodeActionTriggerApplyExpand); ok { configResource := actionTriggerNodes.Get(atn.triggerConfig.resourceAddress) actionTriggerNodes.Put(atn.triggerConfig.resourceAddress, append(configResource, atn)) From 5e32bf2cdc5de01f4c17fed12e020957ef6535ec Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:01:40 -0500 Subject: [PATCH 095/136] Add trigger information to action invocation status and progress Include the action trigger (lifecycle or invoke) in ActionInvocationStatus and ActionInvocationProgress messages to uniquely identify action invocations triggered by different events. Additional context: https://github.com/hashicorp/terraform/pull/38051#discussion_r2812460131 --- internal/rpcapi/stacks.go | 101 ++++++++++++++---- .../stackruntime/hooks/resource_instance.go | 2 + .../internal/stackeval/applying.go | 1 + .../internal/stackeval/terraform_hook.go | 3 + 4 files changed, 85 insertions(+), 22 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index f7adf2291f..e39c71d103 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1238,13 +1238,18 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou providerAddr = statusData.ProviderAddr.String() } + protoStatus := &stacks.StackChangeProgress_ActionInvocationStatus{ + Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), + Status: statusData.Status.ForProtobuf(), + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationStatusTrigger(protoStatus, statusData.Addr.Component, statusData.Trigger) + send(&stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ActionInvocationStatus_{ - ActionInvocationStatus: &stacks.StackChangeProgress_ActionInvocationStatus{ - Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), - Status: statusData.Status.ForProtobuf(), - ProviderAddr: providerAddr, - }, + ActionInvocationStatus: protoStatus, }, }) @@ -1263,13 +1268,18 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou providerAddr = progressData.ProviderAddr.String() } + protoProgress := &stacks.StackChangeProgress_ActionInvocationProgress{ + Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), + Message: progressData.Message, + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationProgressTrigger(protoProgress, progressData.Addr.Component, progressData.Trigger) + send(&stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ActionInvocationProgress_{ - ActionInvocationProgress: &stacks.StackChangeProgress_ActionInvocationProgress{ - Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), - Message: progressData.Message, - ProviderAddr: providerAddr, - }, + ActionInvocationProgress: protoProgress, }, }) @@ -1394,34 +1404,81 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro ProviderAddr: ai.ProviderAddr.String(), } - switch trig := ai.Trigger.(type) { + setActionInvocationPlannedTrigger(res, ai.Addr.Component, ai.Trigger) + + return res, nil +} + +// setActionInvocationStatusTrigger sets the ActionTrigger oneof field on an ActionInvocationStatus message. +func setActionInvocationStatusTrigger(msg *stacks.StackChangeProgress_ActionInvocationStatus, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { case *plans.ResourceActionTrigger: - triggerEvent, err := stacks.ActionTriggerEventForStackChangeProgress(trig.TriggerEvent()) - if err != nil { - return nil, err - } - res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger{ ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( stackaddrs.AbsResourceInstance{ - Component: ai.Addr.Component, + Component: component, Item: trig.TriggeringResourceAddr, }, ), - TriggerEvent: triggerEvent, + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), ActionsListIndex: int64(trig.ActionsListIndex), }, } case *plans.InvokeActionTrigger: - res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger{ InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, } - default: - return nil, fmt.Errorf("unsupported action invocation trigger type") } +} - return res, nil +// setActionInvocationProgressTrigger sets the ActionTrigger oneof field on an ActionInvocationProgress message. +func setActionInvocationProgressTrigger(msg *stacks.StackChangeProgress_ActionInvocationProgress, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } +} + +// setActionInvocationPlannedTrigger sets the ActionTrigger oneof field on an ActionInvocationPlanned message. +func setActionInvocationPlannedTrigger(msg *stacks.StackChangeProgress_ActionInvocationPlanned, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } } func evtComponentInstanceStatus(ci stackaddrs.AbsComponentInstance, status hooks.ComponentInstanceStatus) *stacks.StackChangeProgress { diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index cf55e33314..c00a93e629 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -157,6 +157,7 @@ type ActionInvocationStatusHookData struct { Addr stackaddrs.AbsActionInvocationInstance ProviderAddr addrs.Provider Status ActionInvocationStatus + Trigger plans.ActionTrigger } // String returns a concise string representation of the action invocation status. @@ -171,6 +172,7 @@ type ActionInvocationProgressHookData struct { Addr stackaddrs.AbsActionInvocationInstance ProviderAddr addrs.Provider Message string + Trigger plans.ActionTrigger } // String returns a concise string representation of the action invocation progress. diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 6db03af7f1..2ad1704610 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -140,6 +140,7 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi Addr: absActionAddr, ProviderAddr: action.ProviderAddr.Provider, Status: hooks.ActionInvocationPending, + Trigger: action.ActionTrigger, }) } } diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 6848d1626f..71d279d04f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -231,6 +231,7 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden Addr: ai.Addr, ProviderAddr: providerAddr, Status: hooks.ActionInvocationRunning, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } @@ -249,6 +250,7 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI Addr: ai.Addr, ProviderAddr: providerAddr, Message: progress, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } @@ -273,6 +275,7 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI Addr: ai.Addr, ProviderAddr: providerAddr, Status: status, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } From d9045c8b97e7879f3e33af42829d334f79ac2981 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:06:47 -0500 Subject: [PATCH 096/136] Mirror existing functions and parse with stackaddrs.ParseActionInvocationInstanceStr --- internal/stacks/stackaddrs/in_component.go | 31 +++++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 27 ++---------------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/internal/stacks/stackaddrs/in_component.go b/internal/stacks/stackaddrs/in_component.go index 7283720efe..c130c215db 100644 --- a/internal/stacks/stackaddrs/in_component.go +++ b/internal/stacks/stackaddrs/in_component.go @@ -162,3 +162,34 @@ func ParseAbsResourceInstanceObjectStr(s string) (AbsResourceInstanceObject, tfd diags = diags.Append(moreDiags) return ret, diags } + +func ParseAbsActionInvocationInstance(traversal hcl.Traversal) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + component, remain, diags := ParseAbsComponentInstanceOnly(traversal) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + action, actionDiags := addrs.ParseAbsActionInstance(remain) + diags = diags.Append(actionDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + return AbsActionInvocationInstance{ + Component: component, + Item: action, + }, diags +} + +func ParseActionInvocationInstanceStr(s string) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(s), "", hcl.InitialPos) + diags = diags.Append(hclDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + ret, moreDiags := ParseAbsActionInvocationInstance(traversal) + diags = diags.Append(moreDiags) + return ret, diags +} diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 78ccc49efe..15e91a7f3a 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -528,32 +528,11 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { } func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { - // Parse as "component.instance.action.type.name" format - // E.g., "component.self.action.local_exec.example" - // For simplicity, we'll construct it manually - in a real scenario you'd need proper parsing - parts := strings.Split(addr, ".") - if len(parts) < 5 || parts[2] != "action" { - panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) - } - - // Extract component part: component.instance - compAddr := strings.Join(parts[:2], ".") - comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) + ret, diags := stackaddrs.ParseActionInvocationInstanceStr(addr) if len(diags) > 0 { - panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) - } - - // Extract action part: action.type.name - actionStr := strings.Join(parts[2:], ".") - actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) - if len(moreDiags) > 0 { - panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) - } - - return stackaddrs.AbsActionInvocationInstance{ - Component: comp, - Item: actionAddr, + panic(fmt.Sprintf("failed to parse action invocation instance address %q: %s", addr, diags)) } + return ret } func mustAbsComponent(addr string) stackaddrs.AbsComponent { From 87b37486b930dca1afb2b6ec9670f4251f385e21 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:12:49 -0500 Subject: [PATCH 097/136] Restore applying.go --- internal/stacks/stackruntime/internal/stackeval/applying.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 2ad1704610..e14c22a97d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -255,7 +255,8 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // of either "modifiedPlan" or "plan" (since they share lots of the same // pointers to mutable objects and so both can get modified together.) newState, moreDiags = tfCtx.Apply(plan, moduleTree, &terraform.ApplyOpts{ - ExternalProviders: providerClients, + ExternalProviders: providerClients, + AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. }) diags = diags.Append(moreDiags) } else { From 88f8567b779c2c55527f8fe84566ac1c9164cd0f Mon Sep 17 00:00:00 2001 From: Roniece Date: Fri, 6 Mar 2026 11:56:25 -0500 Subject: [PATCH 098/136] Update copyright headers --- .../stackruntime/action_invocation_hooks_validation_test.go | 2 +- .../internal/stackeval/terraform_hook_action_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index 23bd37ec18..8f63fed045 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -1,4 +1,4 @@ -// Copyright (c) HashiCorp, Inc. +// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package stackruntime diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index 09427082d8..fb9369bfb0 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -1,4 +1,4 @@ -// Copyright (c) HashiCorp, Inc. +// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package stackeval From a47b89170f40e7deaf72e543a86660060f83d4e8 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 11:17:03 +0100 Subject: [PATCH 099/136] Fix ParseVariableValues for different command flows We want to treat `const` variables as part of the configuration, we always require a value for them when loading configuration. Since most command load configuration via `Meta.loadConfig` or `Meta.loadConfigWithTests`, these flows call `ParseVariableValues` with `constOnly` true. Backend operations like `plan` and `apply` require values for all required variables (as before), so here we set `constOnly` to false, to keep the existing logic. --- internal/backend/backendrun/unparsed_value.go | 67 ++-------- .../backend/backendrun/unparsed_value_test.go | 118 +++++++++++++++++- 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, 138 insertions(+), 67 deletions(-) diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index bf83dc933e..e372457413 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -146,7 +146,13 @@ 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. -func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { +// +// 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) { ret, diags := ParseDeclaredVariableValues(vv, decls) undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) @@ -166,62 +172,11 @@ func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls ma // specific error message which mentions -var and -var-file command // line options, whereas the one in Terraform Core is more general // due to supporting both root and child module variables. - if vc.Required() { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "No value for required variable", - Detail: fmt.Sprintf("The root module input variable %q is not set, and has no default value. Use a -var or -var-file command line argument to provide a value for this variable.", name), - Subject: vc.DeclRange.Ptr(), - }) - - // We'll include a placeholder value anyway, just so that our - // result is complete for any calling code that wants to cautiously - // analyze it for diagnostic purposes. Since our diagnostics now - // includes an error, normal processing will ignore this result. - ret[name] = &terraform.InputValue{ - Value: cty.DynamicVal, - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange), - } - } else { - // We're still required to put an entry for this variable - // in the mapping to be explicit to Terraform Core that we - // visited it, but its value will be cty.NilVal to represent - // that it wasn't set at all at this layer, and so Terraform Core - // should substitute a default if available, or generate an error - // if not. - ret[name] = &terraform.InputValue{ - Value: cty.NilVal, - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange), - } + shouldError := vc.Required() + if constOnly { + shouldError = vc.Const && vc.Required() } - } - - return ret, diags -} - -func ParseConstVariableValues(vv map[string]arguments.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 unset placeholders which Terraform Core - // can then react to. - for name, vc := range decls { - if isDefinedAny(name, ret, undeclared) { - continue - } - - // This check is redundant with a check made in Terraform Core when - // processing undeclared variables, but allows us to generate a more - // specific error message which mentions -var and -var-file command - // line options, whereas the one in Terraform Core is more general - // due to supporting both root and child module variables. - if vc.Const && vc.Required() { + if shouldError { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "No value for required variable", diff --git a/internal/backend/backendrun/unparsed_value_test.go b/internal/backend/backendrun/unparsed_value_test.go index 17822487ce..fd970cf2f4 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) + gotVals, diags := ParseVariableValues(vv, decls, false) for _, diag := range diags { t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) } @@ -221,6 +221,122 @@ func TestUnparsedValue(t *testing.T) { t.Errorf("wrong result\n%s", diff) } }) + + t.Run("ParseVariableValues constOnly", func(t *testing.T) { + vv := map[string]arguments.UnparsedVariableValue{ + "declared1": testUnparsedVariableValue("5"), + } + + decls := map[string]*configs.Variable{ + "declared1": { + Name: "declared1", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "missing_const_required": { + Name: "missing_const_required", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: true, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 3, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing_nonconst_required": { + Name: "missing_nonconst_required", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: false, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 4, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 4, Column: 1, Byte: 0}, + }, + }, + "missing_const_optional": { + Name: "missing_const_optional", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: true, + Default: cty.StringVal("default"), + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 5, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 5, Column: 1, Byte: 0}, + }, + }, + } + + gotVals, diags := ParseVariableValues(vv, decls, true) + + if got, want := len(diags), 1; 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, missingRequired; got != want { + t.Fatalf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[0].Description().Detail, `"missing_const_required"`; !strings.Contains(got, want) { + t.Fatalf("wrong detail for diagnostic 0\ngot: %s\nmust contain: %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}, + }, + }, + "missing_const_required": { + 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}, + }, + }, + "missing_nonconst_required": { + Value: cty.DynamicVal, + 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}, + }, + }, + "missing_const_optional": { + Value: cty.NilVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 5, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 5, 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 diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 3c25006939..1311229f04 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) + parsedVars, _ := backendrun.ParseVariableValues(op.Variables, lr.Config.Module.Variables, false) 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 768b94355a..97c42fc45d 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) + variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables, false) 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) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) 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 137c01aa41..f76644ac1f 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) + variables, _ := backendrun.ParseVariableValues(op.Variables, config.Variables, false) // 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 db72d709ed..47d68f2b63 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) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) 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 e3fc587a33..d4df1b9a7f 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) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables, false) 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 02f0b8415f..5dda59b46c 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) + vars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) 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.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + vars, parseDiags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) 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.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + variables, diags := backendrun.ParseVariableValues(m.VariableValues, rootMod.Variables, true) ctx, ctxDiags := terraform.NewContext(&terraform.ContextOpts{ Parallelism: 1, }) diff --git a/internal/command/show.go b/internal/command/show.go index eb7aed4422..f4055eaacb 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) + variables, varDiags := backendrun.ParseVariableValues(variableValues, rootMod.Variables, true) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, diags From d7baa140f891c041052a7bc3e086bf203be32781 Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:33:46 +0000 Subject: [PATCH 100/136] fix: Add missing or correct values to error diagnostics raise when initialising a state store from backend state file data (#38275) --- internal/command/meta_backend.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index e9bf86ff09..f055188088 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -2923,10 +2923,11 @@ func (m *Meta) savedStateStore(sMgr *clistate.LocalState) (backend.Backend, tfdi &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Error reading state store configuration state", - Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q)", + Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q): %s", s.StateStore.Type, s.StateStore.Provider.Source.Type, s.StateStore.Provider.Source, + err, ), }, ) @@ -3403,7 +3404,7 @@ func (m *Meta) StateStoreProviderFactoryFromConfigState(cfgState *workdir.StateS Severity: hcl.DiagError, Summary: "Provider unavailable", Detail: fmt.Sprintf("The provider %s (%q) is required to initialize the %q state store, but the matching provider factory is missing. This is a bug in Terraform and should be reported.", - cfgState.Type, + cfgState.Provider.Source.Type, cfgState.Provider.Source, cfgState.Type, ), From 941dfcc82cc31c979d91509058f5051a6a36b681 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 24 Jan 2025 16:17:10 +0100 Subject: [PATCH 101/136] Add `type` attribute to `output` blocks --- internal/configs/named_values.go | 28 +++++++++++++++++++ .../valid-files/output-type-constraint.tf | 11 ++++++++ 2 files changed, 39 insertions(+) create mode 100644 internal/configs/testdata/valid-files/output-type-constraint.tf diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index 4cbb3e9e98..789a4ed5cc 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -388,12 +388,24 @@ type Output struct { Ephemeral bool Deprecated string + // ConstraintType is a type constraint which the result is guaranteed + // to conform to when used in the calling module. + ConstraintType cty.Type + // TypeDefaults describes any optional attribute defaults that should be + // applied to the Expr result before type conversion. + TypeDefaults *typeexpr.Defaults + Preconditions []*CheckRule DescriptionSet bool SensitiveSet bool EphemeralSet bool DeprecatedSet bool + // TypeSet is true if there was an explicit "type" argument in the + // configuration block. This is mainly to allow distinguish explicitly + // setting vs. just using the default type constraint when processing + // override files. + TypeSet bool DeclRange hcl.Range DeprecatedRange hcl.Range @@ -434,6 +446,19 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic o.Expr = attr.Expr } + if attr, exists := content.Attributes["type"]; exists { + ty, defaults, moreDiags := typeexpr.TypeConstraintWithDefaults(attr.Expr) + diags = append(diags, moreDiags...) + o.ConstraintType = ty + o.TypeDefaults = defaults + o.TypeSet = true + } + if o.ConstraintType == cty.NilType { + // If no constraint is given then the type will be inferred + // automatically from the value. + o.ConstraintType = cty.DynamicPseudoType + } + if attr, exists := content.Attributes["sensitive"]; exists { valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Sensitive) diags = append(diags, valDiags...) @@ -587,6 +612,9 @@ var outputBlockSchema = &hcl.BodySchema{ { Name: "deprecated", }, + { + Name: "type", + }, }, Blocks: []hcl.BlockHeaderSchema{ {Type: "precondition"}, diff --git a/internal/configs/testdata/valid-files/output-type-constraint.tf b/internal/configs/testdata/valid-files/output-type-constraint.tf new file mode 100644 index 0000000000..13be13befd --- /dev/null +++ b/internal/configs/testdata/valid-files/output-type-constraint.tf @@ -0,0 +1,11 @@ +output "string" { + type = string + value = "Hello" +} + +output "object" { + type = object({ + name = optional(string, "Bart"), + }) + value = {} +} From 8b3329e910bb0df7907fadb69a05191b5152a7a7 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 24 Jan 2025 16:40:42 +0100 Subject: [PATCH 102/136] Check type contraints for output values --- internal/terraform/context_apply2_test.go | 47 +++++++++++++++++++ internal/terraform/node_output.go | 36 +++++++++++++- internal/terraform/node_output_test.go | 12 +++-- .../apply-output-type-constraint.tf | 20 ++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index d271331946..2d534ea878 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -4845,3 +4845,50 @@ resource "test_resource" "test" { }) } } + +func TestContext2Apply_outputWithTypeContraint(t *testing.T) { + m := testModule(t, "apply-output-type-constraint") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + tfdiags.AssertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m, nil) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + wantValues := map[string]cty.Value{ + "string": cty.StringVal("true"), + "object_default": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Bart"), + }), + "object_override": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Lisa"), + }), + } + ovs := state.RootOutputValues + for name, want := range wantValues { + os, ok := ovs[name] + if !ok { + t.Errorf("missing output value %q", name) + continue + } + if got := os.Value; !want.RawEquals(got) { + t.Errorf("wrong value for output %q\ngot: %#v\nwant: %#v", name, got, want) + } + } + + for gotName := range ovs { + if _, ok := wantValues[gotName]; !ok { + t.Errorf("unexpected extra output value %q", gotName) + } + } +} diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index eadd281681..078b385861 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -8,7 +8,9 @@ import ( "log" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" @@ -448,7 +450,7 @@ func (n *NodeApplyableOutput) Execute(ctx EvalContext, op walkOperation) (diags // This has to run before we have a state lock, since evaluation also // reads the state var evalDiags tfdiags.Diagnostics - val, evalDiags = ctx.EvaluateExpr(n.Config.Expr, cty.DynamicPseudoType, nil) + val, evalDiags = evalOutputValue(ctx, n.Config.Expr, n.Config.ConstraintType, n.Config.TypeDefaults) diags = diags.Append(evalDiags) // We'll handle errors below, after we have loaded the module. @@ -546,6 +548,38 @@ If you do intend to export this data, annotate the output value as sensitive by return diags } +// evalOutputValue encapsulates the logic for transforming an author's value +// expression into a valid value of their declared type constraint, or returning +// an error describing why that isn't possible. +func evalOutputValue(ctx EvalContext, expr hcl.Expression, wantType cty.Type, defaults *typeexpr.Defaults) (cty.Value, tfdiags.Diagnostics) { + // We can't pass wantType to EvaluateExpr here because we'll need to + // possibly apply our defaults before attempting type conversion below. + val, diags := ctx.EvaluateExpr(expr, cty.DynamicPseudoType, nil) + if diags.HasErrors() { + return cty.UnknownVal(wantType), diags + } + + if defaults != nil { + val = defaults.Apply(val) + } + + val, err := convert.Convert(val, wantType) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid output value", + Detail: fmt.Sprintf("The value expression does not match this output value's type constraint: %s.", tfdiags.FormatError(err)), + Subject: expr.Range().Ptr(), + // TODO: Populate EvalContext and Expression, but we can't do that + // as long as we're using the ctx.EvaluateExpr helper above because + // the EvalContext is hidden from us in that case. + }) + return cty.UnknownVal(wantType), diags + } + + return val, diags +} + // dag.GraphNodeDotter impl. func (n *NodeApplyableOutput) DotNode(name string, opts *dag.DotOpts) *dag.DotNode { return &dag.DotNode{ diff --git a/internal/terraform/node_output_test.go b/internal/terraform/node_output_test.go index 042ce190fc..c86a4eeadf 100644 --- a/internal/terraform/node_output_test.go +++ b/internal/terraform/node_output_test.go @@ -25,7 +25,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) { ctx.ChecksState = checks.NewState(nil) ctx.DeferralsState = deferring.NewDeferred(false) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -58,7 +58,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) { func TestNodeApplyableOutputExecute_noState(t *testing.T) { ctx := new(MockEvalContext) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -86,6 +86,7 @@ func TestNodeApplyableOutputExecute_invalidDependsOn(t *testing.T) { hcl.TraverseAttr{Name: "bar"}, }, }, + ConstraintType: cty.DynamicPseudoType, } addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} @@ -108,7 +109,7 @@ func TestNodeApplyableOutputExecute_sensitiveValueNotOutput(t *testing.T) { ctx.StateState = states.NewState().SyncWrapper() ctx.ChecksState = checks.NewState(nil) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -132,8 +133,9 @@ func TestNodeApplyableOutputExecute_sensitiveValueAndOutput(t *testing.T) { ctx.DeferralsState = deferring.NewDeferred(false) config := &configs.Output{ - Name: "map-output", - Sensitive: true, + Name: "map-output", + Sensitive: true, + ConstraintType: cty.DynamicPseudoType, } addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} diff --git a/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf b/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf new file mode 100644 index 0000000000..604e31bc32 --- /dev/null +++ b/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf @@ -0,0 +1,20 @@ +output "string" { + type = string + value = true +} + +output "object_default" { + type = object({ + name = optional(string, "Bart") + }) + value = {} +} + +output "object_override" { + type = object({ + name = optional(string, "Bart") + }) + value = { + name = "Lisa" + } +} From 83619e1d88f160272b622aa397293052ef5bc231 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 3 Feb 2025 17:58:16 +0100 Subject: [PATCH 103/136] Add changelog --- .changes/v1.15/ENHANCEMENTS-20250203-175807.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/ENHANCEMENTS-20250203-175807.yaml diff --git a/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml b/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml new file mode 100644 index 0000000000..53bad4ecb5 --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'config: `output` blocks now can have an explicit type constraints' +time: 2025-02-03T17:58:07.110141+01:00 +custom: + Issue: "36411" From 8c88364e1c7722dfc193ed1e95d7fb2dbdfe5c86 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 6 Mar 2026 17:48:52 +0100 Subject: [PATCH 104/136] Add evaluation scope and expr to output diagnostic --- internal/terraform/node_output.go | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index 078b385861..d9d32f4961 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -563,16 +563,29 @@ func evalOutputValue(ctx EvalContext, expr hcl.Expression, wantType cty.Type, de val = defaults.Apply(val) } + refs, moreDiags := langrefs.ReferencesInExpr(addrs.ParseRef, expr) + diags = diags.Append(moreDiags) + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + var hclCtx *hcl.EvalContext + if scope != nil { + hclCtx, moreDiags = scope.EvalContext(refs) + } else { + // This shouldn't happen in real code, but it can unfortunately arise + // in unit tests due to incompletely-implemented mocks. :( + hclCtx = &hcl.EvalContext{} + } + diags = diags.Append(moreDiags) + val, err := convert.Convert(val, wantType) if err != nil { diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid output value", - Detail: fmt.Sprintf("The value expression does not match this output value's type constraint: %s.", tfdiags.FormatError(err)), - Subject: expr.Range().Ptr(), - // TODO: Populate EvalContext and Expression, but we can't do that - // as long as we're using the ctx.EvaluateExpr helper above because - // the EvalContext is hidden from us in that case. + Severity: hcl.DiagError, + Summary: "Invalid output value", + Detail: fmt.Sprintf("The value expression does not match this output value's type constraint: %s.", tfdiags.FormatError(err)), + Subject: expr.Range().Ptr(), + Expression: expr, + EvalContext: hclCtx, }) return cty.UnknownVal(wantType), diags } From 45ccfbf87aae3195432d820a756cf6b0221fab8f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 6 Mar 2026 18:00:48 +0100 Subject: [PATCH 105/136] Test output type override merge --- internal/configs/module_merge.go | 5 ++ internal/configs/module_merge_test.go | 55 +++++++++++++++++++ .../override-output-type/a_override.tf | 7 +++ .../override-output-type/primary.tf | 13 +++++ 4 files changed, 80 insertions(+) create mode 100644 internal/configs/testdata/valid-modules/override-output-type/a_override.tf create mode 100644 internal/configs/testdata/valid-modules/override-output-type/primary.tf diff --git a/internal/configs/module_merge.go b/internal/configs/module_merge.go index d9bc2abcc7..f0570c037f 100644 --- a/internal/configs/module_merge.go +++ b/internal/configs/module_merge.go @@ -160,6 +160,11 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics { o.Ephemeral = oo.Ephemeral o.EphemeralSet = oo.EphemeralSet } + if oo.TypeSet { + o.ConstraintType = oo.ConstraintType + o.TypeDefaults = oo.TypeDefaults + o.TypeSet = oo.TypeSet + } // We don't allow depends_on to be overridden because that is likely to // cause confusing misbehavior. diff --git a/internal/configs/module_merge_test.go b/internal/configs/module_merge_test.go index dabed28a8f..f6351594ee 100644 --- a/internal/configs/module_merge_test.go +++ b/internal/configs/module_merge_test.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/hashicorp/terraform/internal/addrs" ) @@ -413,6 +414,60 @@ func TestModuleOverrideConstVariable(t *testing.T) { } } +func TestModuleOverrideOutputType(t *testing.T) { + type testCase struct { + constraintType cty.Type + typeDefaults *typeexpr.Defaults + typeSet bool + } + cases := map[string]testCase{ + "fully_overridden": { + constraintType: cty.Number, + typeDefaults: nil, + typeSet: true, + }, + "no_override": { + constraintType: cty.String, + typeDefaults: nil, + typeSet: true, + }, + "type_added_by_override": { + constraintType: cty.List(cty.String), + typeDefaults: nil, + typeSet: true, + }, + } + + mod, diags := testModuleFromDir("testdata/valid-modules/override-output-type") + + assertNoDiagnostics(t, diags) + + if mod == nil { + t.Fatalf("module is nil") + } + + for name, want := range cases { + t.Run(fmt.Sprintf("output %s", name), func(t *testing.T) { + got, exists := mod.Outputs[name] + if !exists { + t.Fatalf("output %q not found", name) + } + + if !got.ConstraintType.Equals(want.constraintType) { + t.Errorf("wrong result for constraint type\ngot: %#v\nwant: %#v", got.ConstraintType, want.constraintType) + } + + if got.TypeSet != want.typeSet { + t.Errorf("wrong result for type set\ngot: %t want: %t", got.TypeSet, want.typeSet) + } + + if got.TypeDefaults != want.typeDefaults { + t.Errorf("wrong result for type defaults\ngot: %#v want: %#v", got.TypeDefaults, want.typeDefaults) + } + }) + } +} + func TestModuleOverrideResourceFQNs(t *testing.T) { mod, diags := testModuleFromDir("testdata/valid-modules/override-resource-provider") assertNoDiagnostics(t, diags) diff --git a/internal/configs/testdata/valid-modules/override-output-type/a_override.tf b/internal/configs/testdata/valid-modules/override-output-type/a_override.tf new file mode 100644 index 0000000000..ca9bf7f070 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-output-type/a_override.tf @@ -0,0 +1,7 @@ +output "fully_overridden" { + type = number +} + +output "type_added_by_override" { + type = list(string) +} diff --git a/internal/configs/testdata/valid-modules/override-output-type/primary.tf b/internal/configs/testdata/valid-modules/override-output-type/primary.tf new file mode 100644 index 0000000000..0fff30800d --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-output-type/primary.tf @@ -0,0 +1,13 @@ +output "fully_overridden" { + value = "hello" + type = string +} + +output "no_override" { + value = "hello" + type = string +} + +output "type_added_by_override" { + value = "hello" +} From 9495ff1aa4ec1ee4efec84239047f90d8752a1b7 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Fri, 6 Mar 2026 15:04:44 -0500 Subject: [PATCH 106/136] more precise output evaluation with types Allow for more precise types to be used when evaluating unknown output values. If we know all the types of outputs, and there are no dynamic types involved, we can use lists and maps for the modules, which allows module values to better match between validation and plan/apply. We also fix an old problem of modules evaluating to lists and maps during validation but not plan/apply, and causing expressions to evaluate differently in different phases. We use the existence of a type declaration to opt-in to this very subtly different behavior. It's not expected that any config would notice the difference since we are only removing possible type mismatches. --- internal/terraform/evaluate.go | 114 +++++++++++++++++++--------- internal/terraform/evaluate_test.go | 5 +- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 4c61753711..ee1374427a 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -420,21 +420,66 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc } outputConfigs := moduleConfig.Module.Outputs - // We don't do instance expansion during validation, and so we need to - // return an unknown value. Technically we should always return - // cty.DynamicVal here because the final value during plan will always - // be an object or tuple type with unpredictable attributes/elements, - // but because we never actually carry values forward from validation to - // planning we lie a little here and return unknown list and map types, - // just to give us more opportunities to catch author mistakes during - // validation. - // - // This means that in practice any expression that refers to a module - // call must be written to be valid for either a collection type or - // structural type of similar kind, so that it can be considered as - // valid during both the validate and plan walks. - if d.Operation == walkValidate { - // In case of non-expanded module calls we return a known object with unknonwn values + // typeDefined tracks if a module has defined any output type at all. We can + // use this as a flag to abandon some subtly incorrect legacy behavior. + // Start false because any TypeSet will flip the flag to true + typeDefined := false + + // noDynamicTypes indicates that the module fully defines all output types, + // and they themselves contain no dynamic types. This allows us to create + // more precise unknowns for outputs, and use lists and maps when + // applicable. + // Start true because any dynamic type will flip the flag to false. + noDynamicTypes := true + + for _, out := range outputConfigs { + typeDefined = typeDefined || out.TypeSet + 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 + } + instTy := cty.Object(atys) + + switch { + case callConfig.Count != nil && noDynamicTypes: + return cty.UnknownVal(cty.List(instTy)), diags + case callConfig.ForEach != nil && noDynamicTypes: + return cty.UnknownVal(cty.Map(instTy)), diags + case callConfig.Count != nil || callConfig.ForEach != nil: + return cty.DynamicVal, diags + default: + val := cty.ObjectVal(as) + return val, diags + } + } else if d.Operation == walkValidate { + // the legacy behavior here is slightly wrong, but we're going to + // preserve it for now when modules don't define any typed output. The + // fact that we are returning a list or map is incorrect when the types + // are unknown, because the known values we get later are going to be + // tuples and objects. This usually doesn't present a problem, but it is + // possible to write complex expressions where it can only pass during + // one of validation or planning because the types will cause a mismatch + // in the other case. + // This means that in practice any expression that refers to a module + // call must be written to be valid for either a collection type or + // structural type of similar kind, so that it can be considered as + // valid during both the validate and plan walks. + + // In case of non-expanded module calls we return a known object with unknown values // In case of an expanded module call we return unknown list/map // This means deprecation can only for non-expanded modules be detected during validate // since we don't want false positives. The plan walk will give definitive warnings. @@ -487,16 +532,15 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc for name, cfg := range outputConfigs { outputAddr := moduleInstAddr.OutputValue(name) - // Although we do typically expect the graph dependencies to - // ensure that values get registered before they are needed, - // we track depedencies with specific output values where - // possible, instead of with entire module calls, and so - // in this specific case it's valid for some of this call's - // output values to not be known yet, with the graph builder - // being responsible for making sure that no expression - // in the configuration can actually observe that. + // Although we do typically expect the graph dependencies to ensure + // that values get registered before they are needed, we track + // dependencies with specific output values where possible, instead + // of with entire module calls, and so in this specific case it's + // valid for some of this call's output values to not be known yet, + // with the graph builder being responsible for making sure that no + // expression in the configuration can actually observe that. if !namedVals.HasOutputValue(outputAddr) { - attrs[name] = cty.DynamicVal + attrs[name] = cty.UnknownVal(cfg.ConstraintType) continue } outputVal := namedVals.GetOutputValue(outputAddr) @@ -535,7 +579,11 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc elems = append(elems, instVal) diags = diags.Append(moreDiags) } - return cty.TupleVal(elems), diags + if noDynamicTypes { + return cty.ListVal(elems), diags + } else { + return cty.TupleVal(elems), diags + } case addrs.StringKeyType: attrs := make(map[string]cty.Value, len(instKeys)) @@ -544,7 +592,11 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc attrs[string(instKey.(addrs.StringKey))] = instVal diags = diags.Append(moreDiags) } - return cty.ObjectVal(attrs), diags + if noDynamicTypes { + return cty.MapVal(attrs), diags + } else { + return cty.ObjectVal(attrs), diags + } default: diags = diags.Append(&hcl.Diagnostic{ @@ -602,7 +654,7 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc // TODO: When deferred actions are more stable and robust in stacks, it // would be nice to rework this function to rely on the ResourceInstanceKeys // result for _all_ of its work, rather than continuing to duplicate a bunch - // of the logic we've tried to encapsulate over ther already. + // of the logic we've tried to encapsulate over there already. if d.Operation == walkPlan || d.Operation == walkApply { if !d.Evaluator.Instances.ResourceInstanceExpanded(addr.Absolute(moduleAddr)) { // Then we've asked for a resource that hasn't been evaluated yet. @@ -795,14 +847,6 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc } case walkImport: - // Import does not yet plan resource changes, so new resources from - // config are not going to be found here. Once walkImport fully - // plans resources, this case should not longer be needed. - // In the single instance case, we can return a typed unknown value - // for the instance to better satisfy other expressions using the - // value. This of course will not help if statically known - // attributes are expected to be known elsewhere, but reduces the - // number of problematic configs for now. // Unlike in plan and apply above we can't be sure the count or // for_each instances are empty, so we return a DynamicVal. We // don't really have a good value to return otherwise -- empty diff --git a/internal/terraform/evaluate_test.go b/internal/terraform/evaluate_test.go index 0f590dbf71..67f1528731 100644 --- a/internal/terraform/evaluate_test.go +++ b/internal/terraform/evaluate_test.go @@ -612,8 +612,9 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Module: &configs.Module{ Outputs: map[string]*configs.Output{ "out": { - Name: "out", - Sensitive: true, + Name: "out", + Sensitive: true, + ConstraintType: cty.DynamicPseudoType, }, }, }, From 2767639191d3e731677eeab10a51395c5c12eb09 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Mon, 16 Mar 2026 11:28:18 +0100 Subject: [PATCH 107/136] Test cases for new typed module evaluations --- internal/terraform/evaluate_test.go | 312 +++++++++++++++++++++++++++- 1 file changed, 302 insertions(+), 10 deletions(-) diff --git a/internal/terraform/evaluate_test.go b/internal/terraform/evaluate_test.go index 67f1528731..e2853fa70b 100644 --- a/internal/terraform/evaluate_test.go +++ b/internal/terraform/evaluate_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/davecgh/go-spew/spew" + "github.com/hashicorp/hcl/v2/hcltest" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" @@ -568,7 +569,7 @@ func TestEvaluatorGetResource_changes(t *testing.T) { } func TestEvaluatorGetModule(t *testing.T) { - evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper()) + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), nil, nil) evaluator.Instances.SetModuleSingle(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}) evaluator.NamedValues.SetOutputValue( addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{addrs.ModuleInstanceStep{Name: "mod"}}), @@ -593,7 +594,304 @@ func TestEvaluatorGetModule(t *testing.T) { } } -func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesSync) *Evaluator { +func TestEvaluatorGetModule_validateTypedOutputs(t *testing.T) { + tests := map[string]struct { + configureModuleCall func(*configs.ModuleCall) + want cty.Value + }{ + "single": { + want: cty.ObjectVal(map[string]cty.Value{ + "out": cty.UnknownVal(cty.String), + }), + }, + "count": { + configureModuleCall: func(call *configs.ModuleCall) { + call.Count = hcltest.MockExprLiteral(cty.NumberIntVal(1)) + }, + 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{ + "a": cty.StringVal("a"), + })) + }, + want: cty.UnknownVal(cty.Map(cty.Object(map[string]cty.Type{ + "out": cty.String, + }))), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.String + out.TypeSet = true + }, test.configureModuleCall) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkValidate, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func TestEvaluatorGetModule_validateTypedOutputsWithDynamicTypes(t *testing.T) { + tests := map[string]struct { + configureModuleCall func(*configs.ModuleCall) + }{ + "count": { + configureModuleCall: func(call *configs.ModuleCall) { + call.Count = hcltest.MockExprLiteral(cty.NumberIntVal(1)) + }, + }, + "for_each": { + configureModuleCall: func(call *configs.ModuleCall) { + call.ForEach = hcltest.MockExprLiteral(cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("a"), + })) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.DynamicPseudoType + out.TypeSet = true + }, test.configureModuleCall) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkValidate, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(cty.DynamicVal) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.DynamicVal) + } + }) + } +} + +func TestEvaluatorGetModule_planTypedOutputs(t *testing.T) { + tests := map[string]struct { + setupInstances func(*instances.Expander) + setupOutputs func(*namedvals.State) + want cty.Value + }{ + "count": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleCount(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, 2) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(0)}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(1)}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + "for_each": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{ + "a": cty.StringVal("a"), + "b": cty.StringVal("b"), + }) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("a")}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("b")}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + "b": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.String + out.TypeSet = true + }, nil) + + test.setupInstances(evaluator.Instances) + test.setupOutputs(evaluator.NamedValues) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkPlan, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func TestEvaluatorGetModule_planUntypedOutputsRemainStructural(t *testing.T) { + tests := map[string]struct { + setupInstances func(*instances.Expander) + setupOutputs func(*namedvals.State) + want cty.Value + }{ + "count": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleCount(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, 2) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(0)}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(1)}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + "for_each": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{ + "a": cty.StringVal("a"), + "b": cty.StringVal("b"), + }) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("a")}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("b")}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + "b": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), nil, nil) + + test.setupInstances(evaluator.Instances) + test.setupOutputs(evaluator.NamedValues) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkPlan, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesSync, configureOutput func(*configs.Output), configureModuleCall func(*configs.ModuleCall)) *Evaluator { + moduleCall := &configs.ModuleCall{ + Name: "mod", + } + if configureModuleCall != nil { + configureModuleCall(moduleCall) + } + + output := &configs.Output{ + Name: "out", + Sensitive: true, + ConstraintType: cty.DynamicPseudoType, + } + if configureOutput != nil { + configureOutput(output) + } + return &Evaluator{ Meta: &ContextMeta{ Env: "foo", @@ -601,9 +899,7 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Config: &configs.Config{ Module: &configs.Module{ ModuleCalls: map[string]*configs.ModuleCall{ - "mod": { - Name: "mod", - }, + "mod": moduleCall, }, }, Children: map[string]*configs.Config{ @@ -611,11 +907,7 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Path: addrs.Module{"module.mod"}, Module: &configs.Module{ Outputs: map[string]*configs.Output{ - "out": { - Name: "out", - Sensitive: true, - ConstraintType: cty.DynamicPseudoType, - }, + "out": output, }, }, }, From 2cddb708985b0b54160fab68c8704bb70d733974 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 16 Mar 2026 14:58:31 +0100 Subject: [PATCH 108/136] use go-cty's WrangleMarksDeep instead of UnmarkDeepWithPaths This should improve the performance since we don't need to remark the values again and only handle the marks we want to deal with. --- internal/deprecation/deprecation.go | 64 ++++++++++++----------------- internal/lang/marks/marks.go | 48 ++++++++++++---------- internal/terraform/eval_for_each.go | 2 +- internal/terraform/node_output.go | 2 +- 4 files changed, 55 insertions(+), 61 deletions(-) diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go index f7065680a6..a176c4ca00 100644 --- a/internal/deprecation/deprecation.go +++ b/internal/deprecation/deprecation.go @@ -54,22 +54,19 @@ func (d *Deprecations) ValidateAndUnmark(value cty.Value, module addrs.Module, r // It finds the most specific range possible for each diagnostic. func (d *Deprecations) ValidateExpressionDeepAndUnmark(value cty.Value, module addrs.Module, expr hcl.Expression) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - unmarked, pvms := value.UnmarkDeepWithPaths() + undeprecatedVal, pdms := marks.GetDeprecationMarksDeep(value) // Check if we need to suppress deprecation warnings for this module call. if d.IsModuleCallDeprecationSuppressed(module) { - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } - for _, pvm := range pvms { - for m := range pvm.Marks { - if depMark, ok := m.(marks.DeprecationMark); ok { - rng := tfdiags.RangeForExpressionAtPath(expr, pvm.Path) - diags = diags.Append(deprecationMarkToDiagnostic(depMark, &rng)) - } - } + for _, pdm := range pdms { + rng := tfdiags.RangeForExpressionAtPath(expr, pdm.Path) + diags = diags.Append(deprecationMarkToDiagnostic(pdm.Mark, &rng)) } - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + + return undeprecatedVal, diags } func (d *Deprecations) deprecationMarksToDiagnostics(deprecationMarks []marks.DeprecationMark, module addrs.Module, rng *hcl.Range) tfdiags.Diagnostics { @@ -109,41 +106,34 @@ func deprecationMarkToDiagnostic(depMark marks.DeprecationMark, subject *hcl.Ran // unless deprecation warnings are suppressed for the given module. func (d *Deprecations) ValidateAndUnmarkConfig(value cty.Value, schema *configschema.Block, module addrs.Module) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - unmarked, pvms := value.UnmarkDeepWithPaths() + undeprecatedVal, pdms := marks.GetDeprecationMarksDeep(value) if d.IsModuleCallDeprecationSuppressed(module) { // Even if we don't want to get deprecation warnings we want to remove the marks - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } - for _, pvm := range pvms { - for m := range pvm.Marks { - if depMark, ok := m.(marks.DeprecationMark); ok { - diag := tfdiags.AttributeValue( - tfdiags.Warning, - "Deprecated value used", - depMark.Message, - pvm.Path, - ) - if depMark.OriginDescription != "" { - diag = tfdiags.Override( - diag, - tfdiags.Warning, // We just want to override the extra info - func() tfdiags.DiagnosticExtraWrapper { - return &tfdiags.DeprecationOriginDiagnosticExtra{ - // TODO: Remove common prefixes from origin descriptions? - OriginDescription: depMark.OriginDescription, - } - }) - } - - diags = diags.Append(diag) - - } + for _, pdm := range pdms { + diag := tfdiags.AttributeValue( + tfdiags.Warning, + "Deprecated value used", + pdm.Mark.Message, + pdm.Path, + ) + if pdm.Mark.OriginDescription != "" { + diag = tfdiags.Override( + diag, + tfdiags.Warning, // We just want to override the extra info + func() tfdiags.DiagnosticExtraWrapper { + return &tfdiags.DeprecationOriginDiagnosticExtra{ + OriginDescription: pdm.Mark.OriginDescription, + } + }) } + diags = diags.Append(diag) } - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } func (d *Deprecations) IsModuleCallDeprecationSuppressed(addr addrs.Module) bool { diff --git a/internal/lang/marks/marks.go b/internal/lang/marks/marks.go index 7639419e2f..d2aca10924 100644 --- a/internal/lang/marks/marks.go +++ b/internal/lang/marks/marks.go @@ -5,6 +5,7 @@ package marks import ( "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/ctymarks" ) // valueMarks allow creating strictly typed values for use as cty.Value marks. @@ -24,12 +25,11 @@ func Has(val cty.Value, mark interface{}) bool { // For value marks Has returns true if a mark of the type is present case DeprecationMark: - for depMark := range val.Marks() { - if _, ok := depMark.(DeprecationMark); ok { - return true - } + for range cty.ValueMarksOfType[DeprecationMark](val) { + return true } return false + default: panic("Unknown mark type") } @@ -72,26 +72,30 @@ func GetDeprecationMarks(val cty.Value) (cty.Value, []DeprecationMark) { return unmarked.WithMarks(other), depMarks } -// RemoveDeprecationMarks returns a copy of the given cty.Value with all -// deprecation marks removed. -func RemoveDeprecationMarks(val cty.Value) cty.Value { - newVal, marks := val.Unmark() - - for mark := range marks { - if _, ok := mark.(DeprecationMark); !ok { - newVal = newVal.Mark(mark) - } - } - - return newVal +type PathDeprecationMark struct { + Mark DeprecationMark + Path cty.Path } -// RemoveDeprecationMarksDeep returns a copy of the given cty.Value with all -// deprecation marks deeply removed. -func RemoveDeprecationMarksDeep(val cty.Value) cty.Value { - newVal, pvms := val.UnmarkDeepWithPaths() - otherPvms := RemoveAll(pvms, Deprecation) - return newVal.MarkWithPaths(otherPvms) +// GetDeprecationMarksDeep returns a copy of the given cty.Value with all +// deprecation marks removed, along with a slice of all deprecation marks found +// in the value and their paths. +func GetDeprecationMarksDeep(value cty.Value) (cty.Value, []PathDeprecationMark) { + pdms := []PathDeprecationMark{} + undeprecatedVal, _ := value.WrangleMarksDeep(func(mark any, path cty.Path) (ctymarks.WrangleAction, error) { + if depMark, ok := mark.(DeprecationMark); ok { + pdms = append(pdms, PathDeprecationMark{ + Mark: depMark, + Path: path.Copy(), + }) + // We want to drop the deprecation marks + return ctymarks.WrangleDrop, nil + } + // and ignore all other marks + return ctymarks.WrangleKeep, nil + }) + + return undeprecatedVal, pdms } // Sensitive indicates that this value is marked as sensitive in the context of diff --git a/internal/terraform/eval_for_each.go b/internal/terraform/eval_for_each.go index 8f0951d1b4..e6eb4535c6 100644 --- a/internal/terraform/eval_for_each.go +++ b/internal/terraform/eval_for_each.go @@ -90,7 +90,7 @@ func (ev *forEachEvaluator) ResourceValue() (map[string]cty.Value, bool, tfdiags return res, false, diags } - forEachVal = marks.RemoveDeprecationMarks(forEachVal) + forEachVal, _ = marks.GetDeprecationMarks(forEachVal) if forEachVal.IsNull() || !forEachVal.IsKnown() || markSafeLengthInt(forEachVal) == 0 { // we check length, because an empty set returns a nil map which will panic below return res, true, diags diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index d9d32f4961..ddb3e22646 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -521,7 +521,7 @@ If you do intend to export this data, annotate the output value as sensitive by } if n.Config.DeprecatedSet { - val = marks.RemoveDeprecationMarksDeep(val) + val, _ = marks.GetDeprecationMarksDeep(val) if n.Addr.Module.IsRoot() { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, From f3658552eaa7c3646cd7bfd70dc21fd8ee5348b4 Mon Sep 17 00:00:00 2001 From: creatorHead Date: Tue, 17 Mar 2026 17:46:03 +0530 Subject: [PATCH 109/136] Update LICENSE punctuation and wording --- LICENSE | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/LICENSE b/LICENSE index c3ba8fe791..ad310798d9 100644 --- a/LICENSE +++ b/LICENSE @@ -9,16 +9,16 @@ Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) Additional Use Grant: You may make production use of the Licensed Work, provided Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis in order to compete with - IBM Corp's paid version(s) of the Licensed Work. For purposes + IBM Corp.'s paid version(s) of the Licensed Work. For purposes of this license: A "competitive offering" is a Product that is offered to third parties on a paid basis, including through paid support arrangements, that significantly overlaps with the capabilities - of IBM Corp's paid version(s) of the Licensed Work. If Your + of IBM Corp.'s paid version(s) of the Licensed Work. If Your Product is not a competitive offering when You first make it generally available, it will not become a competitive offering - later due to IBM Corp releasing a new version of the Licensed + later due to IBM Corp. releasing a new version of the Licensed Work with additional capabilities. In addition, Products that are not provided on a paid basis are not competitive. @@ -34,10 +34,10 @@ Additional Use Grant: You may make production use of the Licensed Work, provided Hosting or using the Licensed Work(s) for internal purposes within an organization is not considered a competitive - offering. IBM Corp considers your organization to include all + offering. IBM Corp. considers your organization to include all of your affiliates under common control. - For binding interpretive guidance on using IBM Corp products + For binding interpretive guidance on using IBM Corp. products under the Business Source License, please visit our FAQ. (https://www.hashicorp.com/license-faq) Change Date: Four years from the date the Licensed Work is published. From ad270d952d73973b1d257c973f7178d99c5cf435 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:36:21 +0000 Subject: [PATCH 110/136] Bump the github-actions-breaking group across 1 directory with 4 updates (#38285) Bumps the github-actions-breaking group with 4 updates in the / directory: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/download-artifact](https://github.com/actions/download-artifact), [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) and [dorny/paths-filter](https://github.com/dorny/paths-filter). Updates `actions/upload-artifact` from 6.0.0 to 7.0.0 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) Updates `docker/setup-qemu-action` from 3.7.0 to 4.0.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/c7c53464625b32c7a7e944ae62b3e17d2b600130...ce360397dd3f832beb865e1373c09c0e9f86d70a) Updates `dorny/paths-filter` from 3.0.2 to 4.0.1 - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-breaking - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-breaking - dependency-name: docker/setup-qemu-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-breaking - dependency-name: dorny/paths-filter dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-breaking ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-terraform-cli.yml | 4 ++-- .github/workflows/build.yml | 8 ++++---- .github/workflows/changelog.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-terraform-cli.yml b/.github/workflows/build-terraform-cli.yml index 7b4c3cab92..06b3244e48 100644 --- a/.github/workflows/build-terraform-cli.yml +++ b/.github/workflows/build-terraform-cli.yml @@ -85,13 +85,13 @@ jobs: echo "RPM_PACKAGE=$(basename out/*.rpm)" >> $GITHUB_ENV echo "DEB_PACKAGE=$(basename out/*.deb)" >> $GITHUB_ENV - if: ${{ inputs.goos == 'linux' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ env.RPM_PACKAGE }} path: out/${{ env.RPM_PACKAGE }} if-no-files-found: error - if: ${{ inputs.goos == 'linux' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ env.DEB_PACKAGE }} path: out/${{ env.DEB_PACKAGE }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 779c40757a..c6514ef73b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,7 +87,7 @@ jobs: version: ${{ needs.get-product-version.outputs.product-version }} product: ${{ env.PKG_NAME }} - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: metadata.json path: ${{ steps.generate-metadata-file.outputs.filepath }} @@ -258,7 +258,7 @@ jobs: fail-on-cache-miss: true enableCrossOsArchive: true - name: "Download Terraform CLI package" - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 id: clipkg with: name: terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip @@ -269,7 +269,7 @@ jobs: unzip "${{ needs.e2etest-build.outputs.e2e-cache-path }}/terraform-e2etest_${{ env.os }}_${{ env.arch }}.zip" unzip "./terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip" - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 if: ${{ contains(matrix.goarch, 'arm') }} with: platforms: all @@ -307,7 +307,7 @@ jobs: with: go-version: ${{ needs.get-go-version.outputs.go-version }} - name: Download Terraform CLI package - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 id: clipkg with: name: terraform_${{ env.version }}_linux_amd64.zip diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index f91eb3af9f..dc6a9950ce 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -35,7 +35,7 @@ jobs: steps: - name: "Changed files" - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: changelog with: filters: | From 4b132a487982f8ecbb10608a39f040de85f44b8d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 10 Mar 2026 15:34:20 +0100 Subject: [PATCH 111/136] command: Add vars to validate command --- internal/command/arguments/validate.go | 5 +- internal/command/arguments/validate_test.go | 63 ++++++++++++++++++++- internal/command/validate.go | 37 ++++++++++-- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/internal/command/arguments/validate.go b/internal/command/arguments/validate.go index 8c337b37e9..2dcccb776a 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 1e9f0939dc..8619822e0c 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,20 +57,67 @@ 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 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 *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -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 952abb3fb2..5ac75db902 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 a1c74c7d4e9c27b619c437f381d41e7daca7cf25 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:06:12 +0100 Subject: [PATCH 112/136] 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 291ad2dfe0..cbcd455403 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 227707e039..1f5c83ff55 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 8e4b8efa57..02e8b3c924 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. + -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. + -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. - -module-depth=n (deprecated) In prior versions of Terraform, specified the - depth of modules to show in the output. + -module-depth=n (deprecated) In prior versions of Terraform, specified the + depth of modules to show in the output. + + -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 c9e1b3a47ba6d189a488bbfbb26312f594fbd5e8 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:06:40 +0100 Subject: [PATCH 113/136] 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 af76bf01c7..547dd71633 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 f4d41b6971..3a6ba941dc 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 cb2972dbc4..95d8acb7ad 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 1cc8345926f26cf6015daf35579b8dad69ec8d0b Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:01 +0100 Subject: [PATCH 114/136] 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 5637f47f03..697376567f 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 b55f69704a..02b9cedd14 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 4ada467523..7e936dbee8 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 6c6be5bcfa09f181018dee56f31a38d23db3559d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:26 +0100 Subject: [PATCH 115/136] 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 5793927152..fca6685e97 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 a59c836453..f919b431cd 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 a13a1bc86e..8f9a65659d 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 ed2bdf6825b1e91db613d7cb257ae44cb1b24769 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:07:48 +0100 Subject: [PATCH 116/136] 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 219ad50032..6b7c671a07 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 d6fb5598ee..596ed73d66 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 cfff1b2e2c..d17f548189 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. + 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. + -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 '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 c28c6c6d6451d1aae9dd0599f3d515e87b3bd2e7 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Mar 2026 15:08:08 +0100 Subject: [PATCH 117/136] 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 15afa0acfc..6e7029fa1a 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 8f6df7220c..9cb3d3c2c2 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 6af9b75f3a..33541806d5 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 b2a5ce8af1f073a7fb61f9136a583c292c7e9ba4 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:03:14 +0100 Subject: [PATCH 118/136] 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 475c5cabcf..e0ee44b6d7 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 be1d084dbf..7162773746 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 64d47e6eba..2548ad942e 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 aac88346006d8043067f7ed9676bb485534e9168 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:20:20 +0100 Subject: [PATCH 119/136] command: Add vars to state pull command --- internal/command/arguments/state_pull.go | 8 ++- internal/command/arguments/state_pull_test.go | 65 +++++++++++++++++-- internal/command/state_pull.go | 30 ++++++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/internal/command/arguments/state_pull.go b/internal/command/arguments/state_pull.go index 85bc653760..99673d971f 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 37b9922853..7696bd94cb 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,7 +19,55 @@ 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 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"}, + }, }, } @@ -26,8 +77,8 @@ func TestParseStatePull_valid(t *testing.T) { 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 vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -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 6eb6da85b5..f332f6b53a 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 36b26535b9859c46540dd1e27fb4709f844f3c1e Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:24:01 +0100 Subject: [PATCH 120/136] command: Add vars to state push command --- internal/command/arguments/state_push.go | 9 ++- internal/command/arguments/state_push_test.go | 67 ++++++++++++++++++- internal/command/state_push.go | 26 +++++++ 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/internal/command/arguments/state_push.go b/internal/command/arguments/state_push.go index f12d0670c8..58612840a8 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 70a0c8b112..e69b46443d 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,14 +70,61 @@ 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 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 *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -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 b99ca05cf3..97ef857ecd 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 2115032765904bf2e3d11797b0f4a1ddbfa1883b Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:27:15 +0100 Subject: [PATCH 121/136] 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 57f36d2c85..678d0e5a22 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 2938cfe513..ce50616693 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 73967806c3..74279202aa 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 a836cd610d87178cdd6027192027f803ee8a2066 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:31:40 +0100 Subject: [PATCH 122/136] 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 2421903afd..2271733786 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 c23887406d..6e11e6490c 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 1c555497de..b45dff41af 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 cdbd4f17f2a5101c4dbacc8dae98314addab4642 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:34:55 +0100 Subject: [PATCH 123/136] 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 3be7a20d2e..bf7060af26 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 7a6a3c07cd..f79da4f6e2 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 ba9ef4c82d..9b50a0508f 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 0fa9e5b4da9b9f18cdcb93eca29775d8a5c0e649 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:48:32 +0100 Subject: [PATCH 124/136] 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 | 46 +++++-- 3 files changed, 248 insertions(+), 13 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 0000000000..2e1d125a08 --- /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 0000000000..3aaf5b381e --- /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 7de3f96be6..98e1f7093a 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 + parsedArgs, diags := arguments.ParseGet(c.Meta.process(args)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } - 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())) + 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 97534ebd3b5d1090ea28ebbb50fe36de9cb256e2 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 11:49:22 +0100 Subject: [PATCH 125/136] 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 e372457413..695232b47c 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 fd970cf2f4..b3172ee1de 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 1311229f04..3c25006939 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 97c42fc45d..768b94355a 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 f76644ac1f..137c01aa41 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 47d68f2b63..db72d709ed 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 d4df1b9a7f..e3fc587a33 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 5dda59b46c..5972a8f74c 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 f4055eaacb..461343098b 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 1d517c3ae8970d0ee790a1c82efcc2c293ad4d29 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 12:59:14 +0100 Subject: [PATCH 126/136] 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 695232b47c..298cc969fa 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 b3172ee1de..ce299016cb 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 36b5207d2bd35ebefc2292999e523f78dd12c78d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 15:47:18 +0100 Subject: [PATCH 127/136] 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 | 144 ++++++++++++------------ 2 files changed, 72 insertions(+), 78 deletions(-) diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 768b94355a..90f63a6f25 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 e3fc587a33..4b39db0a6a 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 + // 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) } - 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 + for k, v := range fetchedVars { + if _, ok := op.Variables[k]; !ok { + op.Variables[k] = v } - - 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 op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) - diags = diags.Append(varDiags) - if diags.HasErrors() { - return nil, nil, diags - } - 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 - } - - val, diags := raw.ParseVariableValue(cfg.ParsingMode) - if diags.HasErrors() { - ret[name] = &terraform.InputValue{ - Value: cty.UnknownVal(cfg.Type), - SourceType: terraform.ValueFromConfig, - } - continue - } - ret[name] = val + remoteWorkspaceID, err := b.getRemoteWorkspaceID(ctx, workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) + return nil, diags } - return ret + 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, + } + } + } + } + + return result, nil } // remoteStoredVariableValue is a backendrun.UnparsedVariableValue implementation From 5bf90e356f004d943c1a778d1b9c53b959037bbf Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 15:50:18 +0100 Subject: [PATCH 128/136] 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 0000000000..6a85dfee25 --- /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 59e8d8ca37..5dae9abda8 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 5972a8f74c..c476378ff1 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 316d1c27ab81dd2ab60ba320cf10c71eb4f56181 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 16:26:15 +0100 Subject: [PATCH 129/136] 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 0000000000..bf9807ddca --- /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 977a0fb117cfbddfe88c780114d85230bc07765d Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 13 Mar 2026 17:57:44 +0100 Subject: [PATCH 130/136] 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 98e1f7093a..c3587aa62f 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 bbdf4ed09f..7a9dee7308 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 95d8acb7ad..93584285eb 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 7e936dbee8..1e6c2714f5 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 8f9a65659d..09ee1a791d 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 d17f548189..97881300bc 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 5ac75db902..6a49ec2f55 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 { From 332a94d9a1e5c419a021a33e28e2c8142c027073 Mon Sep 17 00:00:00 2001 From: mickael-hc <86245626+mickael-hc@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:55:11 -0400 Subject: [PATCH 131/136] update gpg with latest version from website (#38271) * update gpg with latest version updated expiry to 2030-03-01, key can be found at https://www.hashicorp.com/.well-known/pgp-key.txt * update another reference * include both old and new pubkeys ensures the certification of the old key is still present --- internal/getproviders/public_keys.go | 122 +++++++++++++++++++++++++++ internal/releaseauth/signature.go | 122 +++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/internal/getproviders/public_keys.go b/internal/getproviders/public_keys.go index 8b692d7b10..df310b0b59 100644 --- a/internal/getproviders/public_keys.go +++ b/internal/getproviders/public_keys.go @@ -126,6 +126,128 @@ aTS71iR7nZNZ1f8LZV2OvGE6fJVtgJ1J4Nu02K54uuIhU3tg1+7Xt+IqwRc9rbVr pHH/hFCYBPW2D2dxB+k2pQlg5NI+TpsXj5Zun8kRw5RtVb+dLuiH/xmxArIee8Jq ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== =7pIB +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGB9+xkBEACabYZOWKmgZsHTdRDiyPJxhbuUiKX65GUWkyRMJKi/1dviVxOX +PG6hBPtF48IFnVgxKpIb7G6NjBousAV+CuLlv5yqFKpOZEGC6sBV+Gx8Vu1CICpl +Zm+HpQPcIzwBpN+Ar4l/exCG/f/MZq/oxGgH+TyRF3XcYDjG8dbJCpHO5nQ5Cy9h +QIp3/Bh09kET6lk+4QlofNgHKVT2epV8iK1cXlbQe2tZtfCUtxk+pxvU0UHXp+AB +0xc3/gIhjZp/dePmCOyQyGPJbp5bpO4UeAJ6frqhexmNlaw9Z897ltZmRLGq1p4a +RnWL8FPkBz9SCSKXS8uNyV5oMNVn4G1obCkc106iWuKBTibffYQzq5TG8FYVJKrh +RwWB6piacEB8hl20IIWSxIM3J9tT7CPSnk5RYYCTRHgA5OOrqZhC7JefudrP8n+M +pxkDgNORDu7GCfAuisrf7dXYjLsxG4tu22DBJJC0c/IpRpXDnOuJN1Q5e/3VUKKW +mypNumuQpP5lc1ZFG64TRzb1HR6oIdHfbrVQfdiQXpvdcFx+Fl57WuUraXRV6qfb +4ZmKHX1JEwM/7tu21QE4F1dz0jroLSricZxfaCTHHWNfvGJoZ30/MZUrpSC0IfB3 +iQutxbZrwIlTBt+fGLtm3vDtwMFNWM+Rb1lrOxEQd2eijdxhvBOHtlIcswARAQAB +tERIYXNoaUNvcnAgU2VjdXJpdHkgKGhhc2hpY29ycC5jb20vc2VjdXJpdHkpIDxz +ZWN1cml0eUBoYXNoaWNvcnAuY29tPokCVAQTAQoAPgIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgBYhBMh0AR8KtAURDQIQVTQ2XZRy10aPBQJplkfQBQkQrOy3AAoJ +EDQ2XZRy10aPw6gP/3GUEMUa6mCRuuSOT9UnziPIvXYd63mcN6A6Jwmwj8JaB2qu +OCijvJkw56UbZK3x1FZIbe0hA6VUAwNSNmSIxVJkilgwIYYFO0tnL79XhIeP7jYF +ydXLZ4rTi1FDl8lltAujTNARdY8UGg4hGlcM9OrEeXEFLWugJNiChL15FVoxZqIS +jeduaEqyxGfJnyVwy8z3pZfgODeFr7xs2NkUIMSfuRg24VcL4aW8Frt3jW8P45y3 +o/5fsi6Aw2tZ0wD9NSgkVc8VD1NRV9eSZ95Bv+Awf9IXa+Cn5OCjc8Jc+XF+nLfB +oPswOO7E8dLiuBUw6/GzSLMbVs8qf8BNXB92dOe1VccVTqjCxK2sEpVaHh7e+co8 +d8lDGBIWMGh7NS6XlGORpFb/T6gxjjOYUV3SKd4QDebUUG8kMkb5juLljOoq+YOP +vgNLDZLZteFpmH+zB9DpOY1YtHZB/OD+DtzLMaSl6VPF2Ln0j5aQGwNDt7sheyAe +sXbu0qn2H5FxojSfvhT0kUDKZ0mgg5y3Oflg49MiAOhjLGY0JocFpBeMILw27fbw +fpIBP7siQWFTFJ1O+l2NQiWAwC2x5fX2EakyCBJmrkPV2hr4nEogNqg9/RDskIUq +cpcOOd/0BntiXMyUCCH2AoCt5acaTQ0WU6CAosZPojOYhtGGgOgeQSdflpMSuQIN +BGB9+xkBEACoklYsfvWRCjOwS8TOKBTfl8myuP9V9uBNbyHufzNETbhYeT33Cj0M +GCNd9GdoaknzBQLbQVSQogA+spqVvQPz1MND18GIdtmr0BXENiZE7SRvu76jNqLp +KxYALoK2Pc3yK0JGD30HcIIgx+lOofrVPA2dfVPTj1wXvm0rbSGA4Wd4Ng3d2AoR +G/wZDAQ7sdZi1A9hhfugTFZwfqR3XAYCk+PUeoFrkJ0O7wngaon+6x2GJVedVPOs +2x/XOR4l9ytFP3o+5ILhVnsK+ESVD9AQz2fhDEU6RhvzaqtHe+sQccR3oVLoGcat +ma5rbfzH0Fhj0JtkbP7WreQf9udYgXxVJKXLQFQgel34egEGG+NlbGSPG+qHOZtY +4uWdlDSvmo+1P95P4VG/EBteqyBbDDGDGiMs6lAMg2cULrwOsbxWjsWka8y2IN3z +1stlIJFvW2kggU+bKnQ+sNQnclq3wzCJjeDBfucR3a5WRojDtGoJP6Fc3luUtS7V +5TAdOx4dhaMFU9+01OoH8ZdTRiHZ1K7RFeAIslSyd4iA/xkhOhHq89F4ECQf3Bt4 +ZhGsXDTaA/VgHmf3AULbrC94O7HNqOvTWzwGiWHLfcxXQsr+ijIEQvh6rHKmJK8R +9NMHqc3L18eMO6bqrzEHW0Xoiu9W8Yj+WuB3IKdhclT3w0pO4Pj8gQARAQABiQI8 +BBgBCgAmAhsMFiEEyHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWR+0FCRCs7NQACgkQ +NDZdlHLXRo/R0A//QW1opBlzWSmWww1q9QuJA2WCIIs8tJKRDOsmgJPscNpzwZFU +N1Df0wWNjqi1BDReei7lZTHwUk+ebBn0bkI3ANmmgYg7LBueAt5UWSingOc+rvKA +N32BDzBYkMckRzJSQsmeC5hm3J3wLSy90uaIlrJJE9GJZkf/W2Ob+4SQZZ+dnnRP +JokDdW1DuZS9PbxSLJKD5eIWHBxJnFM1CmHfOfrjTJ+MYvVGM5sxSY8R7E+GADj5 +L/i4N+tTFJLuTMYARGfA6d+KPKcMJtgpUPjSMAg8nGUhukctpuBs27mOKW0CBtmJ +82X/qYROTL0+vGTvUYflYiuceVlhX/kw0JZnMaG5V/mpHq8SwD07pCGOf69j/mNa +5EL3++Pmzg0s0stw3Ea5pCN0cL/nKkoWchHBfW15W4JOnKAIspyD1vH670P4WfeV +E9B9d6tgKSbM/9JlXoQS5ZdG+kbdosieELhmVWmvojyK7K+Ry6C9wgd+UfnW5jXd +iNwKW3KHuautQwlFhHRNMyDg08c+pI5emTMT3IUQyGWo+Gska3TqGujFcABx7Ip+ +mHNmMrCkSD+XC2bvzvRR7FcM0/B9fsjLX/Wttm5vRJ1d2oAoEPvw2IZnJIXpOt2z +zo55sJTztNu4lWGgDVgtp9SXO5a0E5YvFHQNZN5QLeVTTFu6I7qG+ME1E/K5Ag0E +YH3+JQEQALivllTjMolxUW2OxrXb+a2Pt6vjCBsiJzrUj0Pa63U+lT9jldbCCfgP +wDpcDuO1O05Q8k1MoYZ6HddjWnqKG7S3eqkV5c3ct3amAXp513QDKZUfIDylOmhU +qvxjEgvGjdRjz6kECFGYr6Vnj/p6AwWv4/FBRFlrq7cnQgPynbIH4hrWvewp3Tqw +GVgqm5RRofuAugi8iZQVlAiQZJo88yaztAQ/7VsXBiHTn61ugQ8bKdAsr8w/ZZU5 +HScHLqRolcYg0cKN91c0EbJq9k1LUC//CakPB9mhi5+aUVUGusIM8ECShUEgSTCi +KQiJUPZ2CFbbPE9L5o9xoPCxjXoX+r7L/WyoCPTeoS3YRUMEnWKvc42Yxz3meRb+ +BmaqgbheNmzOah5nMwPupJYmHrjWPkX7oyyHxLSFw4dtoP2j6Z7GdRXKa2dUYdk2 +x3JYKocrDoPHh3Q0TAZujtpdjFi1BS8pbxYFb3hHmGSdvz7T7KcqP7ChC7k2RAKO +GiG7QQe4NX3sSMgweYpl4OwvQOn73t5CVWYp/gIBNZGsU3Pto8g27vHeWyH9mKr4 +cSepDhw+/X8FGRNdxNfpLKm7Vc0Sm9Sof8TRFrBTqX+vIQupYHRi5QQCuYaV6OVr +ITeegNK3So4m39d6ajCR9QxRbmjnx9UcnSYYDmIB6fpBuwT0ogNtABEBAAGJBHIE +GAEKACYCGwIWIQTIdAEfCrQFEQ0CEFU0Nl2UctdGjwUCYH4bgAUJAeFQ2wJAwXQg +BBkBCgAdFiEEs2y6kaLAcwxDX8KAsLRBCXaFtnYFAmB9/iUACgkQsLRBCXaFtnYX +BhAAlxejyFXoQwyGo9U+2g9N6LUb/tNtH29RHYxy4A3/ZUY7d/FMkArmh4+dfjf0 +p9MJz98Zkps20kaYP+2YzYmaizO6OA6RIddcEXQDRCPHmLts3097mJ/skx9qLAf6 +rh9J7jWeSqWO6VW6Mlx8j9m7sm3Ae1OsjOx/m7lGZOhY4UYfY627+Jf7WQ5103Qs +lgQ09es/vhTCx0g34SYEmMW15Tc3eCjQ21b1MeJD/V26npeakV8iCZ1kHZHawPq/ +aCCuYEcCeQOOteTWvl7HXaHMhHIx7jjOd8XX9V+UxsGz2WCIxX/j7EEEc7CAxwAN +nWp9jXeLfxYfjrUB7XQZsGCd4EHHzUyCf7iRJL7OJ3tz5Z+rOlNjSgci+ycHEccL +YeFAEV+Fz+sj7q4cFAferkr7imY1XEI0Ji5P8p/uRYw/n8uUf7LrLw5TzHmZsTSC +UaiL4llRzkDC6cVhYfqQWUXDd/r385OkE4oalNNE+n+txNRx92rpvXWZ5qFYfv7E +95fltvpXc0iOugPMzyof3lwo3Xi4WZKc1CC/jEviKTQhfn3WZukuF5lbz3V1PQfI +xFsYe9WYQmp25XGgezjXzp89C/OIcYsVB1KJAKihgbYdHyUN4fRCmOszmOUwEAKR +3k5j4X8V5bk08sA69NVXPn2ofxyk3YYOMYWW8ouObnXoS8QJEDQ2XZRy10aPMpsQ +AIbwX21erVqUDMPn1uONP6o4NBEq4MwG7d+fT85rc1U0RfeKBwjucAE/iStZDQoM +ZKWvGhFR+uoyg1LrXNKuSPB82unh2bpvj4zEnJsJadiwtShTKDsikhrfFEK3aCK8 +Zuhpiu3jxMFDhpFzlxsSwaCcGJqcdwGhWUx0ZAVD2X71UCFoOXPjF9fNnpy80YNp +flPjj2RnOZbJyBIM0sWIVMd8F44qkTASf8K5Qb47WFN5tSpePq7OCm7s8u+lYZGK +wR18K7VliundR+5a8XAOyUXOL5UsDaQCK4Lj4lRaeFXunXl3DJ4E+7BKzZhReJL6 +EugV5eaGonA52TWtFdB8p+79wPUeI3KcdPmQ9Ll5Zi/jBemY4bzasmgKzNeMtwWP +fk6WgrvBwptqohw71HDymGxFUnUP7XYYjic2sVKhv9AevMGycVgwWBiWroDCQ9Ja +btKfxHhI2p+g+rcywmBobWJbZsujTNjhtme+kNn1mhJsD3bKPjKQfAxaTskBLb0V +wgV21891TS1Dq9kdPLwoS4XNpYg2LLB4p9hmeG3fu9+OmqwY5oKXsHiWc43dei9Y +yxZ1AAUOIaIdPkq+YG/PhlGE4YcQZ4RPpltAr0HfGgZhmXWigbGS+66pUj+Ojysc +j0K5tCVxVu0fhhFpOlHv0LWaxCbnkgkQH9jfMEJkAWMOuQINBGCAXCYBEADW6RNr +ZVGNXvHVBqSiOWaxl1XOiEoiHPt50Aijt25yXbG+0kHIFSoR+1g6Lh20JTCChgfQ +kGGjzQvEuG1HTw07YhsvLc0pkjNMfu6gJqFox/ogc53mz69OxXauzUQ/TZ27GDVp +UBu+EhDKt1s3OtA6Bjz/csop/Um7gT0+ivHyvJ/jGdnPEZv8tNuSE/Uo+hn/Q9hg +8SbveZzo3C+U4KcabCESEFl8Gq6aRi9vAfa65oxD5jKaIz7cy+pwb0lizqlW7H9t +Qlr3dBfdIcdzgR55hTFC5/XrcwJ6/nHVH/xGskEasnfCQX8RYKMuy0UADJy72TkZ +bYaCx+XXIcVB8GTOmJVoAhrTSSVLAZspfCnjwnSxisDn3ZzsYrq3cV6sU8b+QlIX +7VAjurE+5cZiVlaxgCjyhKqlGgmonnReWOBacCgL/UvuwMmMp5TTLmiLXLT7uxeG +ojEyoCk4sMrqrU1jevHyGlDJH9Taux15GILDwnYFfAvPF9WCid4UZ4Ouwjcaxfys +3LxNiZIlUsXNKwS3mhiMRL4TRsbs4k4QE+LIMOsauIvcvm8/frydvQ/kUwIhVTH8 +0XGOH909bYtJvY3fudK7ShIwm7ZFTduBJUG473E/Fn3VkhTmBX6+PjOC50HR/Hyb +waRCzfDruMe3TAcE/tSP5CUOb9C7+P+hPzQcDwARAQABiQRyBBgBCgAmAhsCFiEE +yHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWSAoFCRCqi+QCQMF0IAQZAQoAHRYhBDdO +x1tIWRNgSoMcx8ggxtXNJ6uHBQJggFwmAAoJEMggxtXNJ6uHRfAP/2CGdSyg0K7U +66Vygl0dugxrMm8O3/Oe211BKdQsFUSWAznOTRTK/zvMUHO4LJAlYvdtZ6xDa4XH +l9FYQ8MR9ZV0OuOlAZvU4IJDLPVCU09X/UzX/GEoZL0R5esvwPAXopMaRHCfXJeI +/gEaB94UhAeYlwpcRn0eSuk1vyZx7GRE6/hog8DCf4hoT40dW20gGe58xcvJ+mRY +lC0lr16WH08wuUcee6+dgu+4Cg6SG6+zt9cMyl8VnTUL5BK/V3MebnYZJK0RFDNn +nXDhzStgOd5gOeIL+xBPXHd0/ld/rDM74SFExpuS+hNsyo+xMQ/HJavak21MFinu +l9COwfGEmlAXTGMY30Lf3Pt/eAkbwgmGc966VSoRmOFEXJVlDr+yJR6ru+7j50z8 +lAv6Lsop7sun1Qysbo0swf6W1qgPf6VWbx91NTFLkw0+gD8jxwrU5ZMkeSuntX9d +pjuZS29CflXXIRPlvhuiDPicwTpYuIUx37vHveAH5gnowZg247x780Urrsx8duTX +8CI9MAnqzm4dFAiRlwE8bvLk+l9wekiXA9gIMZiVNqNlduXIqvAG21Wdgq8qyeXK +y/XWCVKDQOmEbFAltfNam8E3KEw0fl199x+93d5ckDGcPzUYPbNkCuIwngC/ZN96 +pDafF3Z12fSNfhZUe0C8td8KAszYa96GCRA0Nl2UctdGj1gKD/4jOGhEGTg88Vyu +PVjeK+zkwrTIZSvHdUHfTt/+rTLSNb/RQiBCUQuEZvafj6FrntS7bAEhccGqH894 +T3St5K0AXWkvsLd6K+cbIQdlnFA2zb6geJUCk6qx5NgWpRc3i0DS7CheGwl+Bwu7 ++n9pNjNjiHV+rYDgqbQXG0dtGysB0/3qIRgEDHFO0HJu/dcte4oXrQIqrZrpOwe8 +WxqFqdU918JpSUcc8coiFp9YtwpgqQNxGVZ+rhgnTGdZzk1f/Yhhimh+2B0ReaFv +k3UzVBj3HQ9C6+Ot3MyDEhSgdhjr9e25Tm9S5YfhwtWmghRw9RKPyLMSXSxm/Uc0 +mK1NucAp8TQBwKqKzNpCk5IdrBSWRUbjOoOFyzyCsY6gS285GCpSIzI39hTf+3gd +wYPlE6fj+F2TZzdhx62DPnzBzBHnByYTVdJ649bx0FFp4Q+5TbIWtxu/AQkRDxmW +NQfE+6GgeshlrhXWsh6+PGDzt+2raG6zUT913sdz7Ctw4fLjmsKOTdTz3Xa9pr8l +xfI/JuukSgt9o/n3GirhTB3zE1w/I/Xt6k7oASiP3zQSuHtB/CYKYHDtOCWwjo7J +PEGtb/FkreKNxsk/p20jnlrB8WZxxswdr2Vri9NmFeyMDVX7qF3WqT+8aCV9GtS1 +GCHx/5nGBdDwoxEsXqpI3IUqPb6FDg== +=wtp+ -----END PGP PUBLIC KEY BLOCK-----` // HashicorpPartnersKey is a key created by HashiCorp, used to generate and diff --git a/internal/releaseauth/signature.go b/internal/releaseauth/signature.go index 2b1af10587..eb2db3ca5f 100644 --- a/internal/releaseauth/signature.go +++ b/internal/releaseauth/signature.go @@ -181,4 +181,126 @@ aTS71iR7nZNZ1f8LZV2OvGE6fJVtgJ1J4Nu02K54uuIhU3tg1+7Xt+IqwRc9rbVr pHH/hFCYBPW2D2dxB+k2pQlg5NI+TpsXj5Zun8kRw5RtVb+dLuiH/xmxArIee8Jq ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== =7pIB +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGB9+xkBEACabYZOWKmgZsHTdRDiyPJxhbuUiKX65GUWkyRMJKi/1dviVxOX +PG6hBPtF48IFnVgxKpIb7G6NjBousAV+CuLlv5yqFKpOZEGC6sBV+Gx8Vu1CICpl +Zm+HpQPcIzwBpN+Ar4l/exCG/f/MZq/oxGgH+TyRF3XcYDjG8dbJCpHO5nQ5Cy9h +QIp3/Bh09kET6lk+4QlofNgHKVT2epV8iK1cXlbQe2tZtfCUtxk+pxvU0UHXp+AB +0xc3/gIhjZp/dePmCOyQyGPJbp5bpO4UeAJ6frqhexmNlaw9Z897ltZmRLGq1p4a +RnWL8FPkBz9SCSKXS8uNyV5oMNVn4G1obCkc106iWuKBTibffYQzq5TG8FYVJKrh +RwWB6piacEB8hl20IIWSxIM3J9tT7CPSnk5RYYCTRHgA5OOrqZhC7JefudrP8n+M +pxkDgNORDu7GCfAuisrf7dXYjLsxG4tu22DBJJC0c/IpRpXDnOuJN1Q5e/3VUKKW +mypNumuQpP5lc1ZFG64TRzb1HR6oIdHfbrVQfdiQXpvdcFx+Fl57WuUraXRV6qfb +4ZmKHX1JEwM/7tu21QE4F1dz0jroLSricZxfaCTHHWNfvGJoZ30/MZUrpSC0IfB3 +iQutxbZrwIlTBt+fGLtm3vDtwMFNWM+Rb1lrOxEQd2eijdxhvBOHtlIcswARAQAB +tERIYXNoaUNvcnAgU2VjdXJpdHkgKGhhc2hpY29ycC5jb20vc2VjdXJpdHkpIDxz +ZWN1cml0eUBoYXNoaWNvcnAuY29tPokCVAQTAQoAPgIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgBYhBMh0AR8KtAURDQIQVTQ2XZRy10aPBQJplkfQBQkQrOy3AAoJ +EDQ2XZRy10aPw6gP/3GUEMUa6mCRuuSOT9UnziPIvXYd63mcN6A6Jwmwj8JaB2qu +OCijvJkw56UbZK3x1FZIbe0hA6VUAwNSNmSIxVJkilgwIYYFO0tnL79XhIeP7jYF +ydXLZ4rTi1FDl8lltAujTNARdY8UGg4hGlcM9OrEeXEFLWugJNiChL15FVoxZqIS +jeduaEqyxGfJnyVwy8z3pZfgODeFr7xs2NkUIMSfuRg24VcL4aW8Frt3jW8P45y3 +o/5fsi6Aw2tZ0wD9NSgkVc8VD1NRV9eSZ95Bv+Awf9IXa+Cn5OCjc8Jc+XF+nLfB +oPswOO7E8dLiuBUw6/GzSLMbVs8qf8BNXB92dOe1VccVTqjCxK2sEpVaHh7e+co8 +d8lDGBIWMGh7NS6XlGORpFb/T6gxjjOYUV3SKd4QDebUUG8kMkb5juLljOoq+YOP +vgNLDZLZteFpmH+zB9DpOY1YtHZB/OD+DtzLMaSl6VPF2Ln0j5aQGwNDt7sheyAe +sXbu0qn2H5FxojSfvhT0kUDKZ0mgg5y3Oflg49MiAOhjLGY0JocFpBeMILw27fbw +fpIBP7siQWFTFJ1O+l2NQiWAwC2x5fX2EakyCBJmrkPV2hr4nEogNqg9/RDskIUq +cpcOOd/0BntiXMyUCCH2AoCt5acaTQ0WU6CAosZPojOYhtGGgOgeQSdflpMSuQIN +BGB9+xkBEACoklYsfvWRCjOwS8TOKBTfl8myuP9V9uBNbyHufzNETbhYeT33Cj0M +GCNd9GdoaknzBQLbQVSQogA+spqVvQPz1MND18GIdtmr0BXENiZE7SRvu76jNqLp +KxYALoK2Pc3yK0JGD30HcIIgx+lOofrVPA2dfVPTj1wXvm0rbSGA4Wd4Ng3d2AoR +G/wZDAQ7sdZi1A9hhfugTFZwfqR3XAYCk+PUeoFrkJ0O7wngaon+6x2GJVedVPOs +2x/XOR4l9ytFP3o+5ILhVnsK+ESVD9AQz2fhDEU6RhvzaqtHe+sQccR3oVLoGcat +ma5rbfzH0Fhj0JtkbP7WreQf9udYgXxVJKXLQFQgel34egEGG+NlbGSPG+qHOZtY +4uWdlDSvmo+1P95P4VG/EBteqyBbDDGDGiMs6lAMg2cULrwOsbxWjsWka8y2IN3z +1stlIJFvW2kggU+bKnQ+sNQnclq3wzCJjeDBfucR3a5WRojDtGoJP6Fc3luUtS7V +5TAdOx4dhaMFU9+01OoH8ZdTRiHZ1K7RFeAIslSyd4iA/xkhOhHq89F4ECQf3Bt4 +ZhGsXDTaA/VgHmf3AULbrC94O7HNqOvTWzwGiWHLfcxXQsr+ijIEQvh6rHKmJK8R +9NMHqc3L18eMO6bqrzEHW0Xoiu9W8Yj+WuB3IKdhclT3w0pO4Pj8gQARAQABiQI8 +BBgBCgAmAhsMFiEEyHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWR+0FCRCs7NQACgkQ +NDZdlHLXRo/R0A//QW1opBlzWSmWww1q9QuJA2WCIIs8tJKRDOsmgJPscNpzwZFU +N1Df0wWNjqi1BDReei7lZTHwUk+ebBn0bkI3ANmmgYg7LBueAt5UWSingOc+rvKA +N32BDzBYkMckRzJSQsmeC5hm3J3wLSy90uaIlrJJE9GJZkf/W2Ob+4SQZZ+dnnRP +JokDdW1DuZS9PbxSLJKD5eIWHBxJnFM1CmHfOfrjTJ+MYvVGM5sxSY8R7E+GADj5 +L/i4N+tTFJLuTMYARGfA6d+KPKcMJtgpUPjSMAg8nGUhukctpuBs27mOKW0CBtmJ +82X/qYROTL0+vGTvUYflYiuceVlhX/kw0JZnMaG5V/mpHq8SwD07pCGOf69j/mNa +5EL3++Pmzg0s0stw3Ea5pCN0cL/nKkoWchHBfW15W4JOnKAIspyD1vH670P4WfeV +E9B9d6tgKSbM/9JlXoQS5ZdG+kbdosieELhmVWmvojyK7K+Ry6C9wgd+UfnW5jXd +iNwKW3KHuautQwlFhHRNMyDg08c+pI5emTMT3IUQyGWo+Gska3TqGujFcABx7Ip+ +mHNmMrCkSD+XC2bvzvRR7FcM0/B9fsjLX/Wttm5vRJ1d2oAoEPvw2IZnJIXpOt2z +zo55sJTztNu4lWGgDVgtp9SXO5a0E5YvFHQNZN5QLeVTTFu6I7qG+ME1E/K5Ag0E +YH3+JQEQALivllTjMolxUW2OxrXb+a2Pt6vjCBsiJzrUj0Pa63U+lT9jldbCCfgP +wDpcDuO1O05Q8k1MoYZ6HddjWnqKG7S3eqkV5c3ct3amAXp513QDKZUfIDylOmhU +qvxjEgvGjdRjz6kECFGYr6Vnj/p6AwWv4/FBRFlrq7cnQgPynbIH4hrWvewp3Tqw +GVgqm5RRofuAugi8iZQVlAiQZJo88yaztAQ/7VsXBiHTn61ugQ8bKdAsr8w/ZZU5 +HScHLqRolcYg0cKN91c0EbJq9k1LUC//CakPB9mhi5+aUVUGusIM8ECShUEgSTCi +KQiJUPZ2CFbbPE9L5o9xoPCxjXoX+r7L/WyoCPTeoS3YRUMEnWKvc42Yxz3meRb+ +BmaqgbheNmzOah5nMwPupJYmHrjWPkX7oyyHxLSFw4dtoP2j6Z7GdRXKa2dUYdk2 +x3JYKocrDoPHh3Q0TAZujtpdjFi1BS8pbxYFb3hHmGSdvz7T7KcqP7ChC7k2RAKO +GiG7QQe4NX3sSMgweYpl4OwvQOn73t5CVWYp/gIBNZGsU3Pto8g27vHeWyH9mKr4 +cSepDhw+/X8FGRNdxNfpLKm7Vc0Sm9Sof8TRFrBTqX+vIQupYHRi5QQCuYaV6OVr +ITeegNK3So4m39d6ajCR9QxRbmjnx9UcnSYYDmIB6fpBuwT0ogNtABEBAAGJBHIE +GAEKACYCGwIWIQTIdAEfCrQFEQ0CEFU0Nl2UctdGjwUCYH4bgAUJAeFQ2wJAwXQg +BBkBCgAdFiEEs2y6kaLAcwxDX8KAsLRBCXaFtnYFAmB9/iUACgkQsLRBCXaFtnYX +BhAAlxejyFXoQwyGo9U+2g9N6LUb/tNtH29RHYxy4A3/ZUY7d/FMkArmh4+dfjf0 +p9MJz98Zkps20kaYP+2YzYmaizO6OA6RIddcEXQDRCPHmLts3097mJ/skx9qLAf6 +rh9J7jWeSqWO6VW6Mlx8j9m7sm3Ae1OsjOx/m7lGZOhY4UYfY627+Jf7WQ5103Qs +lgQ09es/vhTCx0g34SYEmMW15Tc3eCjQ21b1MeJD/V26npeakV8iCZ1kHZHawPq/ +aCCuYEcCeQOOteTWvl7HXaHMhHIx7jjOd8XX9V+UxsGz2WCIxX/j7EEEc7CAxwAN +nWp9jXeLfxYfjrUB7XQZsGCd4EHHzUyCf7iRJL7OJ3tz5Z+rOlNjSgci+ycHEccL +YeFAEV+Fz+sj7q4cFAferkr7imY1XEI0Ji5P8p/uRYw/n8uUf7LrLw5TzHmZsTSC +UaiL4llRzkDC6cVhYfqQWUXDd/r385OkE4oalNNE+n+txNRx92rpvXWZ5qFYfv7E +95fltvpXc0iOugPMzyof3lwo3Xi4WZKc1CC/jEviKTQhfn3WZukuF5lbz3V1PQfI +xFsYe9WYQmp25XGgezjXzp89C/OIcYsVB1KJAKihgbYdHyUN4fRCmOszmOUwEAKR +3k5j4X8V5bk08sA69NVXPn2ofxyk3YYOMYWW8ouObnXoS8QJEDQ2XZRy10aPMpsQ +AIbwX21erVqUDMPn1uONP6o4NBEq4MwG7d+fT85rc1U0RfeKBwjucAE/iStZDQoM +ZKWvGhFR+uoyg1LrXNKuSPB82unh2bpvj4zEnJsJadiwtShTKDsikhrfFEK3aCK8 +Zuhpiu3jxMFDhpFzlxsSwaCcGJqcdwGhWUx0ZAVD2X71UCFoOXPjF9fNnpy80YNp +flPjj2RnOZbJyBIM0sWIVMd8F44qkTASf8K5Qb47WFN5tSpePq7OCm7s8u+lYZGK +wR18K7VliundR+5a8XAOyUXOL5UsDaQCK4Lj4lRaeFXunXl3DJ4E+7BKzZhReJL6 +EugV5eaGonA52TWtFdB8p+79wPUeI3KcdPmQ9Ll5Zi/jBemY4bzasmgKzNeMtwWP +fk6WgrvBwptqohw71HDymGxFUnUP7XYYjic2sVKhv9AevMGycVgwWBiWroDCQ9Ja +btKfxHhI2p+g+rcywmBobWJbZsujTNjhtme+kNn1mhJsD3bKPjKQfAxaTskBLb0V +wgV21891TS1Dq9kdPLwoS4XNpYg2LLB4p9hmeG3fu9+OmqwY5oKXsHiWc43dei9Y +yxZ1AAUOIaIdPkq+YG/PhlGE4YcQZ4RPpltAr0HfGgZhmXWigbGS+66pUj+Ojysc +j0K5tCVxVu0fhhFpOlHv0LWaxCbnkgkQH9jfMEJkAWMOuQINBGCAXCYBEADW6RNr +ZVGNXvHVBqSiOWaxl1XOiEoiHPt50Aijt25yXbG+0kHIFSoR+1g6Lh20JTCChgfQ +kGGjzQvEuG1HTw07YhsvLc0pkjNMfu6gJqFox/ogc53mz69OxXauzUQ/TZ27GDVp +UBu+EhDKt1s3OtA6Bjz/csop/Um7gT0+ivHyvJ/jGdnPEZv8tNuSE/Uo+hn/Q9hg +8SbveZzo3C+U4KcabCESEFl8Gq6aRi9vAfa65oxD5jKaIz7cy+pwb0lizqlW7H9t +Qlr3dBfdIcdzgR55hTFC5/XrcwJ6/nHVH/xGskEasnfCQX8RYKMuy0UADJy72TkZ +bYaCx+XXIcVB8GTOmJVoAhrTSSVLAZspfCnjwnSxisDn3ZzsYrq3cV6sU8b+QlIX +7VAjurE+5cZiVlaxgCjyhKqlGgmonnReWOBacCgL/UvuwMmMp5TTLmiLXLT7uxeG +ojEyoCk4sMrqrU1jevHyGlDJH9Taux15GILDwnYFfAvPF9WCid4UZ4Ouwjcaxfys +3LxNiZIlUsXNKwS3mhiMRL4TRsbs4k4QE+LIMOsauIvcvm8/frydvQ/kUwIhVTH8 +0XGOH909bYtJvY3fudK7ShIwm7ZFTduBJUG473E/Fn3VkhTmBX6+PjOC50HR/Hyb +waRCzfDruMe3TAcE/tSP5CUOb9C7+P+hPzQcDwARAQABiQRyBBgBCgAmAhsCFiEE +yHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWSAoFCRCqi+QCQMF0IAQZAQoAHRYhBDdO +x1tIWRNgSoMcx8ggxtXNJ6uHBQJggFwmAAoJEMggxtXNJ6uHRfAP/2CGdSyg0K7U +66Vygl0dugxrMm8O3/Oe211BKdQsFUSWAznOTRTK/zvMUHO4LJAlYvdtZ6xDa4XH +l9FYQ8MR9ZV0OuOlAZvU4IJDLPVCU09X/UzX/GEoZL0R5esvwPAXopMaRHCfXJeI +/gEaB94UhAeYlwpcRn0eSuk1vyZx7GRE6/hog8DCf4hoT40dW20gGe58xcvJ+mRY +lC0lr16WH08wuUcee6+dgu+4Cg6SG6+zt9cMyl8VnTUL5BK/V3MebnYZJK0RFDNn +nXDhzStgOd5gOeIL+xBPXHd0/ld/rDM74SFExpuS+hNsyo+xMQ/HJavak21MFinu +l9COwfGEmlAXTGMY30Lf3Pt/eAkbwgmGc966VSoRmOFEXJVlDr+yJR6ru+7j50z8 +lAv6Lsop7sun1Qysbo0swf6W1qgPf6VWbx91NTFLkw0+gD8jxwrU5ZMkeSuntX9d +pjuZS29CflXXIRPlvhuiDPicwTpYuIUx37vHveAH5gnowZg247x780Urrsx8duTX +8CI9MAnqzm4dFAiRlwE8bvLk+l9wekiXA9gIMZiVNqNlduXIqvAG21Wdgq8qyeXK +y/XWCVKDQOmEbFAltfNam8E3KEw0fl199x+93d5ckDGcPzUYPbNkCuIwngC/ZN96 +pDafF3Z12fSNfhZUe0C8td8KAszYa96GCRA0Nl2UctdGj1gKD/4jOGhEGTg88Vyu +PVjeK+zkwrTIZSvHdUHfTt/+rTLSNb/RQiBCUQuEZvafj6FrntS7bAEhccGqH894 +T3St5K0AXWkvsLd6K+cbIQdlnFA2zb6geJUCk6qx5NgWpRc3i0DS7CheGwl+Bwu7 ++n9pNjNjiHV+rYDgqbQXG0dtGysB0/3qIRgEDHFO0HJu/dcte4oXrQIqrZrpOwe8 +WxqFqdU918JpSUcc8coiFp9YtwpgqQNxGVZ+rhgnTGdZzk1f/Yhhimh+2B0ReaFv +k3UzVBj3HQ9C6+Ot3MyDEhSgdhjr9e25Tm9S5YfhwtWmghRw9RKPyLMSXSxm/Uc0 +mK1NucAp8TQBwKqKzNpCk5IdrBSWRUbjOoOFyzyCsY6gS285GCpSIzI39hTf+3gd +wYPlE6fj+F2TZzdhx62DPnzBzBHnByYTVdJ649bx0FFp4Q+5TbIWtxu/AQkRDxmW +NQfE+6GgeshlrhXWsh6+PGDzt+2raG6zUT913sdz7Ctw4fLjmsKOTdTz3Xa9pr8l +xfI/JuukSgt9o/n3GirhTB3zE1w/I/Xt6k7oASiP3zQSuHtB/CYKYHDtOCWwjo7J +PEGtb/FkreKNxsk/p20jnlrB8WZxxswdr2Vri9NmFeyMDVX7qF3WqT+8aCV9GtS1 +GCHx/5nGBdDwoxEsXqpI3IUqPb6FDg== +=wtp+ -----END PGP PUBLIC KEY BLOCK-----` From 1ec439bccac7a20d2cf086fdc5f19447d7062aea Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Wed, 18 Mar 2026 13:20:21 +0000 Subject: [PATCH 132/136] Prepare main branch for post-v1.15 development (#38287) * Prepare main branch for post-v1.15 development * Bump Terraform version in the PR template --- .changes/{1.15.0.md => 1.16.0.md} | 0 .changes/previous-releases.md | 1 + .changes/v1.13/BUG FIXES-20251009-144645.yaml | 5 -- .changes/v1.13/BUG FIXES-20251103-112034.yaml | 5 -- .changes/v1.14/BUG FIXES-20250924-110416.yaml | 5 -- .changes/v1.14/BUG FIXES-20250926-113318.yaml | 5 -- .changes/v1.14/BUG FIXES-20251024-164434.yaml | 5 -- .changes/v1.14/BUG FIXES-20251029-175958.yaml | 5 -- .changes/v1.14/BUG FIXES-20251031-144915.yaml | 5 -- .changes/v1.14/BUG FIXES-20251103-112034.yaml | 5 -- .changes/v1.14/BUG FIXES-20251104-122322.yaml | 5 -- .changes/v1.14/BUG FIXES-20251124-150000.yaml | 5 -- .changes/v1.14/BUG FIXES-20251208-170259.yaml | 5 -- .changes/v1.14/BUG FIXES-20251209-130050.yaml | 5 -- .changes/v1.14/BUG FIXES-20251209-230000.yaml | 5 -- .changes/v1.14/BUG FIXES-20260108-114527.yaml | 5 -- .changes/v1.14/BUG FIXES-20260116-101253.yaml | 5 -- .changes/v1.14/BUG FIXES-20260123-103307.yaml | 5 -- .changes/v1.14/BUG FIXES-20260311-163804.yaml | 5 -- .changes/v1.14/BUGFIX-20250927-184134.yaml | 5 -- .../v1.14/ENHANCEMENTS-20250919-115253.yaml | 5 -- .../v1.14/ENHANCEMENTS-20250925-151237.yaml | 5 -- .../v1.14/ENHANCEMENTS-20251002-172626.yaml | 5 -- .../v1.14/ENHANCEMENTS-20251204-125848.yaml | 5 -- .changes/v1.15/BUG FIXES-20251024-042900.yaml | 5 -- .changes/v1.15/BUG FIXES-20251110-120921.yaml | 5 -- .changes/v1.15/BUG FIXES-20251112-033830.yaml | 5 -- .changes/v1.15/BUG FIXES-20251119-103000.yaml | 5 -- .changes/v1.15/BUG FIXES-20251121-183045.yaml | 5 -- .changes/v1.15/BUG FIXES-20251201-114950.yaml | 5 -- .changes/v1.15/BUG FIXES-20251213-120000.yaml | 5 -- .changes/v1.15/BUG FIXES-20251223-184516.yaml | 5 -- .changes/v1.15/BUG FIXES-20260105-170648.yaml | 5 -- .changes/v1.15/BUG FIXES-20260115-103000.yaml | 5 -- .changes/v1.15/BUG FIXES-20260210-113930.yaml | 5 -- .changes/v1.15/BUG FIXES-20260214-120000.yaml | 5 -- .../v1.15/ENHANCEMENTS-20250203-175807.yaml | 5 -- .../v1.15/ENHANCEMENTS-20251022-162909.yaml | 5 -- .../v1.15/ENHANCEMENTS-20251027-132238.yaml | 5 -- .../v1.15/ENHANCEMENTS-20251107-140221.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260113-130449.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260120-172831.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260209-142149.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260219-143304.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260305-103721.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260312-140423.yaml | 5 -- .../v1.15/ENHANCEMENTS-20260313-162537.yaml | 5 -- .../v1.15/NEW FEATURES-20250926-164134.yaml | 5 -- .../v1.15/NEW FEATURES-20251205-171418.yaml | 5 -- .../v1.15/NEW FEATURES-20260108-105919.yaml | 5 -- .../v1.15/NEW FEATURES-20260212-104240.yaml | 5 -- .../v1.15/NEW FEATURES-20260212-181401.yaml | 5 -- .../v1.15/NEW FEATURES-20260226-175431.yaml | 5 -- .changes/v1.15/NOTES-20260303-115443.yaml | 5 -- .../v1.15/UPGRADE NOTES-20260107-154515.yaml | 5 -- .changes/v1.16/.gitkeep | 0 .changie.yaml | 2 +- .github/pull_request_template.md | 3 +- CHANGELOG.md | 72 +------------------ version/VERSION | 2 +- 60 files changed, 7 insertions(+), 338 deletions(-) rename .changes/{1.15.0.md => 1.16.0.md} (100%) delete mode 100644 .changes/v1.13/BUG FIXES-20251009-144645.yaml delete mode 100644 .changes/v1.13/BUG FIXES-20251103-112034.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20250924-110416.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20250926-113318.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251024-164434.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251029-175958.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251031-144915.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251103-112034.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251104-122322.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251124-150000.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251208-170259.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251209-130050.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20251209-230000.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20260108-114527.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20260116-101253.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20260123-103307.yaml delete mode 100644 .changes/v1.14/BUG FIXES-20260311-163804.yaml delete mode 100644 .changes/v1.14/BUGFIX-20250927-184134.yaml delete mode 100644 .changes/v1.14/ENHANCEMENTS-20250919-115253.yaml delete mode 100644 .changes/v1.14/ENHANCEMENTS-20250925-151237.yaml delete mode 100644 .changes/v1.14/ENHANCEMENTS-20251002-172626.yaml delete mode 100644 .changes/v1.14/ENHANCEMENTS-20251204-125848.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251024-042900.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251110-120921.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251112-033830.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251119-103000.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251121-183045.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251201-114950.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251213-120000.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20251223-184516.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20260105-170648.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20260115-103000.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20260210-113930.yaml delete mode 100644 .changes/v1.15/BUG FIXES-20260214-120000.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20250203-175807.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20251022-162909.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20251027-132238.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20251107-140221.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260113-130449.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260120-172831.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260209-142149.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260219-143304.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260305-103721.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260312-140423.yaml delete mode 100644 .changes/v1.15/ENHANCEMENTS-20260313-162537.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20250926-164134.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20251205-171418.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20260108-105919.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20260212-104240.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20260212-181401.yaml delete mode 100644 .changes/v1.15/NEW FEATURES-20260226-175431.yaml delete mode 100644 .changes/v1.15/NOTES-20260303-115443.yaml delete mode 100644 .changes/v1.15/UPGRADE NOTES-20260107-154515.yaml create mode 100644 .changes/v1.16/.gitkeep diff --git a/.changes/1.15.0.md b/.changes/1.16.0.md similarity index 100% rename from .changes/1.15.0.md rename to .changes/1.16.0.md diff --git a/.changes/previous-releases.md b/.changes/previous-releases.md index d3f9501009..36de9d3ed0 100644 --- a/.changes/previous-releases.md +++ b/.changes/previous-releases.md @@ -1,3 +1,4 @@ +- [v1.15](https://github.com/hashicorp/terraform/blob/v1.15/CHANGELOG.md) - [v1.14](https://github.com/hashicorp/terraform/blob/v1.14/CHANGELOG.md) - [v1.13](https://github.com/hashicorp/terraform/blob/v1.13/CHANGELOG.md) - [v1.12](https://github.com/hashicorp/terraform/blob/v1.12/CHANGELOG.md) diff --git a/.changes/v1.13/BUG FIXES-20251009-144645.yaml b/.changes/v1.13/BUG FIXES-20251009-144645.yaml deleted file mode 100644 index 2f96fe0e9c..0000000000 --- a/.changes/v1.13/BUG FIXES-20251009-144645.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Fix crash when showing a cloud plan without having a cloud backend -time: 2025-10-09T14:46:45.59398+02:00 -custom: - Issue: "37751" diff --git a/.changes/v1.13/BUG FIXES-20251103-112034.yaml b/.changes/v1.13/BUG FIXES-20251103-112034.yaml deleted file mode 100644 index abe076ad3b..0000000000 --- a/.changes/v1.13/BUG FIXES-20251103-112034.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Allow filesystem functions to return inconsistent results when evaluated within provider configuration -time: 2025-11-03T11:20:34.913068-05:00 -custom: - Issue: "37854" diff --git a/.changes/v1.14/BUG FIXES-20250924-110416.yaml b/.changes/v1.14/BUG FIXES-20250924-110416.yaml deleted file mode 100644 index 3ddb833d6e..0000000000 --- a/.changes/v1.14/BUG FIXES-20250924-110416.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'console and test: return explicit diagnostics when referencing resources that were not included in the most recent operation.' -time: 2025-09-24T11:04:16.860364+02:00 -custom: - Issue: "37663" diff --git a/.changes/v1.14/BUG FIXES-20250926-113318.yaml b/.changes/v1.14/BUG FIXES-20250926-113318.yaml deleted file mode 100644 index 3adfd48017..0000000000 --- a/.changes/v1.14/BUG FIXES-20250926-113318.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'query: generate unique resource identifiers for results of expanded list resources' -time: 2025-09-26T11:33:18.241184+02:00 -custom: - Issue: "37681" diff --git a/.changes/v1.14/BUG FIXES-20251024-164434.yaml b/.changes/v1.14/BUG FIXES-20251024-164434.yaml deleted file mode 100644 index c2fa4a88cf..0000000000 --- a/.changes/v1.14/BUG FIXES-20251024-164434.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'test: allow ephemeral outputs in root modules' -time: 2025-10-24T16:44:34.197847+02:00 -custom: - Issue: "37813" diff --git a/.changes/v1.14/BUG FIXES-20251029-175958.yaml b/.changes/v1.14/BUG FIXES-20251029-175958.yaml deleted file mode 100644 index e9f7d9d2ae..0000000000 --- a/.changes/v1.14/BUG FIXES-20251029-175958.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Combinations of replace_triggered_by and -replace could result in some instances not being replaced -time: 2025-10-29T17:59:58.326396-04:00 -custom: - Issue: "37833" diff --git a/.changes/v1.14/BUG FIXES-20251031-144915.yaml b/.changes/v1.14/BUG FIXES-20251031-144915.yaml deleted file mode 100644 index 8bc6c766e3..0000000000 --- a/.changes/v1.14/BUG FIXES-20251031-144915.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'providers lock: include providers required by terraform test' -time: 2025-10-31T14:49:15.121756+01:00 -custom: - Issue: "37851" diff --git a/.changes/v1.14/BUG FIXES-20251103-112034.yaml b/.changes/v1.14/BUG FIXES-20251103-112034.yaml deleted file mode 100644 index abe076ad3b..0000000000 --- a/.changes/v1.14/BUG FIXES-20251103-112034.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Allow filesystem functions to return inconsistent results when evaluated within provider configuration -time: 2025-11-03T11:20:34.913068-05:00 -custom: - Issue: "37854" diff --git a/.changes/v1.14/BUG FIXES-20251104-122322.yaml b/.changes/v1.14/BUG FIXES-20251104-122322.yaml deleted file mode 100644 index f8475db0aa..0000000000 --- a/.changes/v1.14/BUG FIXES-20251104-122322.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'query: improve error handling for missing identity schemas' -time: 2025-11-04T12:23:22.096828+01:00 -custom: - Issue: "37863" diff --git a/.changes/v1.14/BUG FIXES-20251124-150000.yaml b/.changes/v1.14/BUG FIXES-20251124-150000.yaml deleted file mode 100644 index 798c4744cc..0000000000 --- a/.changes/v1.14/BUG FIXES-20251124-150000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'actions: make after_create & after_update actions run after the resource has applied' -time: 2025-11-24T15:00:00.316597+01:00 -custom: - Issue: "37936" diff --git a/.changes/v1.14/BUG FIXES-20251208-170259.yaml b/.changes/v1.14/BUG FIXES-20251208-170259.yaml deleted file mode 100644 index 1f4f9dc708..0000000000 --- a/.changes/v1.14/BUG FIXES-20251208-170259.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: surface runtime issues with local values to user during plan' -time: 2025-12-08T17:02:59.971622+01:00 -custom: - Issue: "37980" diff --git a/.changes/v1.14/BUG FIXES-20251209-130050.yaml b/.changes/v1.14/BUG FIXES-20251209-130050.yaml deleted file mode 100644 index 97914871b9..0000000000 --- a/.changes/v1.14/BUG FIXES-20251209-130050.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: resource instance apply failures should not cause the resource instance state to be empty. -time: 2025-12-09T13:00:50.440436+01:00 -custom: - Issue: "37981" diff --git a/.changes/v1.14/BUG FIXES-20251209-230000.yaml b/.changes/v1.14/BUG FIXES-20251209-230000.yaml deleted file mode 100644 index d378f24610..0000000000 --- a/.changes/v1.14/BUG FIXES-20251209-230000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: change absolute paths in path.module/path.root to be relative, as documented' -time: 2025-12-09T23:00:00.316597+00:00 -custom: - Issue: "37982" diff --git a/.changes/v1.14/BUG FIXES-20260108-114527.yaml b/.changes/v1.14/BUG FIXES-20260108-114527.yaml deleted file mode 100644 index e5eae67efe..0000000000 --- a/.changes/v1.14/BUG FIXES-20260108-114527.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Fixes an issue where any warning diagnostics generated during terraform query execution failed to render in the cloud backend session -time: 2026-01-08T11:45:27.489784-08:00 -custom: - Issue: "38040" diff --git a/.changes/v1.14/BUG FIXES-20260116-101253.yaml b/.changes/v1.14/BUG FIXES-20260116-101253.yaml deleted file mode 100644 index 6846bf3aa6..0000000000 --- a/.changes/v1.14/BUG FIXES-20260116-101253.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'Fixed an issue where terraform stacks validate was failing to resolve relative paths for modules' -time: 2026-01-16T10:12:53.854705-05:00 -custom: - Issue: "38025" diff --git a/.changes/v1.14/BUG FIXES-20260123-103307.yaml b/.changes/v1.14/BUG FIXES-20260123-103307.yaml deleted file mode 100644 index 7eac4e1b7d..0000000000 --- a/.changes/v1.14/BUG FIXES-20260123-103307.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: actions in modules without instances failed the plan graph -time: 2026-01-23T10:33:07.244665+01:00 -custom: - Issue: "38089" diff --git a/.changes/v1.14/BUG FIXES-20260311-163804.yaml b/.changes/v1.14/BUG FIXES-20260311-163804.yaml deleted file mode 100644 index 5c0ae36b6f..0000000000 --- a/.changes/v1.14/BUG FIXES-20260311-163804.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Prevent crash in the display of relevant attributes after provider upgrades -time: 2026-03-11T16:38:04.50368-04:00 -custom: - Issue: "38264" diff --git a/.changes/v1.14/BUGFIX-20250927-184134.yaml b/.changes/v1.14/BUGFIX-20250927-184134.yaml deleted file mode 100644 index a6772d3afb..0000000000 --- a/.changes/v1.14/BUGFIX-20250927-184134.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUGFIX -body: The CLI now summarizes the number of actions invoked during `terraform apply`, matching the plan output. -time: 2025-09-27T18:41:34.771437+02:00 -custom: - Issue: "37689" diff --git a/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml b/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml deleted file mode 100644 index 6f521d0c01..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: '`terraform stacks` command support for `-help` flag' -time: 2025-09-19T11:52:53.923764-04:00 -custom: - Issue: "37645" diff --git a/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml b/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml deleted file mode 100644 index 6e6c3381c7..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "query: support offline validation of query files via -query flag in the validate command" -time: 2025-09-25T15:12:37.198573+02:00 -custom: - Issue: "37671" diff --git a/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml b/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml deleted file mode 100644 index 466b34fc14..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Updates to support the AWS European Sovereign Cloud -time: 2025-10-02T17:26:26.513708-04:00 -custom: - Issue: "37721" diff --git a/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml b/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml deleted file mode 100644 index 785f51cfb7..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Add component registry source resolution support to Terraform Stacks -time: 2025-12-04T12:58:48.622196-05:00 -custom: - Issue: "37888" diff --git a/.changes/v1.15/BUG FIXES-20251024-042900.yaml b/.changes/v1.15/BUG FIXES-20251024-042900.yaml deleted file mode 100644 index 989ac31a94..0000000000 --- a/.changes/v1.15/BUG FIXES-20251024-042900.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'testing: File-level error diagnostics are now included in JUnit XML skipped test elements, ensuring CI/CD pipelines can detect validation failures' -time: 2025-10-24T04:29:00.000000Z -custom: - Issue: "37801" diff --git a/.changes/v1.15/BUG FIXES-20251110-120921.yaml b/.changes/v1.15/BUG FIXES-20251110-120921.yaml deleted file mode 100644 index cc5ae0920b..0000000000 --- a/.changes/v1.15/BUG FIXES-20251110-120921.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: A refresh-only plan could result in a non-zero exit code with no changes -time: 2025-11-10T12:09:21.029489-05:00 -custom: - Issue: "37406" diff --git a/.changes/v1.15/BUG FIXES-20251112-033830.yaml b/.changes/v1.15/BUG FIXES-20251112-033830.yaml deleted file mode 100644 index 1cafafd43a..0000000000 --- a/.changes/v1.15/BUG FIXES-20251112-033830.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'cli: Fixed crash in `terraform show -json` when plan contains ephemeral resources with preconditions or postconditions' -time: 2025-11-12T03:38:30.000000-08:00 -custom: - Issue: "37834" diff --git a/.changes/v1.15/BUG FIXES-20251119-103000.yaml b/.changes/v1.15/BUG FIXES-20251119-103000.yaml deleted file mode 100644 index 28eba48641..0000000000 --- a/.changes/v1.15/BUG FIXES-20251119-103000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'cli: Fixed `terraform init -json` to properly format all backend configuration messages as JSON instead of plain text' -time: 2025-11-19T10:30:00.000000Z -custom: - Issue: "37911" diff --git a/.changes/v1.15/BUG FIXES-20251121-183045.yaml b/.changes/v1.15/BUG FIXES-20251121-183045.yaml deleted file mode 100644 index fca86c0bf7..0000000000 --- a/.changes/v1.15/BUG FIXES-20251121-183045.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: '`state show`: The `state show` command will now explicitly fail and return code 1 when it fails to render the named resources state' -time: 2025-11-21T18:30:45.571448Z -custom: - Issue: "37933" diff --git a/.changes/v1.15/BUG FIXES-20251201-114950.yaml b/.changes/v1.15/BUG FIXES-20251201-114950.yaml deleted file mode 100644 index cfa8ded58b..0000000000 --- a/.changes/v1.15/BUG FIXES-20251201-114950.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace' -time: 2025-12-01T11:49:50.360928Z -custom: - Issue: "37954" diff --git a/.changes/v1.15/BUG FIXES-20251213-120000.yaml b/.changes/v1.15/BUG FIXES-20251213-120000.yaml deleted file mode 100644 index 3a9f434753..0000000000 --- a/.changes/v1.15/BUG FIXES-20251213-120000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource' -time: 2025-12-13T12:00:00.000000Z -custom: - Issue: "36740" diff --git a/.changes/v1.15/BUG FIXES-20251223-184516.yaml b/.changes/v1.15/BUG FIXES-20251223-184516.yaml deleted file mode 100644 index e3c65982a5..0000000000 --- a/.changes/v1.15/BUG FIXES-20251223-184516.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'backend: Fix nil pointer dereference crash during `terraform init` when the destination backend returns an error' -time: 2025-12-23T18:45:16.000000Z -custom: - Issue: "38027" diff --git a/.changes/v1.15/BUG FIXES-20260105-170648.yaml b/.changes/v1.15/BUG FIXES-20260105-170648.yaml deleted file mode 100644 index d0764cfb66..0000000000 --- a/.changes/v1.15/BUG FIXES-20260105-170648.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: send progress events if the plan fails for better UI integration' -time: 2026-01-05T17:06:48.252069+01:00 -custom: - Issue: "38039" diff --git a/.changes/v1.15/BUG FIXES-20260115-103000.yaml b/.changes/v1.15/BUG FIXES-20260115-103000.yaml deleted file mode 100644 index 61fec4c20f..0000000000 --- a/.changes/v1.15/BUG FIXES-20260115-103000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: component instances should report no-op plan/apply. This solves a UI inconsistency with convergence destroy plans ' -time: 2026-01-15T10:30:00.72402+01:00 -custom: - Issue: "38049" diff --git a/.changes/v1.15/BUG FIXES-20260210-113930.yaml b/.changes/v1.15/BUG FIXES-20260210-113930.yaml deleted file mode 100644 index d42670a8e5..0000000000 --- a/.changes/v1.15/BUG FIXES-20260210-113930.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'backend/http: Return conflicting lock info from HTTP backend instead of the lock that failed to be taken' -time: 2026-02-10T11:39:30.00000-08:00 -custom: - Issue: "38144" \ No newline at end of file diff --git a/.changes/v1.15/BUG FIXES-20260214-120000.yaml b/.changes/v1.15/BUG FIXES-20260214-120000.yaml deleted file mode 100644 index f4f4a12a54..0000000000 --- a/.changes/v1.15/BUG FIXES-20260214-120000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations.' -time: 2026-02-14T12:00:00.000000+00:00 -custom: - Issue: "38181" diff --git a/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml b/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml deleted file mode 100644 index 53bad4ecb5..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20250203-175807.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: 'config: `output` blocks now can have an explicit type constraints' -time: 2025-02-03T17:58:07.110141+01:00 -custom: - Issue: "36411" diff --git a/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml b/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml deleted file mode 100644 index 5e78468cbf..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: 'ssh-based provisioner (file + remote-exec): Re-enable support for PowerShell' -time: 2025-10-22T16:29:09.342697+01:00 -custom: - Issue: "37794" diff --git a/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml b/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml deleted file mode 100644 index 7c4d0af699..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: terraform init log timestamps include millisecond precision -time: 2025-10-27T13:22:38.714891768-05:00 -custom: - Issue: "37818" diff --git a/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml b/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml deleted file mode 100644 index d25c1a2695..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "init: skip dependencies declared in development override. This allows you to use `terraform init` with developer overrides and install dependencies that are not declared in the override file." -time: 2025-11-07T14:02:21.847382+01:00 -custom: - Issue: "37884" diff --git a/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml b/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml deleted file mode 100644 index 25b035e502..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "Terraform Test: Allow functions within mock blocks" -time: 2026-01-13T13:04:49.034917+01:00 -custom: - Issue: "34672" diff --git a/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml b/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml deleted file mode 100644 index a3d9aebb2f..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: improve detection of deprecated resource attributes / blocks -time: 2026-01-20T17:28:31.861321+01:00 -custom: - Issue: "38077" diff --git a/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml b/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml deleted file mode 100644 index f933412f84..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Deprecation messages providers set on resources / blocks / attributes are now part of the deprecation warning -time: 2026-02-09T14:21:49.076863+01:00 -custom: - Issue: "38135" diff --git a/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml b/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml deleted file mode 100644 index 7cb08cebaa..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Include which attribute paths are marked as sensitive in list_start JSON logs -time: 2026-02-19T14:33:04.832615-05:00 -custom: - Issue: "38197" diff --git a/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml b/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml deleted file mode 100644 index af43f8ea8e..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Add input variable validation for Stacks -time: 2026-03-05T10:37:21.047704-05:00 -custom: - Issue: "38240" diff --git a/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml b/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml deleted file mode 100644 index f99ad00b16..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260312-140423.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: When comparing a container value to null, only top level marks are now considered for the result. -time: 2026-03-12T14:04:23.868546-04:00 -custom: - Issue: "38270" diff --git a/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml b/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml deleted file mode 100644 index bf9807ddca..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260313-162537.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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" diff --git a/.changes/v1.15/NEW FEATURES-20250926-164134.yaml b/.changes/v1.15/NEW FEATURES-20250926-164134.yaml deleted file mode 100644 index b55e44da38..0000000000 --- a/.changes/v1.15/NEW FEATURES-20250926-164134.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: We now produce builds for Windows ARM64 -time: 2025-09-26T16:41:34.771437+02:00 -custom: - Issue: "32719" diff --git a/.changes/v1.15/NEW FEATURES-20251205-171418.yaml b/.changes/v1.15/NEW FEATURES-20251205-171418.yaml deleted file mode 100644 index 4eceeadec3..0000000000 --- a/.changes/v1.15/NEW FEATURES-20251205-171418.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: You can set a `deprecated` attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output. -time: 2025-12-05T17:14:18.623477+01:00 -custom: - Issue: "38001" diff --git a/.changes/v1.15/NEW FEATURES-20260108-105919.yaml b/.changes/v1.15/NEW FEATURES-20260108-105919.yaml deleted file mode 100644 index e7e9252f12..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260108-105919.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: 'backend/s3: Support authentication via `aws login`' -time: 2026-01-08T10:59:19.249387-05:00 -custom: - Issue: "37976" diff --git a/.changes/v1.15/NEW FEATURES-20260212-104240.yaml b/.changes/v1.15/NEW FEATURES-20260212-104240.yaml deleted file mode 100644 index 1567580d89..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260212-104240.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: "validate: The validate command now checks the `backend` block. This ensures the backend type exists, that all required attributes are present, and that the backend's own validation logic passes." -time: 2026-02-12T10:42:40.333849Z -custom: - Issue: "38021" diff --git a/.changes/v1.15/NEW FEATURES-20260212-181401.yaml b/.changes/v1.15/NEW FEATURES-20260212-181401.yaml deleted file mode 100644 index 770c035a1e..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260212-181401.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: '`convert` function, which allows for precise inline type conversions' -time: 2026-02-12T18:14:01.356478-05:00 -custom: - Issue: "38160" diff --git a/.changes/v1.15/NEW FEATURES-20260226-175431.yaml b/.changes/v1.15/NEW FEATURES-20260226-175431.yaml deleted file mode 100644 index 73de4ec6c7..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260226-175431.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: Terraform now supports variables and locals in module source and version attributes -time: 2026-02-26T17:54:31.157412+01:00 -custom: - Issue: "38217" diff --git a/.changes/v1.15/NOTES-20260303-115443.yaml b/.changes/v1.15/NOTES-20260303-115443.yaml deleted file mode 100644 index 2b8be19f29..0000000000 --- a/.changes/v1.15/NOTES-20260303-115443.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NOTES -body: 'command/init: Provider installation was refactored to enable future enhancements in the area. This results in different order of operations during init and 2 new log messages replacing one (`initializing_provider_plugin_message`). The change should not have any end-user impact aside from the `init` command output.' -time: 2026-03-03T11:54:43.732353Z -custom: - Issue: "38227" diff --git a/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml b/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml deleted file mode 100644 index 31246c92cb..0000000000 --- a/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: UPGRADE NOTES -body: 'backend/s3: The `AWS_USE_FIPS_ENDPOINT` and `AWS_USE_DUALSTACK_ENDPOINT` environment variables now only respect `true` or `false` values, aligning with the AWS SDK for Go. This replaces the previous behavior which treated any non-empty value as `true`.' -time: 2026-01-07T15:45:15.958679-05:00 -custom: - Issue: "37601" diff --git a/.changes/v1.16/.gitkeep b/.changes/v1.16/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.changie.yaml b/.changie.yaml index 4ed75a510d..f80f100db5 100644 --- a/.changie.yaml +++ b/.changie.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: BUSL-1.1 changesDir: .changes -unreleasedDir: v1.15 +unreleasedDir: v1.16 versionFooterPath: version_footer.tpl.md changelogPath: CHANGELOG.md versionExt: md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0194cc47c3..15397f4cfc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,9 +31,10 @@ label to enable the backport bot. --> -1.15.x +1.16.x + ## Rollback Plan - [ ] If a change needs to be reverted, we will roll out an update to the code within 7 days. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3400550920..8e00c4f5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,73 +1,4 @@ -## 1.15.0 (Unreleased) - - -NEW FEATURES: - -* We now produce builds for Windows ARM64 ([#32719](https://github.com/hashicorp/terraform/issues/32719)) - -* You can set a `deprecated` attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output. ([#38001](https://github.com/hashicorp/terraform/issues/38001)) - -* backend/s3: Support authentication via `aws login` ([#37976](https://github.com/hashicorp/terraform/issues/37976)) - -* validate: The validate command now checks the `backend` block. This ensures the backend type exists, that all required attributes are present, and that the backend's own validation logic passes. ([#38021](https://github.com/hashicorp/terraform/issues/38021)) - -* `convert` function, which allows for precise inline type conversions ([#38160](https://github.com/hashicorp/terraform/issues/38160)) - -* Terraform now supports variables and locals in module source and version attributes ([#38217](https://github.com/hashicorp/terraform/issues/38217)) - - -ENHANCEMENTS: - -* ssh-based provisioner (file + remote-exec): Re-enable support for PowerShell ([#37794](https://github.com/hashicorp/terraform/issues/37794)) - -* terraform init log timestamps include millisecond precision ([#37818](https://github.com/hashicorp/terraform/issues/37818)) - -* init: skip dependencies declared in development override. This allows you to use `terraform init` with developer overrides and install dependencies that are not declared in the override file. ([#37884](https://github.com/hashicorp/terraform/issues/37884)) - -* Terraform Test: Allow functions within mock blocks ([#34672](https://github.com/hashicorp/terraform/issues/34672)) - -* improve detection of deprecated resource attributes / blocks ([#38077](https://github.com/hashicorp/terraform/issues/38077)) - -* Deprecation messages providers set on resources / blocks / attributes are now part of the deprecation warning ([#38135](https://github.com/hashicorp/terraform/issues/38135)) - -* Include which attribute paths are marked as sensitive in list_start JSON logs ([#38197](https://github.com/hashicorp/terraform/issues/38197)) - - -BUG FIXES: - -* testing: File-level error diagnostics are now included in JUnit XML skipped test elements, ensuring CI/CD pipelines can detect validation failures ([#37801](https://github.com/hashicorp/terraform/issues/37801)) - -* A refresh-only plan could result in a non-zero exit code with no changes ([#37406](https://github.com/hashicorp/terraform/issues/37406)) - -* cli: Fixed crash in `terraform show -json` when plan contains ephemeral resources with preconditions or postconditions ([#37834](https://github.com/hashicorp/terraform/issues/37834)) - -* cli: Fixed `terraform init -json` to properly format all backend configuration messages as JSON instead of plain text ([#37911](https://github.com/hashicorp/terraform/issues/37911)) - -* `state show`: The `state show` command will now explicitly fail and return code 1 when it fails to render the named resources state ([#37933](https://github.com/hashicorp/terraform/issues/37933)) - -* apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace ([#37954](https://github.com/hashicorp/terraform/issues/37954)) - -* lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource ([#36740](https://github.com/hashicorp/terraform/issues/36740)) - -* backend: Fix nil pointer dereference crash during `terraform init` when the destination backend returns an error ([#38027](https://github.com/hashicorp/terraform/issues/38027)) - -* stacks: send progress events if the plan fails for better UI integration ([#38039](https://github.com/hashicorp/terraform/issues/38039)) - -* stacks: component instances should report no-op plan/apply. This solves a UI inconsistency with convergence destroy plans ([#38049](https://github.com/hashicorp/terraform/issues/38049)) - -* backend/http: Return conflicting lock info from HTTP backend instead of the lock that failed to be taken ([#38144](https://github.com/hashicorp/terraform/issues/38144)) - -* states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations. ([#38181](https://github.com/hashicorp/terraform/issues/38181)) - - -NOTES: - -* command/init: Provider installation was refactored to enable future enhancements in the area. This results in different order of operations during init and 2 new log messages replacing one (`initializing_provider_plugin_message`). The change should not have any end-user impact aside from the `init` command output. ([#38227](https://github.com/hashicorp/terraform/issues/38227)) - - -UPGRADE NOTES: - -* backend/s3: The `AWS_USE_FIPS_ENDPOINT` and `AWS_USE_DUALSTACK_ENDPOINT` environment variables now only respect `true` or `false` values, aligning with the AWS SDK for Go. This replaces the previous behavior which treated any non-empty value as `true`. ([#37601](https://github.com/hashicorp/terraform/issues/37601)) +## 1.16.0 (Unreleased) EXPERIMENTS: @@ -84,6 +15,7 @@ Experiments are only enabled in alpha releases of Terraform CLI. The following f For information on prior major and minor releases, refer to their changelogs: +- [v1.15](https://github.com/hashicorp/terraform/blob/v1.15/CHANGELOG.md) - [v1.14](https://github.com/hashicorp/terraform/blob/v1.14/CHANGELOG.md) - [v1.13](https://github.com/hashicorp/terraform/blob/v1.13/CHANGELOG.md) - [v1.12](https://github.com/hashicorp/terraform/blob/v1.12/CHANGELOG.md) diff --git a/version/VERSION b/version/VERSION index 9a4866bbce..1f0d2f3351 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.0-dev +1.16.0-dev From fd6b53b4f1d1db9ce89bed95a333b5cc6081dd6f Mon Sep 17 00:00:00 2001 From: Lakshay Oza <86004857+ozalakshay@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:24:56 +0530 Subject: [PATCH 133/136] Fix grammar in`textdecodebase64` error message (#38289) * Fix grammar in base64 error message * Add changelog entry for base64 error message fix * Remove changelog entry --- internal/lang/funcs/encoding.go | 2 +- internal/lang/funcs/encoding_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/lang/funcs/encoding.go b/internal/lang/funcs/encoding.go index 886be8fc95..05f661d878 100644 --- a/internal/lang/funcs/encoding.go +++ b/internal/lang/funcs/encoding.go @@ -137,7 +137,7 @@ var TextDecodeBase64Func = function.New(&function.Spec{ if err != nil { switch err := err.(type) { case base64.CorruptInputError: - return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value is has an invalid base64 symbol at offset %d", int(err)) + return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value has an invalid base64 symbol at offset %d", int(err)) default: return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid source string: %w", err) } diff --git a/internal/lang/funcs/encoding_test.go b/internal/lang/funcs/encoding_test.go index 6ff593f558..cf0bc8c892 100644 --- a/internal/lang/funcs/encoding_test.go +++ b/internal/lang/funcs/encoding_test.go @@ -322,7 +322,7 @@ func TestBase64TextDecode(t *testing.T) { cty.StringVal(""), cty.StringVal("cp437"), cty.UnknownVal(cty.String).RefineNotNull(), - `the given value is has an invalid base64 symbol at offset 0`, + `the given value has an invalid base64 symbol at offset 0`, }, { cty.StringVal("gQ=="), // this is 0x81, which is not defined in windows-1250 From 2dbb7d9c058f910f769c68fca135d447410a59dc Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:47:29 +0000 Subject: [PATCH 134/136] PSS: Remove automatic creation of the default workspace's state file during `init` (#38281) * refactor: Stop Terraform creating the default workspace during init when using PSS This is part of reconciling how in the past backends always reported that the default backend existed, even when it didn't. We want state stores to report reality only, so we need to let Terraform handle the discrepancy. Prior to this commit we handled it by making reality match the old lie that the default workspace always exists. After this commit we're just embracing Terraform working with truthful information. --- internal/command/arguments/init.go | 30 ----- internal/command/arguments/init_test.go | 61 +++------- .../e2etest/pluggable_state_store_test.go | 29 ++--- internal/command/e2etest/primary_test.go | 27 +---- internal/command/init.go | 21 +--- internal/command/init_test.go | 109 +----------------- internal/command/meta_backend.go | 89 ++++++-------- internal/command/meta_backend_errors.go | 17 --- internal/command/workspace_command_test.go | 25 ++-- 9 files changed, 92 insertions(+), 316 deletions(-) diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go index e52d18325e..94df38df07 100644 --- a/internal/command/arguments/init.go +++ b/internal/command/arguments/init.go @@ -79,10 +79,6 @@ type Init struct { // TODO(SarahFrench/radeksimko): Remove this once the feature is no longer // experimental EnablePssExperiment bool - - // CreateDefaultWorkspace indicates whether the default workspace should be created by - // Terraform when initializing a state store for the first time. - CreateDefaultWorkspace bool } // ParseInit processes CLI arguments, returning an Init value and errors. @@ -116,7 +112,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti cmdFlags.BoolVar(&init.Json, "json", false, "json") cmdFlags.Var(&init.BackendConfig, "backend-config", "") cmdFlags.Var(&init.PluginPath, "plugin-dir", "plugin directory") - cmdFlags.BoolVar(&init.CreateDefaultWorkspace, "create-default-workspace", true, "when -input=false, use this flag to block creation of the default workspace") // Used for enabling experimental code that's invoked before configuration is parsed. cmdFlags.BoolVar(&init.EnablePssExperiment, "enable-pluggable-state-storage-experiment", false, "Enable the pluggable state storage experiment") @@ -133,13 +128,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti init.EnablePssExperiment = true } - if v := os.Getenv("TF_SKIP_CREATE_DEFAULT_WORKSPACE"); v != "" { - // If TF_SKIP_CREATE_DEFAULT_WORKSPACE is set it will override - // a -create-default-workspace=true flag that's set explicitly, - // as that's indistinguishable from the default value being used. - init.CreateDefaultWorkspace = false - } - if !experimentsEnabled { // If experiments aren't enabled then these flags should not be used. if init.EnablePssExperiment { @@ -149,24 +137,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti "Terraform cannot use the -enable-pluggable-state-storage-experiment flag (or TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable) unless experiments are enabled.", )) } - if !init.CreateDefaultWorkspace { - // Can only be set to false by using the flag - // and we cannot identify if -create-default-workspace=true is set explicitly. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Cannot use -create-default-workspace flag without experiments enabled", - "Terraform cannot use the -create-default-workspace flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless experiments are enabled.", - )) - } - } else { - // Errors using flags despite experiments being enabled. - if !init.CreateDefaultWorkspace && !init.EnablePssExperiment { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - "Terraform cannot use the -create-default-workspace=false flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless you also supply the -enable-pluggable-state-storage-experiment flag (or set the TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable).", - )) - } } if init.MigrateState && init.Json { diff --git a/internal/command/arguments/init_test.go b/internal/command/arguments/init_test.go index e6df4a6faa..7fdc96fb9e 100644 --- a/internal/command/arguments/init_test.go +++ b/internal/command/arguments/init_test.go @@ -40,19 +40,20 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - CompactWarnings: false, - TargetFlags: nil, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: true, + CompactWarnings: false, + TargetFlags: nil, }, }, "setting multiple options": { - []string{"-backend=false", "-force-copy=true", + []string{ + "-backend=false", "-force-copy=true", "-from-module=./main-dir", "-json", "-get=false", "-lock=false", "-lock-timeout=10s", "-reconfigure=true", "-upgrade=true", "-lockfile=readonly", "-compact-warnings=true", - "-ignore-remote-version=true", "-test-directory=./test-dir"}, + "-ignore-remote-version=true", "-test-directory=./test-dir", + }, &Init{ FromModule: "./main-dir", Lockfile: "readonly", @@ -73,12 +74,11 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - Args: []string{}, - CompactWarnings: true, - TargetFlags: nil, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: true, + Args: []string{}, + CompactWarnings: true, + TargetFlags: nil, }, }, "with cloud option": { @@ -103,12 +103,11 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &[]FlagNameValue{{Name: "-backend-config", Value: "backend.config"}}, }, - Vars: &Vars{}, - InputEnabled: false, - Args: []string{}, - CompactWarnings: false, - TargetFlags: []string{"foo_bar.baz"}, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: []string{"foo_bar.baz"}, }, }, } @@ -194,30 +193,6 @@ func TestParseInit_experimentalFlags(t *testing.T) { experimentsEnabled: false, wantErr: "Cannot use -enable-pluggable-state-storage-experiment flag without experiments enabled", }, - "error: -create-default-workspace=false and experiments are disabled": { - args: []string{"-create-default-workspace=false"}, - experimentsEnabled: false, - wantErr: "Cannot use -create-default-workspace flag without experiments enabled", - }, - "error: TF_SKIP_CREATE_DEFAULT_WORKSPACE is set and experiments are disabled": { - envs: map[string]string{ - "TF_SKIP_CREATE_DEFAULT_WORKSPACE": "1", - }, - experimentsEnabled: false, - wantErr: "Cannot use -create-default-workspace flag without experiments enabled", - }, - "error: -create-default-workspace=false used without -enable-pluggable-state-storage-experiment, while experiments are enabled": { - args: []string{"-create-default-workspace=false"}, - experimentsEnabled: true, - wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - }, - "error: TF_SKIP_CREATE_DEFAULT_WORKSPACE used without -enable-pluggable-state-storage-experiment, while experiments are enabled": { - envs: map[string]string{ - "TF_SKIP_CREATE_DEFAULT_WORKSPACE": "1", - }, - experimentsEnabled: true, - wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - }, } for name, tc := range testCases { diff --git a/internal/command/e2etest/pluggable_state_store_test.go b/internal/command/e2etest/pluggable_state_store_test.go index 66865804c7..208a8001fb 100644 --- a/internal/command/e2etest/pluggable_state_store_test.go +++ b/internal/command/e2etest/pluggable_state_store_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -114,9 +115,6 @@ func TestPrimary_stateStore_unmanaged_separatePlan(t *testing.T) { if !provider.ReadStateBytesCalled() { t.Error("ReadStateBytes not called on un-managed provider") } - if !provider.WriteStateBytesCalled() { - t.Error("WriteStateBytes not called on un-managed provider") - } provider.ResetReadStateBytesCalled() provider.ResetWriteStateBytesCalled() @@ -211,12 +209,9 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) - if err != nil { - t.Fatalf("failed to open default workspace's state file: %s", err) - } - if fi.Size() == 0 { - t.Fatal("default workspace's state file should not have size 0 bytes") + _, err = os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) + if !errors.Is(err, os.ErrNotExist) { + t.Fatal("expected default workspace's state file to not exist, but it exists") } //// Create Workspace: terraform workspace new @@ -229,7 +224,7 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { if !strings.Contains(stdout, expectedMsg) { t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) } - fi, err = os.Stat(path.Join(tf.WorkDir(), workspaceDirName, newWorkspace, "terraform.tfstate")) + fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, newWorkspace, "terraform.tfstate")) if err != nil { t.Fatalf("failed to open %s workspace's state file: %s", newWorkspace, err) } @@ -248,13 +243,13 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { //// Select Workspace: terraform workspace select selectedWorkspace := "default" - stdout, stderr, err = tf.Run("workspace", "select", selectedWorkspace, "-no-color") + stdout, stderr, err = tf.Run("workspace", "select", "-or-create", selectedWorkspace, "-no-color") if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - expectedMsg = fmt.Sprintf("Switched to workspace %q.", selectedWorkspace) + expectedMsg = fmt.Sprintf("Created and switched to workspace %q!", selectedWorkspace) if !strings.Contains(stdout, expectedMsg) { - t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + t.Errorf("unexpected output, expected %s, but got:\n%s", expectedMsg, stdout) } //// Show Workspace: terraform workspace show @@ -640,13 +635,7 @@ func TestPrimary_stateStore_providerCmds(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) - if err != nil { - t.Fatalf("failed to open default workspace's state file: %s", err) - } - if fi.Size() == 0 { - t.Fatal("default workspace's state file should not have size 0 bytes") - } + // Note: The default state was already created earlier in the test //// Providers: `terraform providers` stdout, stderr, err := tf.Run("providers", "-no-color") diff --git a/internal/command/e2etest/primary_test.go b/internal/command/e2etest/primary_test.go index 583691c110..6720305189 100644 --- a/internal/command/e2etest/primary_test.go +++ b/internal/command/e2etest/primary_test.go @@ -146,7 +146,6 @@ func TestPrimarySeparatePlan(t *testing.T) { if len(stateResources) != 0 { t.Errorf("wrong resources in state after destroy; want none, but still have:%s", spew.Sdump(stateResources)) } - } func TestPrimaryChdirOption(t *testing.T) { @@ -236,7 +235,6 @@ func TestPrimaryChdirOption(t *testing.T) { } func TestPrimary_stateStore(t *testing.T) { - if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't // currently execute this test which depends on being able to build @@ -270,20 +268,16 @@ func TestPrimary_stateStore(t *testing.T) { } //// INIT - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN // No separate plan step; this test lets the apply make a plan. //// APPLY - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color") if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } @@ -315,7 +309,6 @@ func TestPrimary_stateStore(t *testing.T) { } func TestPrimary_stateStore_planFile(t *testing.T) { - if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't // currently execute this test which depends on being able to build @@ -348,15 +341,11 @@ func TestPrimary_stateStore_planFile(t *testing.T) { } //// INIT - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN planFile := "testplan" _, stderr, err = tf.Run("plan", "-out="+planFile, "-no-color") @@ -365,7 +354,7 @@ func TestPrimary_stateStore_planFile(t *testing.T) { } //// APPLY - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color", planFile) + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color", planFile) if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } @@ -432,15 +421,11 @@ func TestPrimary_stateStore_inMem(t *testing.T) { // // Note - the inmem PSS implementation means that the default workspace state created during init // is lost as soon as the command completes. - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN // No separate plan step; this test lets the apply make a plan. @@ -448,7 +433,7 @@ func TestPrimary_stateStore_inMem(t *testing.T) { // // Note - the inmem PSS implementation means that writing to the default workspace during apply // is creating the default state file for the first time. - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color") if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } diff --git a/internal/command/init.go b/internal/command/init.go index 6bcb08c5ff..7033efba00 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -232,12 +232,11 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ini } opts = &BackendOpts{ - StateStoreConfig: root.StateStore, - Locks: configLocks, - CreateDefaultWorkspace: initArgs.CreateDefaultWorkspace, - ConfigOverride: configOverride, - Init: true, - ViewType: initArgs.ViewType, + StateStoreConfig: root.StateStore, + Locks: configLocks, + ConfigOverride: configOverride, + Init: true, + ViewType: initArgs.ViewType, } case root.Backend != nil: @@ -561,7 +560,6 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // The calling code is expected to provide the previous locks (if any) and the two sets of locks determined from // configuration and state data. func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLocks *depsfile.Locks, flagLockfile string, view views.Init) (output bool, diags tfdiags.Diagnostics) { - // Get the combination of config and state locks newLocks := c.mergeLockedDependencies(configLocks, stateLocks) @@ -631,7 +629,6 @@ func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLo // when a specific type of event occurs during provider installation. // The calling code needs to provide a tfdiags.Diagnostics collection, so that provider installation code returns diags to the calling code using closures func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags *tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { - // Because we're currently just streaming a series of events sequentially // into the terminal, we're showing only a subset of the events to keep // things relatively concise. Later it'd be nice to have a progress UI @@ -758,7 +755,6 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) } - }, QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { displayWarnings := make([]string, len(warnings)) @@ -1151,13 +1147,6 @@ Options: -enable-pluggable-state-storage-experiment [EXPERIMENTAL] A flag to enable an alternative init command that allows use of pluggable state storage. Only usable with experiments enabled. - - -create-default-workspace [EXPERIMENTAL] - This flag must be used alongside the -enable-pluggable-state-storage- - experiment flag with experiments enabled. This flag's value defaults - to true, which allows the default workspace to be created if it does - not exist. Use -create-default-workspace=false to disable this behavior. - ` return strings.TrimSpace(helpText) } diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 5216dcfb1e..bda5709cca 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3494,7 +3494,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", - "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } for _, expected := range expectedOutputs { @@ -3504,7 +3503,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("the init command creates a backend state file, and creates the default workspace by default", func(t *testing.T) { + t.Run("the init command creates a backend state file, and the default workspace is not made by default", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() testCopyDir(t, testFixturePath("init-with-state-store"), td) @@ -3547,7 +3546,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", - "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } for _, expected := range expectedOutputs { @@ -3556,9 +3554,9 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } } - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { - t.Fatal("expected the default workspace to be created during init, but it is missing") + // Assert the default workspace was not created + if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { + t.Fatal("expected the default workspace to not be created during init, but it exists") } // Assert contents of the backend state file @@ -3591,105 +3589,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("an init command with the flag -create-default-workspace=false will not make the default workspace by default", func(t *testing.T) { - // Create a temporary, uninitialized working directory with configuration including a state store - td := t.TempDir() - testCopyDir(t, testFixturePath("init-with-state-store"), td) - t.Chdir(td) - - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, - }) - defer close() - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - Ui: ui, - View: view, - AllowExperimentalFeatures: true, - testingOverrides: &testingOverrides{ - Providers: map[addrs.Provider]providers.Factory{ - mockProviderAddress: providers.FactoryFixed(mockProvider), - }, - }, - ProviderSource: providerSource, - }, - } - - args := []string{"-enable-pluggable-state-storage-experiment=true", "-create-default-workspace=false"} - code := c.Run(args) - testOutput := done(t) - if code != 0 { - t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) - } - - // Check output - output := testOutput.All() - expectedOutput := `Terraform has been configured to skip creation of the default workspace` - if !strings.Contains(output, expectedOutput) { - t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) - } - - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { - t.Fatal("expected Terraform to skip creating the default workspace, but it has been created") - } - }) - - t.Run("an init command with TF_SKIP_CREATE_DEFAULT_WORKSPACE set will not make the default workspace by default", func(t *testing.T) { - // Create a temporary, uninitialized working directory with configuration including a state store - td := t.TempDir() - testCopyDir(t, testFixturePath("init-with-state-store"), td) - t.Chdir(td) - - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, - }) - defer close() - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - Ui: ui, - View: view, - AllowExperimentalFeatures: true, - testingOverrides: &testingOverrides{ - Providers: map[addrs.Provider]providers.Factory{ - mockProviderAddress: providers.FactoryFixed(mockProvider), - }, - }, - ProviderSource: providerSource, - }, - } - - t.Setenv("TF_SKIP_CREATE_DEFAULT_WORKSPACE", "1") // any value - args := []string{"-enable-pluggable-state-storage-experiment=true"} - code := c.Run(args) - testOutput := done(t) - if code != 0 { - t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) - } - - // Check output - output := testOutput.All() - expectedOutput := `Terraform has been configured to skip creation of the default workspace` - if !strings.Contains(output, expectedOutput) { - t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) - } - - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { - t.Fatal("expected Terraform to skip creating the default workspace, but it has been created") - } - }) - // This scenario would be rare, but protecting against it is easy and avoids assumptions. t.Run("if a custom workspace is selected but no workspaces exist an error is returned", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index f055188088..710558b800 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -44,7 +44,6 @@ import ( "github.com/hashicorp/terraform/internal/getproviders/reattach" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" - "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -83,10 +82,6 @@ type BackendOpts struct { // ViewType will set console output format for the // initialization operation (JSON or human-readable). ViewType arguments.ViewType - - // CreateDefaultWorkspace signifies whether the operations backend should create - // the default workspace or not - CreateDefaultWorkspace bool } // BackendWithRemoteTerraformVersion is a shared interface between the 'remote' and 'cloud' backends @@ -1259,8 +1254,32 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // Verify that selected workspace exist. Otherwise prompt user to create one if opts.Init && savedStateStore != nil { if err := m.selectWorkspace(savedStateStore); err != nil { - diags = diags.Append(err) - return nil, diags + if errors.Is(err, &errBackendNoExistingWorkspaces{}) { + // We tolerate no workspaces if we're using a state store and + // the default workspace is selected. + ws, err := m.Workspace() + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to check current workspace: %w", err)) + return nil, diags + } + if ws == backend.DefaultStateName { + // If the default workspace is selected, no workspaces existing _may_ be expected. + // It's valid for the default workspace's state to not be created until the first apply takes place. + // However, it could be that the user is configuring their working directory for the first time but + // they expect pre-existing state to be in the store from previous actions. In that case, the user + // should realise their mistake once they generate a plan. + // + // So here, we will just ignore the error. + } else { + // User needs to run a `terraform workspace new` command to create the missing custom workspace. + diags = diags.Append(err) + return nil, diags + } + } else { + // Report all other errors + diags = diags.Append(err) + return nil, diags + } } } @@ -2457,11 +2476,8 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend // Verify that selected workspace exists in the state store. if opts.Init && b != nil { - err := m.selectWorkspace(b) - if err != nil { + if err := m.selectWorkspace(b); err != nil { if errors.Is(err, &errBackendNoExistingWorkspaces{}) { - // If there are no workspaces, Terraform either needs to create the default workspace here - // or instruct the user to run a `terraform workspace new` command. ws, err := m.Workspace() if err != nil { diags = diags.Append(fmt.Errorf("Failed to check current workspace: %w", err)) @@ -2469,21 +2485,13 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend } if ws == backend.DefaultStateName { - // Users control if the default workspace is created through the -create-default-workspace flag (defaults to true) - if opts.CreateDefaultWorkspace { - diags = diags.Append(m.createDefaultWorkspace(c, b)) - if !diags.HasErrors() { - // Report workspace creation to the view - view := views.NewInit(vt, m.View) - view.Output(views.DefaultWorkspaceCreatedMessage) - } - } else { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "The default workspace does not exist", - Detail: "Terraform has been configured to skip creation of the default workspace in the state store. To create it, either remove the `-create-default-workspace=false` flag and re-run the 'init' command, or create it using a 'workspace new' command", - }) - } + // If the default workspace is selected, no workspaces existing _may_ be expected. + // It's valid for the default workspace's state to not be created until the first apply takes place. + // However, it could be that the user is configuring their working directory for the first time but + // they expect pre-existing state to be in the store from previous actions. In that case, the user + // should realise their mistake once they generate a plan. + // + // So here, we will just ignore the error. } else { // User needs to run a `terraform workspace new` command to create the missing custom workspace. diags = append(diags, tfdiags.Sourceless( @@ -2813,35 +2821,6 @@ To make the initial dependency selections that will initialize the dependency lo return pVersion, diags } -// createDefaultWorkspace receives a backend made using a pluggable state store, and details about that store's config, -// and persists an empty state file in the default workspace. By creating this artifact we ensure that the default -// workspace is created and usable by Terraform in later operations. -func (m *Meta) createDefaultWorkspace(c *configs.StateStore, b backend.Backend) tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - - defaultSMgr, sDiags := b.StateMgr(backend.DefaultStateName) - diags = diags.Append(sDiags) - if sDiags.HasErrors() { - diags = diags.Append(fmt.Errorf("Failed to create a state manager for state store %q in provider %s (%q). This is a bug in Terraform and should be reported: %w", - c.Type, - c.Provider.Name, - c.ProviderAddr, - sDiags.Err())) - return diags - } - emptyState := states.NewState() - if err := defaultSMgr.WriteState(emptyState); err != nil { - diags = diags.Append(errStateStoreWorkspaceCreateDiag(err, c.Type)) - return diags - } - if err := defaultSMgr.PersistState(nil); err != nil { - diags = diags.Append(errStateStoreWorkspaceCreateDiag(err, c.Type)) - return diags - } - - return diags -} - // Initializing a saved state store from the backend state file (aka 'cache file', aka 'legacy state file') func (m *Meta) savedStateStore(sMgr *clistate.LocalState) (backend.Backend, tfdiags.Diagnostics) { // We're preparing a state_store version of backend.Backend. diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index e09356974f..d81284205b 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -280,23 +280,6 @@ If the backend already contains existing workspaces, you may need to update the backend configuration.` } -func errStateStoreWorkspaceCreateDiag(innerError error, storeType string) tfdiags.Diagnostic { - msg := fmt.Sprintf(`Error creating the default workspace using pluggable state store %s: %s - -This could be a bug in the provider used for state storage, or a bug in -Terraform. Please file an issue with the provider developers before reporting -a bug for Terraform.`, - storeType, - innerError, - ) - - return tfdiags.Sourceless( - tfdiags.Error, - "Cannot create the default workspace", - msg, - ) -} - // migrateOrReconfigDiag creates a diagnostic to present to users when // an init command encounters a mismatch in backend state and the current config // and Terraform needs users to provide additional instructions about how Terraform diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index ca90168399..55c02751de 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -31,6 +31,10 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { mock := testStateStoreMockWithChunkNegotiation(t, 1000) + // Mock that a custom workspace already exists. + preExistingState := "pre-existing" + mock.MockStates = map[string]interface{}{preExistingState: true} + // Assumes the mocked provider is hashicorp/test providerSource, close := newMockProviderSource(t, map[string][]string{ "hashicorp/test": {"1.2.3"}, @@ -60,9 +64,9 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { if code != 0 { t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) } - // We expect a state to have been created for the default workspace - if _, ok := mock.MockStates["default"]; !ok { - t.Fatal("expected the default workspace to exist, but it didn't") + // We expect a state to have not been created for the default workspace + if _, ok := mock.MockStates["default"]; ok { + t.Fatal("expected the default workspace to not exist, but it did") } //// Create Workspace @@ -73,9 +77,12 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { Meta: meta, } - current, _ := newCmd.Workspace() - if current != backend.DefaultStateName { - t.Fatal("before creating any custom workspaces, the current workspace should be 'default'") + current, err := newCmd.Workspace() + if err != nil { + t.Fatal(err) + } + if current != preExistingState { + t.Fatalf("before creating any custom workspaces, the current workspace should be %q, got: %q", preExistingState, current) } args = []string{newWorkspace} @@ -117,7 +124,7 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { selCmd := &WorkspaceSelectCommand{ Meta: meta, } - selectedWorkspace := backend.DefaultStateName + selectedWorkspace := preExistingState args = []string{selectedWorkspace} code = selCmd.Run(args) if code != 0 { @@ -145,8 +152,8 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { } current, _ = newCmd.Workspace() - if current != backend.DefaultStateName { - t.Fatal("current workspace should be 'default'") + if current != preExistingState { + t.Fatalf("current workspace should be %q, got %q", preExistingState, current) } //// Delete Workspace From a42d5b9f62055e1f46d9b003213373528a718d3d Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Fri, 20 Mar 2026 10:57:14 +0100 Subject: [PATCH 135/136] release: Ignore false positive CVE (#38293) --- .release/security-scan.hcl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.release/security-scan.hcl b/.release/security-scan.hcl index d287df78eb..1eac1d23ea 100644 --- a/.release/security-scan.hcl +++ b/.release/security-scan.hcl @@ -12,4 +12,12 @@ binary { go_modules = true osv = true nvd = false -} \ No newline at end of file + + triage { + suppress { + vulnerabilities = [ + "GHSA-p77j-4mvh-x3m3" + ] + } + } +} From dc4e328e6202d4ea607dbf70afebe4866cb7b725 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Mar 2026 21:09:20 -0400 Subject: [PATCH 136/136] Restore apply behavior --- internal/stacks/stackplan/component.go | 38 +++++++ internal/stacks/stackplan/from_proto.go | 78 ++++++++++++- internal/stacks/stackplan/from_proto_test.go | 111 +++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/internal/stacks/stackplan/component.go b/internal/stacks/stackplan/component.go index 87b564f2aa..99b639960e 100644 --- a/internal/stacks/stackplan/component.go +++ b/internal/stacks/stackplan/component.go @@ -52,6 +52,14 @@ type Component struct { // that have changes that are deferred to a later plan and apply cycle. DeferredResourceInstanceChanges addrs.Map[addrs.AbsResourceInstanceObject, *plans.DeferredResourceInstanceChangeSrc] + // ActionInvocations describes planned action invocations that should be + // preserved into the modules runtime apply plan. + ActionInvocations []*plans.ActionInvocationInstanceSrc + + // DeferredActionInvocations describes action invocations that were deferred + // to a later plan and apply cycle. + DeferredActionInvocations []*plans.DeferredActionInvocationSrc + // PlanTimestamp is the time Terraform Core recorded as the single "plan // timestamp", which is used only for the result of the "plantimestamp" // function during apply and must not be used for any other purpose. @@ -114,6 +122,18 @@ func (c *Component) ForModulesRuntime() (*plans.Plan, error) { } } + for _, action := range c.ActionInvocations { + if action != nil { + changes.ActionInvocations = append(changes.ActionInvocations, action) + } + } + + for _, deferredAction := range c.DeferredActionInvocations { + if deferredAction != nil { + plan.DeferredActionInvocations = append(plan.DeferredActionInvocations, deferredAction) + } + } + priorState := states.NewState() ss := priorState.SyncWrapper() for _, elem := range c.ResourceInstancePriorState.Elems { @@ -163,5 +183,23 @@ func (c *Component) RequiredProviderInstances() addrs.Set[addrs.RootProviderConf Alias: elem.Value.Alias, }) } + for _, action := range c.ActionInvocations { + if action == nil { + continue + } + providerInstances.Add(addrs.RootProviderConfig{ + Provider: action.ProviderAddr.Provider, + Alias: action.ProviderAddr.Alias, + }) + } + for _, deferredAction := range c.DeferredActionInvocations { + if deferredAction == nil || deferredAction.ActionInvocationInstanceSrc == nil { + continue + } + providerInstances.Add(addrs.RootProviderConfig{ + Provider: deferredAction.ActionInvocationInstanceSrc.ProviderAddr.Provider, + Alias: deferredAction.ActionInvocationInstanceSrc.ProviderAddr.Alias, + }) + } return providerInstances } diff --git a/internal/stacks/stackplan/from_proto.go b/internal/stacks/stackplan/from_proto.go index 31fd11c97b..db9b440280 100644 --- a/internal/stacks/stackplan/from_proto.go +++ b/internal/stacks/stackplan/from_proto.go @@ -237,6 +237,8 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { ResourceInstancePriorState: addrs.MakeMap[addrs.AbsResourceInstanceObject, *states.ResourceInstanceObjectSrc](), ResourceInstanceProviderConfig: addrs.MakeMap[addrs.AbsResourceInstanceObject, addrs.AbsProviderConfig](), DeferredResourceInstanceChanges: addrs.MakeMap[addrs.AbsResourceInstanceObject, *plans.DeferredResourceInstanceChangeSrc](), + ActionInvocations: make([]*plans.ActionInvocationInstanceSrc, 0), + DeferredActionInvocations: make([]*plans.DeferredActionInvocationSrc, 0), }) err = c.PlanTimestamp.UnmarshalText([]byte(msg.PlanTimestamp)) if err != nil { @@ -302,7 +304,42 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { }) case *tfstackdata1.PlanActionInvocationPlanned: - // TODO: Implemented in a future apply-related PR. + c, fullAddr, err := LoadComponentForActionInvocation(l.ret, msg) + if err != nil { + return err + } + + action, err := ValidateActionInvocation(msg, fullAddr) + if err != nil { + return err + } + if action != nil { + c.ActionInvocations = append(c.ActionInvocations, action) + } + + case *tfstackdata1.PlanDeferredActionInvocation: + if msg.Deferred == nil { + return fmt.Errorf("missing deferred from PlanDeferredActionInvocation") + } + if msg.Invocation == nil { + return fmt.Errorf("missing invocation from PlanDeferredActionInvocation") + } + + c, fullAddr, err := LoadComponentForActionInvocation(l.ret, msg.Invocation) + if err != nil { + return err + } + + action, err := ValidateActionInvocation(msg.Invocation, fullAddr) + if err != nil { + return err + } + + deferredReason, _ := planfile.DeferredReasonFromProto(msg.Deferred.Reason) + c.DeferredActionInvocations = append(c.DeferredActionInvocations, &plans.DeferredActionInvocationSrc{ + DeferredReason: deferredReason, + ActionInvocationInstanceSrc: action, + }) default: // Should not get here, because a stack plan can only be loaded by @@ -472,3 +509,42 @@ func LoadComponentForPartialResourceInstance(plan *Plan, change *tfstackdata1.Pl return c, fullAddr, providerConfigAddr, nil } + +func ValidateActionInvocation(change *tfstackdata1.PlanActionInvocationPlanned, fullAddr stackaddrs.AbsActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { + if change.Invocation == nil { + return nil, nil + } + + action, err := planfile.ActionInvocationFromProto(change.Invocation) + if err != nil { + return nil, fmt.Errorf("invalid action invocation: %w", err) + } + if !action.Addr.Equal(fullAddr.Item) { + return nil, fmt.Errorf("planned action invocation has inconsistent address to its containing object") + } + return action, nil +} + +func LoadComponentForActionInvocation(plan *Plan, change *tfstackdata1.PlanActionInvocationPlanned) (*Component, stackaddrs.AbsActionInvocationInstance, error) { + cAddr, diags := stackaddrs.ParsePartialComponentInstanceStr(change.ComponentInstanceAddr) + if diags.HasErrors() { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("invalid component instance address syntax in %q", change.ComponentInstanceAddr) + } + + actionAddr, diags := addrs.ParseAbsActionInstanceStr(change.ActionInvocationAddr) + if diags.HasErrors() { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("invalid action invocation address syntax in %q", change.ActionInvocationAddr) + } + + fullAddr := stackaddrs.AbsActionInvocationInstance{ + Component: cAddr, + Item: actionAddr, + } + + c, ok := plan.Root.GetOk(cAddr) + if !ok { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("action invocation change for unannounced component instance %s", cAddr) + } + + return c, fullAddr, nil +} diff --git a/internal/stacks/stackplan/from_proto_test.go b/internal/stacks/stackplan/from_proto_test.go index 43e1a458f7..0950c3c556 100644 --- a/internal/stacks/stackplan/from_proto_test.go +++ b/internal/stacks/stackplan/from_proto_test.go @@ -11,9 +11,14 @@ import ( "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/types/known/anypb" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/plans/planproto" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" "github.com/hashicorp/terraform/internal/stacks/tfstackdata1" ) @@ -100,3 +105,109 @@ func TestAddRaw(t *testing.T) { }) } } + +func TestAddRaw_ActionInvocations(t *testing.T) { + provider := addrs.MustParseProviderSourceString("example.com/test/actions") + providerConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: provider, + } + action := &plans.ActionInvocationInstanceSrc{ + Addr: addrs.RootModuleInstance.ActionInstance("webhook", "notify", addrs.NoKey), + ActionTrigger: &plans.ResourceActionTrigger{ + TriggeringResourceAddr: addrs.RootModuleInstance.ResourceInstance(addrs.ManagedResourceMode, "example_resource", "main", addrs.NoKey), + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + ProviderAddr: providerConfig, + } + rawAction, err := planfile.ActionInvocationToProto(action) + if err != nil { + t.Fatal(err) + } + + loader := NewLoader() + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanComponentInstance{ + ComponentInstanceAddr: "component.web", + PlannedAction: planproto.Action_NOOP, + Mode: planproto.Mode_NORMAL, + PlanTimestamp: "2017-03-27T10:00:00-08:00", + })) + if err != nil { + t.Fatalf("adding component: %v", err) + } + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: action.Addr.String(), + ProviderConfigAddr: provider.String(), + Invocation: rawAction, + })) + if err != nil { + t.Fatalf("adding planned action invocation: %v", err) + } + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanDeferredActionInvocation{ + Deferred: &planproto.Deferred{ + Reason: planproto.DeferredReason_DEFERRED_PREREQ, + }, + Invocation: &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: action.Addr.String(), + ProviderConfigAddr: provider.String(), + Invocation: rawAction, + }, + })) + if err != nil { + t.Fatalf("adding deferred action invocation: %v", err) + } + + componentAddr, diags := stackaddrs.ParseAbsComponentInstanceStr("component.web") + if diags.HasErrors() { + t.Fatalf("parsing component address: %s", diags.Err()) + } + component := loader.ret.GetComponent(componentAddr) + if component == nil { + t.Fatal("expected component to be loaded") + } + + if len(component.ActionInvocations) != 1 { + t.Fatalf("expected 1 planned action invocation, got %d", len(component.ActionInvocations)) + } + if diff := cmp.Diff(action, component.ActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong planned action invocation (-want +got):\n%s", diff) + } + if len(component.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred action invocation, got %d", len(component.DeferredActionInvocations)) + } + if diff := cmp.Diff(&plans.DeferredActionInvocationSrc{ + DeferredReason: providers.DeferredReasonDeferredPrereq, + ActionInvocationInstanceSrc: action, + }, component.DeferredActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong deferred action invocation (-want +got):\n%s", diff) + } + + modulesPlan, err := component.ForModulesRuntime() + if err != nil { + t.Fatalf("ForModulesRuntime: %v", err) + } + if len(modulesPlan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 planned action invocation in modules runtime plan, got %d", len(modulesPlan.Changes.ActionInvocations)) + } + if diff := cmp.Diff(action, modulesPlan.Changes.ActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong modules runtime action invocation (-want +got):\n%s", diff) + } + if len(modulesPlan.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred action invocation in modules runtime plan, got %d", len(modulesPlan.DeferredActionInvocations)) + } + if diff := cmp.Diff(&plans.DeferredActionInvocationSrc{ + DeferredReason: providers.DeferredReasonDeferredPrereq, + ActionInvocationInstanceSrc: action, + }, modulesPlan.DeferredActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong modules runtime deferred action invocation (-want +got):\n%s", diff) + } + + requiredProviders := component.RequiredProviderInstances() + if !requiredProviders.Has(addrs.RootProviderConfig{Provider: provider}) { + t.Fatalf("expected action provider %s to be required", provider) + } +}