From a9d873f7e1a15897a4cc1ded25b32d29de45ac0f Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 23 Feb 2026 17:57:05 +0000 Subject: [PATCH 01/13] feat: Add -safe-init flag to init command, including validation of use with experiments and mutually-exclusive flags In particular, note how -safe-init and -backend=false are not compatible. --- internal/command/arguments/init.go | 47 +++++++++++++++++++++++++ internal/command/arguments/init_test.go | 42 ++++++++++++++++++---- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go index e52d18325eef..bf46bdebb508 100644 --- a/internal/command/arguments/init.go +++ b/internal/command/arguments/init.go @@ -83,6 +83,13 @@ type Init struct { // CreateDefaultWorkspace indicates whether the default workspace should be created by // Terraform when initializing a state store for the first time. CreateDefaultWorkspace bool + + // SafeInitWithPluggableStateStore indicates whether the user has opted into the process of downloading and approving + // a new provider binary to use for pluggable state storage. + // When false and `init` detects that a provider for PSS needs to be downloaded, `init` will return early and prompt the user to re-run with `-safe init`. + // When true and `init` detects that a provider for PSS needs to be downloaded then the user will experience a new UX. + // Details of the new UX depending on whether Terraform is being run in automation or not. + SafeInitWithPluggableStateStore bool } // ParseInit processes CLI arguments, returning an Init value and errors. @@ -117,6 +124,7 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti 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") + cmdFlags.BoolVar(&init.SafeInitWithPluggableStateStore, "safe-init", false, `Enable the "safe init" workflow when downloading a provider binary for use with pluggable state storage.`) // 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") @@ -158,6 +166,13 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti "Terraform cannot use the -create-default-workspace flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless experiments are enabled.", )) } + if init.SafeInitWithPluggableStateStore { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Cannot use -safe-init flag without experiments enabled", + "Terraform cannot use the -safe-init flag unless experiments are enabled.", + )) + } } else { // Errors using flags despite experiments being enabled. if !init.CreateDefaultWorkspace && !init.EnablePssExperiment { @@ -167,6 +182,38 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti "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.SafeInitWithPluggableStateStore && !init.EnablePssExperiment { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Cannot use -safe-init flag unless the pluggable state storage experiment is enabled", + "Terraform cannot use the -safe-init flag unless you also supply the -enable-pluggable-state-storage-experiment flag (or set the TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable).", + )) + } + } + + // Manage all flag interactions with -safe-init + if init.SafeInitWithPluggableStateStore { + if !init.Backend { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "The -safe-init and -backend=false options are mutually-exclusive", + "When -backend=false is set Terraform uses information from the last successful init to launch a backend or state store. Any providers used for pluggable state storage should already be downloaded, so -safe-init is unnecessary.", + )) + } + if len(init.PluginPath) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "The -safe-init and -plugin-dir options are mutually-exclusive", + "Providers sourced through -plugin-dir have already been vetted by the user, so -safe-init is unnecessary. Please re-run the command without the -safe-init flag.", + )) + } + if init.Lockfile == "readonly" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + `The -safe-init and -lockfile=readonly options are mutually-exclusive`, + "The -safe-init flag is intended to help when first downloading or upgrading a provider to use for state storage, and in those scenarios the lockfile cannot be treated as read-only.", + )) + } } if init.MigrateState && init.Json { diff --git a/internal/command/arguments/init_test.go b/internal/command/arguments/init_test.go index e6df4a6faaee..42d577a94e04 100644 --- a/internal/command/arguments/init_test.go +++ b/internal/command/arguments/init_test.go @@ -40,19 +40,22 @@ 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, + CreateDefaultWorkspace: true, + SafeInitWithPluggableStateStore: false, }, }, "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", @@ -156,6 +159,21 @@ func TestParseInit_invalid(t *testing.T) { wantErr: "The -migrate-state and -reconfigure options are mutually-exclusive.", wantViewType: ViewHuman, }, + "with both -safe-init and -backend=false options set": { + args: []string{"-safe-init", "-backend=false"}, + wantErr: "The -safe-init and -backend=false options are mutually-exclusive", + wantViewType: ViewHuman, + }, + "with both -safe-init and -plugin-dir options set": { + args: []string{"-safe-init", "-plugin-dir=./my/path/to/dir"}, + wantErr: "The -safe-init and -plugin-dir options are mutually-exclusive", + wantViewType: ViewHuman, + }, + "with both -safe-init and -lockfile=readonly options set": { + args: []string{"-safe-init", `-lockfile=readonly`}, + wantErr: "The -safe-init and -lockfile=readonly options are mutually-exclusive", + wantViewType: ViewHuman, + }, } for name, tc := range testCases { @@ -218,6 +236,16 @@ func TestParseInit_experimentalFlags(t *testing.T) { experimentsEnabled: true, wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", }, + "error: -safe-init and experiments are disabled": { + args: []string{"-safe-init"}, + experimentsEnabled: false, + wantErr: "Cannot use -safe-init flag without experiments enabled: Terraform cannot use the -safe-init flag unless experiments are enabled.", + }, + "error: -safe-init used without -enable-pluggable-state-storage-experiment": { + args: []string{"-safe-init"}, + experimentsEnabled: true, + wantErr: "Cannot use -safe-init flag unless the pluggable state storage experiment is enabled", + }, } for name, tc := range testCases { From ca934a5d76c2270c82f622dfa16e9f79367720ef Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 23 Feb 2026 18:45:08 +0000 Subject: [PATCH 02/13] test: Remove temporary test case, update test names when testing init in an uninitialised directory --- internal/command/init_test.go | 63 +++-------------------------------- 1 file changed, 5 insertions(+), 58 deletions(-) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 5216dcfb1e7d..5d48a900f7b8 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3451,60 +3451,7 @@ func TestInit_testsWithModule(t *testing.T) { // Testing init's behaviors with `state_store` when run in an empty working directory func TestInit_stateStore_newWorkingDir(t *testing.T) { - t.Run("temporary: test showing use of HTTP server in mock provider source", 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) - - // Mock provider still needs to be supplied via testingOverrides despite the mock HTTP source - mockProvider := mockPluggableStateStorageProvider() - mockProviderVersion := getproviders.MustParseVersion("1.2.3") - mockProviderAddress := addrs.NewDefaultProvider("test") - - // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 via HTTP. - // This stops Terraform auto-approving the provider installation. - source := newMockProviderSourceUsingTestHttpServer(t, mockProviderAddress, mockProviderVersion) - - ui := new(cli.MockUi) - view, done := testView(t) - meta := Meta{ - Ui: ui, - View: view, - AllowExperimentalFeatures: true, - testingOverrides: &testingOverrides{ - Providers: map[addrs.Provider]providers.Factory{ - mockProviderAddress: providers.FactoryFixed(mockProvider), - }, - }, - ProviderSource: source, - } - c := &InitCommand{ - Meta: meta, - } - - 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() - 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 { - if !strings.Contains(output, expected) { - t.Fatalf("expected output to include %q, but got':\n %s", expected, output) - } - } - }) - - t.Run("the init command creates a backend state file, and creates the default workspace by default", func(t *testing.T) { + t.Run("init: creates a backend state file and creates 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) @@ -3591,7 +3538,7 @@ 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) { + t.Run("init: 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) @@ -3640,7 +3587,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("an init command with TF_SKIP_CREATE_DEFAULT_WORKSPACE set will not make the default workspace by default", func(t *testing.T) { + t.Run("init: TF_SKIP_CREATE_DEFAULT_WORKSPACE will cause the default workspace to not be created", 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) @@ -3691,7 +3638,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { }) // 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) { + t.Run("init: error if a custom workspace is selected but no workspaces exist", 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) @@ -3761,7 +3708,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { // // When input is disabled (in automation, etc) Terraform cannot prompts the user to select an alternative. // Instead, an error is returned. - t.Run("init: returns an error when input is disabled and the selected workspace doesn't exist and other custom workspaces do exist.", func(t *testing.T) { + t.Run("init: error when input is disabled and the selected workspace doesn't exist and other custom workspaces do exist.", 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) From a491544f6bb7094ece48d425dbf1d908994daa02 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 23 Feb 2026 19:23:20 +0000 Subject: [PATCH 03/13] feat: implement safe init security features when PSS is used while input is enabled Terraform prompts users when installing state storage providers via HTTP. If a provider would be sourced via HTTP and the -safe-init flag is missing an error is returned. If a provider is downloaded via other sources then nothing has changed (no new output, no need for new flags). --- internal/command/init.go | 367 +++++++++++++++++++++++++++++++-- internal/command/init_run.go | 69 ++++++- internal/command/init_test.go | 259 +++++++++++++++++++++++ internal/command/views/init.go | 10 + 4 files changed, 692 insertions(+), 13 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 6bcb08c5ff7d..72007275f75a 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -351,11 +351,24 @@ the backend configuration is present and valid. return diags } +// SafeInitAction describes the action that should be taken by Terraform based on whether +// pluggable state storage is in use, if the provider is going to be downloaded via HTTP or not, +// and whether Terraform is being run in automation or not. +type SafeInitAction rune + +const ( + SafeInitActionInvalid SafeInitAction = 0 + SafeInitActionProceed SafeInitAction = 'P' + SafeInitActionPromptForInput SafeInitAction = 'I' + SafeInitActionExitEarly SafeInitAction = 'E' + SafeInitActionNotRelevant SafeInitAction = 'N' // For when a state store isn't in use at all! +) + // getProvidersFromConfig determines what providers are required by the given configuration data. // The method downloads any missing providers that aren't already downloaded and then returns // dependency lock data based on the configuration. // The dependency lock file itself isn't updated here. -func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *configs.Config, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { +func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *configs.Config, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, safeInitAction SafeInitAction, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from config") defer span.End() @@ -369,7 +382,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config reqs, hclDiags := config.ProviderRequirements() diags = diags.Append(hclDiags) if hclDiags.HasErrors() { - return false, nil, diags + return false, nil, SafeInitActionInvalid, diags } reqs = c.removeDevOverrides(reqs) @@ -387,7 +400,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } if diags.HasErrors() { - return false, nil, diags + return false, nil, SafeInitActionInvalid, diags } var inst *providercache.Installer @@ -409,7 +422,310 @@ 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) + // Prepare callback functions for the installer. + // These allow us to send output to the terminal as events happen, catch + // diagnostics, etc. + // + // One of the things we capture via these callbacks is the location of + // providers as we install them. This allows the calling code to determine + // what 'safe init' actions need to take place. + providerLocations := make(map[addrs.Provider]getproviders.PackageLocation) + + // 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.InitializingProviderPluginFromConfigMessage) + }, + 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) + + // Record the location of this provider. + // + // FetchPackageBegin is the callback hook at the start of the process of obtaining a provider that isn't yet + // in the dependency lock file. Providers that are processed here will not be processed here on the next init, + // as then they will be in the lock file. The same provider type would only be processed here again if the + // provider version changed via an `init -upgrade` command. + providerLocations[provider] = location + }, + 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. + c.incompleteProviders = append(c.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 @@ -417,7 +733,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config if flagLockfile == "readonly" { diags = diags.Append(fmt.Errorf("The -upgrade flag conflicts with -lockfile=readonly.")) view.Diagnostics(diags) - return true, nil, diags + return true, nil, SafeInitActionInvalid, diags } mode = providercache.InstallUpgrades @@ -427,7 +743,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) if diags.HasErrors() { - return false, nil, diags + return false, nil, SafeInitActionInvalid, diags } // Determine which required providers are already downloaded, and download any @@ -436,7 +752,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config if ctx.Err() == context.Canceled { diags = diags.Append(fmt.Errorf("Provider installation was canceled by an interrupt signal.")) view.Diagnostics(diags) - return true, nil, diags + return true, nil, SafeInitActionInvalid, diags } if err != nil { // The errors captured in "err" should be redundant with what we @@ -446,10 +762,40 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config diags = diags.Append(err) } - return true, nil, diags + return true, nil, SafeInitActionInvalid, diags + } + + // Return advice to the calling code about what to do regarding safe init feature related to state storage providers + if config.Module.StateStore == nil { + // If PSS isn't in use then return a value that isn't the zero value but isn't misleading. + safeInitAction = SafeInitActionNotRelevant + } + if config.Module.StateStore != nil { + location, ok := providerLocations[config.Module.StateStore.ProviderAddr] + if !ok { + // The provider was not processed in the FetchPackageBegin callback, so it was already present. + log.Printf("[TRACE] init (getProvidersFromConfig): the state storage provider %s (%q) will not be changed in the dependency lock file after provider installation. Either it was already present and/or there was no available upgrade version that matched version constraints.", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr) + safeInitAction = SafeInitActionProceed + } else { + // The provider was processed in the FetchPackageBegin callback, so either it's being downloaded for the first time, or upgraded. + log.Printf("[TRACE] init (getProvidersFromConfig): the state storage provider %s (%q) will be changed in the dependency lock file during provider installation.", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr) + + switch location.(type) { + case getproviders.PackageLocalArchive, getproviders.PackageLocalDir: + // If the provider is downloaded from a local source we assume it's safe. + // We don't require presence of the -safe-init flag, or require input from the user to approve its usage. + log.Printf("[TRACE] init (getProvidersFromConfig): the state storage provider %s (%q) is downloaded from a local source, so we consider it safe.", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr) + safeInitAction = SafeInitActionProceed + case getproviders.PackageHTTPURL: + log.Printf("[DEBUG] init (getProvidersFromConfig): the state storage provider %s (%q) is downloaded via HTTP, so we consider it potentially unsafe.", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr) + safeInitAction = SafeInitActionPromptForInput + default: + panic(fmt.Sprintf("init (getProvidersFromConfig): unexpected provider location type for state storage provider %q: %T", config.Module.StateStore.ProviderAddr, location)) + } + } } - return true, configLocks, diags + return true, configLocks, safeInitAction, diags } // getProvidersFromState determines what providers are required by the given state data. @@ -561,7 +907,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 +976,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 +1102,6 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) } - }, QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { displayWarnings := make([]string, len(warnings)) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index d63013f94eda..c4a1f6b55a52 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -4,16 +4,19 @@ package command import ( + "context" "errors" "fmt" "strings" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" "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/states" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -210,7 +213,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) - configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + configProvidersOutput, configLocks, safeInitAction, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { view.Diagnostics(diags) @@ -220,6 +223,35 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { header = true } + switch safeInitAction { + case SafeInitActionNotRelevant: + // do nothing; security features aren't relevant. + case SafeInitActionProceed: + // do nothing; provider is considered safe and there's no need for notifying the user. + case SafeInitActionPromptForInput: + if !initArgs.SafeInitWithPluggableStateStore { + // If the -safe-init flag isn't present we prompt the user to re-run init so they're opting into the security UX. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "State storage providers must be downloaded using -safe-init flag", + Detail: "The provider used for state storage needs to be installed safely. Please re-run the \"init\" command with the -safe-init flag.", + }) + view.Diagnostics(diags) + return 1 + } + + diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks)) + if diags.HasErrors() { + view.Output(views.UserRejectedStateStoreProviderMessage) + view.Diagnostics(diags) + return 1 + } + view.Output(views.UserApprovedStateStoreProviderMessage) + default: + // Handle SafeInitActionInvalid or unexpected action types + panic(fmt.Sprintf("When installing providers described in the config Terraform couldn't determine what 'safe init' action should be taken and returned action type %T. This is a bug in Terraform and should be reported.", safeInitAction)) + } + // 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 { @@ -362,3 +394,38 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { } return 0 } + +// promptStateStorageProviderApproval is used when Terraform is unsure about the safety of the provider downloaded for state storage +// purposes, and we need to prompt the user to approve or reject using it. +func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider addrs.Provider, configLocks *depsfile.Locks) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // If we can receive input then we prompt for ok from the user + lock := configLocks.Provider(stateStorageProvider) + + v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "approve", + Query: fmt.Sprintf(`Do you want to use provider %q (%s), version %s, for managing state? +Hash: %s +`, + lock.Provider().Type, + lock.Provider(), + lock.Version(), + lock.PreferredHashes()[0], // TODO - better handle of multiple hashes + ), + Description: fmt.Sprintf(`Check the dependency lockfile's entry for %q. + Only 'yes' will be accepted to confirm.`, lock.Provider()), + }) + if err != nil { + return diags.Append(fmt.Errorf("Failed to approve use of state storage provider: %s", err)) + } + if v != "yes" { + return diags.Append( + fmt.Errorf("State storage provider %q (%s) was not approved by the user", + lock.Provider().Type, + lock.Provider(), + ), + ) + } + return diags +} diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 5d48a900f7b8..ebce0f91700b 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3451,6 +3451,265 @@ func TestInit_testsWithModule(t *testing.T) { // Testing init's behaviors with `state_store` when run in an empty working directory func TestInit_stateStore_newWorkingDir(t *testing.T) { + t.Run("init: error if -safe-init isn't set when downloading the state storage provider via HTTP", 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) + + // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 via HTTP. + // This stops Terraform auto-approving the provider installation. + source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("1.2.3")) + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + // We don't use testOverrides here because that causes providers to come from the local + // filesystem, and that makes them automatically trusted. + // The purpose of this test is to assert that downloading providers via HTTP, so we use a + // provider source that's mimicking the Registry with an http.Server. + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + // -safe-init is omitted to create the test scenario + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedOutputs := []string{ + "Error: State storage providers must be downloaded using -safe-init flag", + } + for _, expectedOutput := range expectedOutputs { + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) + } + } + }) + + t.Run("init: -safe-init enables downloading a state storage provider via HTTP", 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) + + // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 via HTTP. + // This stops Terraform auto-approving the provider installation. + source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("1.2.3")) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + + // Allow the test to respond to the pause in provider installation for + // checking the state storage provider. + _ = testInputMap(t, map[string]string{ + "approve": "yes", + }) + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-safe-init", // In this test the provider is downloaded via HTTP so this flag is necessary. + } + 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() + 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 { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } + } + + // Assert the dependency lock file was created + lockFile := filepath.Join(td, ".terraform.lock.hcl") + _, err := os.Stat(lockFile) + if os.IsNotExist(err) { + t.Fatal("expected dependency lock file to exist, but it doesn't") + } + }) + + t.Run("init: can safely download state storage provider from a local archive without needing to supply the -safe-init flag", 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) + + // This mock provider source makes Terraform think the provider is coming from a local archive, + // so security checks are skipped. + source, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) + t.Cleanup(close) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + // This test doesn't need -safe-init in the flags due to the location of the provider + } + 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() + 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 { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } + } + + // Assert the dependency lock file was created + lockFile := filepath.Join(td, ".terraform.lock.hcl") + _, err := os.Stat(lockFile) + if os.IsNotExist(err) { + t.Fatal("expected dependency lock file to not exist, but it doesn't") + } + }) + + t.Run("init: if user forgets -safe-init flag and retries they're prompted as expected on the second attempt", 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) + + // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 via HTTP. + // This stops Terraform auto-approving the provider installation. + source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("1.2.3")) + + // Set up providers for use in the second init attempt after the user adds the -safe-init flag. + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + // Init number 1: forgetting -safe-init flag + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + output := testOutput.All() + expectedOutputs := []string{ + "Error: State storage providers must be downloaded using -safe-init flag", + } + for _, expectedOutput := range expectedOutputs { + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) + } + } + + // Init number 2: retrying with -safe-init flag and responding to prompts + _ = testInputMap(t, map[string]string{ + "approve": "yes", + }) + args = []string{ + "-enable-pluggable-state-storage-experiment=true", + "-safe-init", + } + ui = new(cli.MockUi) + view, done = testView(t) + c.Ui = ui + c.View = view + 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()) + } + 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 { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } + } + }) + t.Run("init: creates a backend state file and creates the default workspace by default", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() diff --git a/internal/command/views/init.go b/internal/command/views/init.go index 1789c9b64578..3f3d7ccacc71 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -199,6 +199,14 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe HumanValue: "\n[reset][bold]Initializing the state store...", JSONValue: "Initializing the state store...", }, + "user_approved_state_store_provider_message": { + HumanValue: "\n[reset][bold]User approved the state storage provider.", + JSONValue: "User approved the state storage provider.", + }, + "user_rejected_state_store_provider_message": { + HumanValue: "\n[reset][bold]User rejected the state storage provider.", + JSONValue: "User rejected the state storage provider.", + }, "default_workspace_created_message": { HumanValue: defaultWorkspaceCreatedInfo, JSONValue: defaultWorkspaceCreatedInfo, @@ -343,6 +351,8 @@ const ( InitializingModulesMessage InitMessageCode = "initializing_modules_message" InitializingBackendMessage InitMessageCode = "initializing_backend_message" InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" + UserApprovedStateStoreProviderMessage InitMessageCode = "user_approved_state_store_provider_message" + UserRejectedStateStoreProviderMessage InitMessageCode = "user_rejected_state_store_provider_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" From e33592e21995e1802b55f5de9716850f50269c49 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 26 Feb 2026 15:35:50 +0000 Subject: [PATCH 04/13] feat: Show all preferred hashes for a provider in the prompt to users, update tests to assert hashes are in the prompt. --- internal/command/init_run.go | 10 ++++++++-- internal/command/init_test.go | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index c4a1f6b55a52..2e14d5d8e6bf 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -403,15 +403,21 @@ func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider ad // If we can receive input then we prompt for ok from the user lock := configLocks.Provider(stateStorageProvider) + var hashList strings.Builder + for _, hash := range lock.PreferredHashes() { + hashList.WriteString(fmt.Sprintf("- %s\n", hash)) + } + v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ Id: "approve", Query: fmt.Sprintf(`Do you want to use provider %q (%s), version %s, for managing state? -Hash: %s +Hashes: +%s `, lock.Provider().Type, lock.Provider(), lock.Version(), - lock.PreferredHashes()[0], // TODO - better handle of multiple hashes + hashList.String(), ), Description: fmt.Sprintf(`Check the dependency lockfile's entry for %q. Only 'yes' will be accepted to confirm.`, lock.Provider()), diff --git a/internal/command/init_test.go b/internal/command/init_test.go index ebce0f91700b..551ee3a876fe 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3514,7 +3514,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { // Allow the test to respond to the pause in provider installation for // checking the state storage provider. - _ = testInputMap(t, map[string]string{ + inputWriter := testInputMap(t, map[string]string{ "approve": "yes", }) @@ -3545,7 +3545,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) } - // Check output + // Check output via view output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", @@ -3557,6 +3557,16 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { t.Fatalf("expected output to include %q, but got':\n %s", expected, output) } } + // Check output when prompting for approval + expectedInputPromptMsg := []string{ + "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", + "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", + } + for _, expected := range expectedInputPromptMsg { + if !strings.Contains(inputWriter.String(), expected) { + t.Fatalf("expected the input prompt to include %q, but got':\n %s", expected, inputWriter.String()) + } + } // Assert the dependency lock file was created lockFile := filepath.Join(td, ".terraform.lock.hcl") From 11a1875391f2abd46c02d05a0ef447082b6bbeee Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 27 Feb 2026 15:29:56 +0000 Subject: [PATCH 05/13] test: Add test showing that upgrading a provider via HTTP triggers the new security features --- internal/command/init_test.go | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 551ee3a876fe..18fb34556153 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -4689,7 +4689,7 @@ func TestInit_stateStore_configChanges(t *testing.T) { // TODO: Add a test case showing that downgrading provider version is ok as long as the schema version hasn't // changed. We should also have a test demonstrating that downgrades when the schema version HAS changed will fail. func TestInit_stateStore_providerUpgrade(t *testing.T) { - t.Run("handling upgrading the provider used for state storage", func(t *testing.T) { + t.Run("upgrading the provider used for state storage from a local archive", func(t *testing.T) { // Create a temporary working directory with state store configuration // that doesn't match the backend state file td := t.TempDir() @@ -4751,6 +4751,77 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) { t.Fatal("expected the default workspace to exist after migration, but it is missing") } }) + + t.Run("upgrading the provider used for state storage via HTTP", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/provider-upgraded"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + // The previous init implied by this test scenario would have created the default workspace. + mockProvider.MockStates = map[string]any{ + backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + // Set up mock provider source that mocks out downloading hashicorp/test v9.9.9 via HTTP. + // The test fixtures include a lock file and backend state file that specify v1.2.3 of the hashicorpt/test provider. + // In this test scenario we perform an upgrade. This causes v9.9.9 to be downloaded. + source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("9.9.9")) + + // Allow the test to respond to the pause in provider installation for + // checking the state storage provider. + _ = testInputMap(t, map[string]string{ + "approve": "yes", + }) + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-migrate-state=true", + "-upgrade", + "-safe-init", + } + code := c.Run(args) + testOutput := done(t) + if code != 0 { + t.Fatalf("expected 0 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Terraform has been successfully initialized!" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + expectedReason := "State store provider \"test\" (hashicorp/test) version changed from 1.2.3 to 9.9.9" + if !strings.Contains(output, expectedReason) { + t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output) + } + + // check state remains accessible after migration + if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { + t.Fatal("expected the default workspace to exist after migration, but it is missing") + } + }) } // Test a scenario where the configuration changes but the -backend-config CLI flags compensate for those changes From 388bcbd8db36abed038c662abfd9a822117d736f Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 27 Feb 2026 17:11:16 +0000 Subject: [PATCH 06/13] test: Update test to show that init -upgrade fails when -safe-init isn't provided, and passes when the flag is present. --- internal/command/init_test.go | 53 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 18fb34556153..b871028ef515 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -4752,7 +4752,7 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) { } }) - t.Run("upgrading the provider used for state storage via HTTP", func(t *testing.T) { + t.Run("upgrading the provider used for state storage via HTTP requires -safe-init flag", func(t *testing.T) { // Create a temporary working directory with state store configuration // that doesn't match the backend state file td := t.TempDir() @@ -4771,12 +4771,7 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) { // In this test scenario we perform an upgrade. This causes v9.9.9 to be downloaded. source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("9.9.9")) - // Allow the test to respond to the pause in provider installation for - // checking the state storage provider. - _ = testInputMap(t, map[string]string{ - "approve": "yes", - }) - + // INIT #1 - fail upgrade due to needing -safe-init flag ui := new(cli.MockUi) view, done := testView(t) meta := Meta{ @@ -4798,28 +4793,46 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) { "-enable-pluggable-state-storage-experiment=true", "-migrate-state=true", "-upgrade", - "-safe-init", + // -safe-init not present } code := c.Run(args) testOutput := done(t) - if code != 0 { - t.Fatalf("expected 0 exit code, got %d, output: \n%s", code, testOutput.All()) + if code != 1 { + t.Fatalf("expected 1 exit code, got %d, output: \n%s", code, testOutput.All()) } - - // Check output output := testOutput.All() - expectedMsg := "Terraform has been successfully initialized!" + expectedMsg := "Error: State storage providers must be downloaded using -safe-init flag" if !strings.Contains(output, expectedMsg) { t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) } - expectedReason := "State store provider \"test\" (hashicorp/test) version changed from 1.2.3 to 9.9.9" - if !strings.Contains(output, expectedReason) { - t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output) - } - // check state remains accessible after migration - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { - t.Fatal("expected the default workspace to exist after migration, but it is missing") + // INIT #2 - successful upgrade due to presence of -safe-init flag + // + // Allow the test to respond to the pause in provider installation for + // checking the state storage provider. + _ = testInputMap(t, map[string]string{ + "approve": "yes", + }) + + ui = new(cli.MockUi) + view, done = testView(t) + c.Meta.View = view + c.Meta.Ui = ui + args = []string{ + "-enable-pluggable-state-storage-experiment=true", + "-migrate-state=true", + "-upgrade", + "-safe-init", + } + code = c.Run(args) + testOutput = done(t) + if code != 0 { + t.Fatalf("expected 0 exit code, got %d, output: \n%s", code, testOutput.All()) + } + output = testOutput.All() + expectedMsg = "Terraform has been successfully initialized!" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) } }) } From c9b67fb21c1f8043342e96ce8a93482ebb61ce7a Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 12:55:45 +0000 Subject: [PATCH 07/13] feat: Show users the platform that the provider is going to be used with, to help when interpreting hashes --- internal/command/init_run.go | 3 +++ internal/command/init_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 2e14d5d8e6bf..3d7794f981ec 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -411,12 +412,14 @@ func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider ad v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ Id: "approve", Query: fmt.Sprintf(`Do you want to use provider %q (%s), version %s, for managing state? +Platform: %s Hashes: %s `, lock.Provider().Type, lock.Provider(), lock.Version(), + getproviders.CurrentPlatform.String(), hashList.String(), ), Description: fmt.Sprintf(`Check the dependency lockfile's entry for %q. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index b871028ef515..c0706c9500fb 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3560,6 +3560,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { // Check output when prompting for approval expectedInputPromptMsg := []string{ "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", + getproviders.CurrentPlatform.String(), "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", } for _, expected := range expectedInputPromptMsg { From b59831e3d6d12c1d7608e65d86f61245ec982d8d Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 13:02:36 +0000 Subject: [PATCH 08/13] feat: Update user prompt text when asking whether to trust a downloaded provider Previously this asked users to refer to the dependency lock file, but this wouldn't have been updated during the ongoing init operation and would contain old information. --- internal/command/init_run.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 3d7794f981ec..d1852fee1d76 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -422,8 +422,8 @@ Hashes: getproviders.CurrentPlatform.String(), hashList.String(), ), - Description: fmt.Sprintf(`Check the dependency lockfile's entry for %q. - Only 'yes' will be accepted to confirm.`, lock.Provider()), + Description: fmt.Sprintf(`Check the details above for provider %q and confirm that you trust the provider. + Only 'yes' will be accepted to confirm.`, lock.Provider().Type), }) if err != nil { return diags.Append(fmt.Errorf("Failed to approve use of state storage provider: %s", err)) From 38cc8d83033bc8a5187f13fb27e52852165b086f Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 15:10:16 +0000 Subject: [PATCH 09/13] docs: Update code comment to say what scenarios a provider could not be downloaded in. For more discussion see this PR review thread: https://github.com/hashicorp/terraform/pull/38205#discussion_r2895390701 --- internal/command/init.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/command/init.go b/internal/command/init.go index 72007275f75a..59ebcb05d463 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -773,7 +773,11 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config if config.Module.StateStore != nil { location, ok := providerLocations[config.Module.StateStore.ProviderAddr] if !ok { - // The provider was not processed in the FetchPackageBegin callback, so it was already present. + // The provider was not processed in the FetchPackageBegin callback. + // A provider that wasn't downloaded during this init could be because: + // * It was already present from a previous installation. + // * If upgrading, no newer version was available that matched version constraints. + // * Or, the provider is unmanaged/reattached and so download was skipped. log.Printf("[TRACE] init (getProvidersFromConfig): the state storage provider %s (%q) will not be changed in the dependency lock file after provider installation. Either it was already present and/or there was no available upgrade version that matched version constraints.", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr) safeInitAction = SafeInitActionProceed } else { From 7fb69062cee6d42be6d7b8cfcbcab8fde967965a Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 15:57:04 +0000 Subject: [PATCH 10/13] Update output when provider is accepted/rejected, update test assertions, add test showing provider being rejected --- internal/command/init_run.go | 4 +- internal/command/init_test.go | 82 +++++++++++++++++++++++++++++++++- internal/command/views/init.go | 16 +++---- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index d1852fee1d76..bd68caf4803d 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -243,11 +243,11 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks)) if diags.HasErrors() { - view.Output(views.UserRejectedStateStoreProviderMessage) + view.Output(views.StateStoreProviderRejectedMessage) view.Diagnostics(diags) return 1 } - view.Output(views.UserApprovedStateStoreProviderMessage) + view.Output(views.StateStoreProviderApprovedMessage) default: // Handle SafeInitActionInvalid or unexpected action types panic(fmt.Sprintf("When installing providers described in the config Terraform couldn't determine what 'safe init' action should be taken and returned action type %T. This is a bug in Terraform and should be reported.", safeInitAction)) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index c0706c9500fb..dd9377c8b1cd 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3499,7 +3499,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("init: -safe-init enables downloading a state storage provider via HTTP", func(t *testing.T) { + t.Run("init: with -safe-init users can approve downloading a state store provider via HTTP", 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) @@ -3549,6 +3549,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", + "The state store provider was approved", "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } @@ -3577,7 +3578,83 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("init: can safely download state storage provider from a local archive without needing to supply the -safe-init flag", func(t *testing.T) { + t.Run("init: with -safe-init users can reject downloading a state store provider via HTTP", 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) + + // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 via HTTP. + // This stops Terraform auto-approving the provider installation. + source := newMockProviderSourceUsingTestHttpServer(t, addrs.NewDefaultProvider("test"), getproviders.MustParseVersion("1.2.3")) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + + // Allow the test to respond to the pause in provider installation for + // checking the state storage provider. + inputWriter := testInputMap(t, map[string]string{ + "approve": "no", + }) + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: source, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-safe-init", // In this test the provider is downloaded via HTTP so this flag is necessary. + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output via view + output := testOutput.All() + expectedOutputs := []string{ + "The state store provider was rejected", + } + for _, expected := range expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } + } + // Check output when prompting for approval + expectedInputPromptMsg := []string{ + "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", + getproviders.CurrentPlatform.String(), + "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", + } + for _, expected := range expectedInputPromptMsg { + if !strings.Contains(inputWriter.String(), expected) { + t.Fatalf("expected the input prompt to include %q, but got':\n %s", expected, inputWriter.String()) + } + } + + // Assert the dependency lock file was not created + lockFile := filepath.Join(td, ".terraform.lock.hcl") + _, err := os.Stat(lockFile) + if !os.IsNotExist(err) { + t.Fatal("expected dependency lock file to not exist, but it does") + } + }) + + t.Run("init: can safely download state store provider from a local archive without needing to supply the -safe-init flag", 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) @@ -3711,6 +3788,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output = testOutput.All() expectedOutputs = []string{ "Initializing the state store...", + "The state store provider was approved", "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } diff --git a/internal/command/views/init.go b/internal/command/views/init.go index 3f3d7ccacc71..eddb8085fee8 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -199,13 +199,13 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe HumanValue: "\n[reset][bold]Initializing the state store...", JSONValue: "Initializing the state store...", }, - "user_approved_state_store_provider_message": { - HumanValue: "\n[reset][bold]User approved the state storage provider.", - JSONValue: "User approved the state storage provider.", + "state_store_provider_approved_message": { + HumanValue: "\n[reset][bold]The state store provider was approved.", + JSONValue: "The state store provider was approved.", }, - "user_rejected_state_store_provider_message": { - HumanValue: "\n[reset][bold]User rejected the state storage provider.", - JSONValue: "User rejected the state storage provider.", + "state_store_provider_rejected_message": { + HumanValue: "\n[reset][bold]The state store provider was rejected.", + JSONValue: "The state store provider was rejected.", }, "default_workspace_created_message": { HumanValue: defaultWorkspaceCreatedInfo, @@ -351,8 +351,8 @@ const ( InitializingModulesMessage InitMessageCode = "initializing_modules_message" InitializingBackendMessage InitMessageCode = "initializing_backend_message" InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" - UserApprovedStateStoreProviderMessage InitMessageCode = "user_approved_state_store_provider_message" - UserRejectedStateStoreProviderMessage InitMessageCode = "user_rejected_state_store_provider_message" + StateStoreProviderApprovedMessage InitMessageCode = "state_store_provider_approved_message" + StateStoreProviderRejectedMessage InitMessageCode = "state_store_provider_rejected_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" From 67e357effdae3fa8ed1ab7ac3dc2bf7c2829bcca Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 15:58:06 +0000 Subject: [PATCH 11/13] Replace "state storage provider" with "state store provider" in user-facing errors, and in test names --- internal/command/init_run.go | 4 ++-- internal/command/init_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index bd68caf4803d..3b569b48c2a0 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -234,7 +234,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { // If the -safe-init flag isn't present we prompt the user to re-run init so they're opting into the security UX. diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "State storage providers must be downloaded using -safe-init flag", + Summary: "State store providers must be downloaded using -safe-init flag", Detail: "The provider used for state storage needs to be installed safely. Please re-run the \"init\" command with the -safe-init flag.", }) view.Diagnostics(diags) @@ -430,7 +430,7 @@ Hashes: } if v != "yes" { return diags.Append( - fmt.Errorf("State storage provider %q (%s) was not approved by the user", + fmt.Errorf("State store provider %q (%s) was not approved, so init cannot continue.", lock.Provider().Type, lock.Provider(), ), diff --git a/internal/command/init_test.go b/internal/command/init_test.go index dd9377c8b1cd..0083e6325421 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3451,7 +3451,7 @@ func TestInit_testsWithModule(t *testing.T) { // Testing init's behaviors with `state_store` when run in an empty working directory func TestInit_stateStore_newWorkingDir(t *testing.T) { - t.Run("init: error if -safe-init isn't set when downloading the state storage provider via HTTP", func(t *testing.T) { + t.Run("init: error if -safe-init isn't set when downloading the state store provider via HTTP", 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) @@ -3490,7 +3490,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { // Check output output := testOutput.All() expectedOutputs := []string{ - "Error: State storage providers must be downloaded using -safe-init flag", + "Error: State store providers must be downloaded using -safe-init flag", } for _, expectedOutput := range expectedOutputs { if !strings.Contains(output, expectedOutput) { @@ -3760,7 +3760,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } output := testOutput.All() expectedOutputs := []string{ - "Error: State storage providers must be downloaded using -safe-init flag", + "Error: State store providers must be downloaded using -safe-init flag", } for _, expectedOutput := range expectedOutputs { if !strings.Contains(output, expectedOutput) { @@ -4880,7 +4880,7 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) { t.Fatalf("expected 1 exit code, got %d, output: \n%s", code, testOutput.All()) } output := testOutput.All() - expectedMsg := "Error: State storage providers must be downloaded using -safe-init flag" + expectedMsg := "Error: State store providers must be downloaded using -safe-init flag" if !strings.Contains(output, expectedMsg) { t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) } From 5c553e958a087722c44a5fffaf3ad0f9cd415eb8 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 9 Mar 2026 16:40:32 +0000 Subject: [PATCH 12/13] Add provider address to error telling users to use the new flag when downloading a state store provider --- internal/command/init_run.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 3b569b48c2a0..a615952329b3 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -235,7 +235,10 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "State store providers must be downloaded using -safe-init flag", - Detail: "The provider used for state storage needs to be installed safely. Please re-run the \"init\" command with the -safe-init flag.", + Detail: fmt.Sprintf( + "The provider used for state storage (%s) needs to be installed safely. Please re-run the \"init\" command with the -safe-init flag.", + config.Module.StateStore.ProviderAddr.ForDisplay(), + ), }) view.Diagnostics(diags) return 1 From 9e643d0bd3c06695bc508e8600f3d3e20c36265a Mon Sep 17 00:00:00 2001 From: Sarah French Date: Tue, 10 Mar 2026 10:28:39 +0000 Subject: [PATCH 13/13] feat: Enable access to decl range for the required_providers entry used in PSS. This is used during errors related to verifying the provider's installation --- internal/command/init_run.go | 1 + internal/command/init_test.go | 5 +++++ internal/configs/config.go | 23 ++++++++++++----------- internal/configs/config_build.go | 2 +- internal/configs/module.go | 15 +++++++++------ internal/configs/state_store.go | 23 ++++++++++++++--------- 6 files changed, 42 insertions(+), 27 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index a615952329b3..31110495ca12 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -239,6 +239,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { "The provider used for state storage (%s) needs to be installed safely. Please re-run the \"init\" command with the -safe-init flag.", config.Module.StateStore.ProviderAddr.ForDisplay(), ), + Subject: &config.Module.StateStore.RequiredProviderDeclRange, }) view.Diagnostics(diags) return 1 diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 0083e6325421..af6eb2b24164 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3491,6 +3491,11 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Error: State store providers must be downloaded using -safe-init flag", + + // The required_providers entry is referenced in the error message. + // These lines are impacted if the test fixture is altered + "on main.tf line 4, in terraform:", + "4: test = {", } for _, expectedOutput := range expectedOutputs { if !strings.Contains(output, expectedOutput) { diff --git a/internal/configs/config.go b/internal/configs/config.go index a258f941730b..c3018742b323 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -677,31 +677,35 @@ func (c *Config) resolveProviderTypes() map[string]addrs.Provider { return providers } -// resolveStateStoreProviderType gets tfaddr.Provider data for the provider used for pluggable state storage -// and assigns it to the ProviderAddr field in the config's root module's state store data. +// resolveStateStoreProviderData gets tfaddr.Provider data for the provider used for state storage, +// and data about the declaration range for the paired required_providers entry. These values are +// assigned to the relevant fields in the config's state store representation. // -// See the reused function resolveStateStoreProviderType for details about logic. +// See the reused function resolveStateStoreProviderData for details about logic. // If no match is found, an error diagnostic is returned. -func (c *Config) resolveStateStoreProviderType() hcl.Diagnostics { +func (c *Config) resolveStateStoreProviderData() hcl.Diagnostics { var diags hcl.Diagnostics - providerType, typeDiags := resolveStateStoreProviderType(c.Root.Module.ProviderRequirements.RequiredProviders, - *c.Root.Module.StateStore) + providerType, reqDeclRange, typeDiags := resolveStateStoreProviderData( + c.Root.Module.ProviderRequirements.RequiredProviders, + *c.Root.Module.StateStore, + ) if typeDiags.HasErrors() { diags = append(diags, typeDiags...) return diags } + diags = append(diags, typeDiags...) // capture any warnings c.Root.Module.StateStore.ProviderAddr = providerType - return nil + c.Root.Module.StateStore.RequiredProviderDeclRange = reqDeclRange + return diags } // resolveProviderTypesForTests matches resolveProviderTypes except it uses // the information from resolveProviderTypes to resolve the provider types for // providers defined within the configs test files. func (c *Config) resolveProviderTypesForTests(providers map[string]addrs.Provider) { - for _, test := range c.Module.Tests { // testProviders contains the configuration blocks for all the providers @@ -780,7 +784,6 @@ func (c *Config) resolveProviderTypesForTests(providers map[string]addrs.Provide } } } - } else { // This provider is going to load all the providers it can using // simple name matching. @@ -833,7 +836,6 @@ func (c *Config) resolveProviderTypesForTests(providers map[string]addrs.Provide } } - } // ProviderTypes returns the FQNs of each distinct provider type referenced @@ -895,7 +897,6 @@ func (c *Config) ResolveAbsProviderAddr(addr addrs.ProviderConfig, inModule addr default: panic(fmt.Sprintf("cannot ResolveAbsProviderAddr(%v, ...)", addr)) } - } // ProviderForConfigAddr returns the FQN for a given addrs.ProviderConfig, first diff --git a/internal/configs/config_build.go b/internal/configs/config_build.go index 84abf6df3b82..94b15e0c3d70 100644 --- a/internal/configs/config_build.go +++ b/internal/configs/config_build.go @@ -59,7 +59,7 @@ func FinalizeConfig(cfg *Config, walker ModuleWalker, loader MockDataLoader) hcl cfg.resolveProviderTypesForTests(providers) if cfg.Module != nil && cfg.Module.StateStore != nil { - stateProviderDiags := cfg.resolveStateStoreProviderType() + stateProviderDiags := cfg.resolveStateStoreProviderData() diags = append(diags, stateProviderDiags...) } } diff --git a/internal/configs/module.go b/internal/configs/module.go index 5437360713c9..0de0d0fbd4a4 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -191,7 +191,7 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { mod.gatherProviderLocalNames() if mod.StateStore != nil { - diags = append(diags, mod.resolveStateStoreProviderType()...) + diags = append(diags, mod.resolveStateStoreProviderData()...) } return mod, diags @@ -895,23 +895,26 @@ func (m *Module) gatherProviderLocalNames() { m.ProviderLocalNames = providers } -// resolveStateStoreProviderType uses the processed module to get tfaddr.Provider data for the provider -// used for pluggable state storage, and assigns it to the ProviderAddr field in the module's state store data. +// resolveStateStoreProviderData gets tfaddr.Provider data for the provider used for state storage, +// and data about the declaration range for the paired required_providers entry. These values are +// assigned to the relevant fields in the module's state store representation. // -// See the reused function resolveStateStoreProviderType for details about logic. +// See the reused function resolveStateStoreProviderData for details about logic. // If no match is found, an error diagnostic is returned. -func (m *Module) resolveStateStoreProviderType() hcl.Diagnostics { +func (m *Module) resolveStateStoreProviderData() hcl.Diagnostics { var diags hcl.Diagnostics - providerType, typeDiags := resolveStateStoreProviderType(m.ProviderRequirements.RequiredProviders, + providerType, reqDeclRange, typeDiags := resolveStateStoreProviderData(m.ProviderRequirements.RequiredProviders, *m.StateStore) if typeDiags.HasErrors() { diags = append(diags, typeDiags...) return diags } + diags = append(diags, typeDiags...) // capture any warnings m.StateStore.ProviderAddr = providerType + m.StateStore.RequiredProviderDeclRange = reqDeclRange return diags } diff --git a/internal/configs/state_store.go b/internal/configs/state_store.go index 868e8ee4de35..efc73e440ec1 100644 --- a/internal/configs/state_store.go +++ b/internal/configs/state_store.go @@ -38,8 +38,12 @@ type StateStore struct { // and is used in diagnostics ProviderAddr tfaddr.Provider - TypeRange hcl.Range - DeclRange hcl.Range + // hcl.Range data is stored for use in diagnostics. + // Different ranges are relevant for different error types, + // e.g. unknown store type, invalid configuration, missing required_provider entry. + TypeRange hcl.Range + DeclRange hcl.Range + RequiredProviderDeclRange hcl.Range } func decodeStateStoreBlock(block *hcl.Block) (*StateStore, hcl.Diagnostics) { @@ -104,10 +108,11 @@ var StateStorageBlockSchema = &hcl.BodySchema{ }, } -// resolveStateStoreProviderType is used to obtain provider source data from required_providers data. -// The only exception is the builtin terraform provider, which we return source data for without using required_providers. +// resolveStateStoreProviderData updates the provided StateStore used to include data from required_providers. +// The StateStore struct is updated to include the provider address and the decl range of the matching required_providers entry. +// Special handling is required for the builtin terraform provider, which we don't expect to be in required_providers. // This code is reused in code for parsing config and modules. -func resolveStateStoreProviderType(requiredProviders map[string]*RequiredProvider, stateStore StateStore) (tfaddr.Provider, hcl.Diagnostics) { +func resolveStateStoreProviderData(requiredProviders map[string]*RequiredProvider, stateStore StateStore) (tfaddr.Provider, hcl.Range, hcl.Diagnostics) { var diags hcl.Diagnostics // We intentionally don't look for entries in required_providers under different local names and match them @@ -118,7 +123,7 @@ func resolveStateStoreProviderType(requiredProviders map[string]*RequiredProvide // We do not expect users to include built in providers in required_providers // So, if we don't find an entry in required_providers under local name 'terraform' we assume // that the builtin provider is intended. - return addrs.NewBuiltInProvider("terraform"), nil + return addrs.NewBuiltInProvider("terraform"), hcl.Range{}, diags case !foundReqProviderEntry: diags = diags.Append( &hcl.Diagnostic{ @@ -130,12 +135,12 @@ func resolveStateStoreProviderType(requiredProviders map[string]*RequiredProvide Subject: &stateStore.DeclRange, }, ) - return tfaddr.Provider{}, diags + return tfaddr.Provider{}, hcl.Range{}, diags default: // We've got a required_providers entry to use // This code path is used for both re-attached providers - // providers that are fully managed by Terraform. - return addr.Type, nil + // and providers that are fully managed by Terraform. + return addr.Type, addr.DeclRange, diags } }