diff --git a/internal/command/init2_test.go b/internal/command/init2_test.go index 6f8e91188af1..3bc5c38929a6 100644 --- a/internal/command/init2_test.go +++ b/internal/command/init2_test.go @@ -10,6 +10,10 @@ import ( "testing" "github.com/hashicorp/cli" + + "github.com/hashicorp/terraform/internal/backend" + backendInit "github.com/hashicorp/terraform/internal/backend/init" + backendCloud "github.com/hashicorp/terraform/internal/cloud" ) func TestInit2_dynamicSourceErrors(t *testing.T) { @@ -718,3 +722,53 @@ func TestPlan_dynamicModuleVersionMismatch(t *testing.T) { t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) } } + +// TestInit_constVarFromCloudBackend verifies that terraform init fetches +// workspace variables from HCP Terraform to resolve const variables used in +// dynamic module source expressions, without requiring -var flags. +func TestInit_constVarFromCloudBackend(t *testing.T) { + // Start a mock HCP Terraform server that serves the "test" workspace and + // returns module_name=example as a Terraform-category variable. + server := cloudTestServerWithVars(t) + defer server.Close() + d := testDisco(server) + + // Override the "cloud" backend to use our test server. + previousBackend := backendInit.Backend("cloud") + backendInit.Set("cloud", func() backend.Backend { return backendCloud.New(d) }) + defer backendInit.Set("cloud", previousBackend) + + // Use the existing fixture: cloud {} block pointing to hashicorp/test, + // variable "module_name" { const = true }, module "example" { source = "./modules/${var.module_name}" } + wd := tempWorkingDirFixture(t, "dynamic-module-sources/get-const-var-backend") + t.Chdir(wd.RootModuleDir()) + + ui := cli.NewMockUi() + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + WorkingDir: wd, + Services: d, + }, + } + + // Run init without any -var flags; module_name should be resolved from the backend. + code := c.Run([]string{}) + output := done(t) + if code != 0 { + t.Fatalf("expected init to succeed, got exit code %d\n%s", code, output.All()) + } + + // The module should have been installed into .terraform/modules. + moduleDir := filepath.Join(wd.RootModuleDir(), ".terraform", "modules") + entries, err := os.ReadDir(moduleDir) + if err != nil { + t.Fatalf("expected .terraform/modules to exist after init: %s", err) + } + if len(entries) == 0 { + t.Fatal("expected at least one module entry in .terraform/modules after init") + } +} diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 228a95416476..8638a59cda61 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "github.com/hashicorp/hcl/v2" @@ -168,6 +169,19 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { } if initArgs.Get { + // If the root module declares any const variables that are not yet + // satisfied by CLI flags, attempt to fetch them from the configured + // backend (e.g. HCP Terraform workspace variables). This is necessary + // so that dynamic module source expressions (e.g. `source = var.path`) + // can be resolved during module installation below. + // + // Non-fatal: if the backend isn't reachable yet (e.g. first-time init + // before credentials are stored) we proceed and let module installation + // fail with a more specific error if needed. + if resolveVarDiags := c.resolveConstVariablesForInit(path, initArgs.ViewType); resolveVarDiags.HasErrors() { + log.Printf("[WARN] init: failed to resolve const variables from backend: %s", resolveVarDiags.Err()) + } + modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) diags = diags.Append(modsDiags) if modsAbort || modsDiags.HasErrors() { diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index a8a004c05e77..29775fc9cef0 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -2138,6 +2138,55 @@ func (m *Meta) backend(configPath string, viewType arguments.ViewType) (backendr return be, diags } +// backendForInit is like backend but sets Init: true so it can be used +// during "terraform init" before the backend has been stored in state. +// It is restricted to cloud/backend configs only (not state_store) since +// state_store requires dependency locks that aren't available during init. +// +// This is used by resolveConstVariablesForInit to fetch workspace variables +// early enough for dynamic module source resolution. +func (m *Meta) backendForInit(configPath string, viewType arguments.ViewType) (backendrun.OperationsBackend, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + if configPath == "" { + configPath = "." + } + + root, mDiags := m.loadSingleModule(configPath) + if mDiags.HasErrors() { + diags = diags.Append(mDiags) + return nil, diags + } + + var opts *BackendOpts + switch { + case root.Backend != nil: + opts = &BackendOpts{ + BackendConfig: root.Backend, + ViewType: viewType, + Init: true, + } + case root.CloudConfig != nil: + backendConfig := root.CloudConfig.ToBackendConfig() + opts = &BackendOpts{ + BackendConfig: &backendConfig, + ViewType: viewType, + Init: true, + } + default: + // No cloud/backend config; can't fetch variables. + return nil, nil + } + + be, beDiags := m.Backend(opts) + diags = diags.Append(beDiags) + if beDiags.HasErrors() { + return nil, diags + } + + return be, diags +} + //------------------------------------------------------------------- // State Store Config Scenarios // The functions below cover handling all the various scenarios that diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index c476378ff1b5..e2b58fdffb01 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -47,6 +47,23 @@ func (m *Meta) normalizePath(path string) string { // 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 { + return m.fetchAndApplyConstVariables(rootDir, func() (backendrun.OperationsBackend, tfdiags.Diagnostics) { + return m.backend(rootDir, viewType) + }) +} + +// resolveConstVariablesForInit is like resolveConstVariables but uses +// backendForInit so it can run during "terraform init" before the backend +// state file exists. This is necessary so that dynamic module source +// expressions (e.g. `source = var.module_path`) can be resolved during +// module installation, which happens before backend init completes. +func (m *Meta) resolveConstVariablesForInit(rootDir string, viewType arguments.ViewType) tfdiags.Diagnostics { + return m.fetchAndApplyConstVariables(rootDir, func() (backendrun.OperationsBackend, tfdiags.Diagnostics) { + return m.backendForInit(rootDir, viewType) + }) +} + +func (m *Meta) fetchAndApplyConstVariables(rootDir string, loadBackend func() (backendrun.OperationsBackend, tfdiags.Diagnostics)) tfdiags.Diagnostics { rootMod, diags := m.loadSingleModule(rootDir) if diags.HasErrors() { return diags @@ -56,9 +73,9 @@ func (m *Meta) resolveConstVariables(rootDir string, viewType arguments.ViewType return nil } - b, backendDiags := m.backend(rootDir, viewType) + b, backendDiags := loadBackend() if backendDiags.HasErrors() { - // Don't report backend init errors here; they'll surface later. + // Don't report backend errors here; they'll surface later during init. return nil }