From f661ebfd5cacc238f17aa0a3c18673611057d228 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 16 Apr 2026 15:47:28 +0100 Subject: [PATCH 01/14] test: Remove temporary test case; it'll now be replaced with tests using the new newMockProviderSourceUsingTestHttpServer method --- internal/command/init_test.go | 108 ---------------------------------- 1 file changed, 108 deletions(-) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 6316dc3f8036..312d8422cde5 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3842,114 +3842,6 @@ 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() - 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, map[string][]string{ - // The test fixture config has no version constraints, so the latest version will - // be used; below 1.2.3 is the 'latest' version in the test world. - "hashicorp/test": {"1.0.0", "1.2.3"}, - }) - - 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 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("temporary: test showing use of network mirror 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 network mirror - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - - // Set up mock provider source that mocks out downloading hashicorp/test v1.2.3 from a network mirror. - source := newHTTPMirrorProviderSourceUsingTestHttpServer(t, map[string][]string{ - "hashicorp/test": {"1.0.0", "1.2.3"}, - }, true) - - 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...", - " Installed hashicorp/test v1.2.3 (verified checksum)", // verified checksum message due to hashes matching those described by the network mirror. - "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 the default workspace is not made by default", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() From 2ad24d9c3f1629a98777ebb7672dfc76f647adf8 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 16 Apr 2026 15:26:11 +0100 Subject: [PATCH 02/14] refactor: Replace use of prepareInstallerEvents method. This will allow finer control of callbacks when implementing security related features --- internal/command/init.go | 696 +++++++++++++++++++++++++++++++++++---- 1 file changed, 631 insertions(+), 65 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 178e2fd4bc8a..b09d1b16a3e7 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -420,7 +420,290 @@ 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) + initMsg := views.InitializingProviderPluginFromConfigMessage + reuseMsg := views.ReusingPreviousVersionInfo + evts := &providercache.InstallerEvents{ + PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { + view.Output(initMsg) + }, + 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(reuseMsg, provider.ForDisplay()) + } else { + if len(versionConstraints) > 0 { + view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) + } else { + view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) + } + } + }, + LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { + view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) + }, + FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { + view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) + }, + QueryPackagesFailure: func(provider addrs.Provider, err error) { + switch errorTy := err.(type) { + case getproviders.ErrProviderNotFound: + sources := errorTy.Sources + displaySources := make([]string, len(sources)) + for i, source := range sources { + displaySources[i] = fmt.Sprintf(" - %s", source) + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to query available provider packages", + fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", + provider.ForDisplay(), err, strings.Join(displaySources, "\n"), + ), + )) + case getproviders.ErrRegistryProviderNotKnown: + // We might be able to suggest an alternative provider to use + // instead of this one. + suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) + alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) + if alternative != provider { + suggestion = fmt.Sprintf( + "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", + alternative.ForDisplay(), provider.ForDisplay(), + ) + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to query available provider packages", + fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", + provider.ForDisplay(), err, suggestion, + ), + )) + case getproviders.ErrHostNoProviders: + switch { + case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: + // If a user copies the URL of a GitHub repository into + // the source argument and removes the schema to make it + // provider-address-shaped then that's one way we can end up + // here. We'll use a specialized error message in anticipation + // of that mistake. We only do this if github.com isn't a + // provider registry, to allow for the (admittedly currently + // rather unlikely) possibility that github.com starts being + // a real Terraform provider registry in the future. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider registry host", + fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", + provider.String(), + ), + )) + + case errorTy.HasOtherVersion: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider registry host", + fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", + errorTy.Hostname, provider.String(), + ), + )) + + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider registry host", + fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", + errorTy.Hostname, provider.String(), + ), + )) + } + + case getproviders.ErrRequestCanceled: + // We don't attribute cancellation to any particular operation, + // but rather just emit a single general message about it at + // the end, by checking ctx.Err(). + + default: + suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to query available provider packages", + fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", + provider.ForDisplay(), err, suggestion, + ), + )) + } + }, + QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { + displayWarnings := make([]string, len(warnings)) + for i, warning := range warnings { + displayWarnings[i] = fmt.Sprintf("- %s", warning) + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Additional provider information from registry", + fmt.Sprintf("The remote registry returned warnings for %s:\n%s", + provider.String(), + strings.Join(displayWarnings, "\n"), + ), + )) + }, + LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to install provider from shared cache", + fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), + )) + }, + FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) { + const summaryIncompatible = "Incompatible provider version" + switch err := err.(type) { + case getproviders.ErrProtocolNotSupported: + closestAvailable := err.Suggestion + switch { + case closestAvailable == getproviders.UnspecifiedVersion: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + summaryIncompatible, + fmt.Sprintf(errProviderVersionIncompatible, provider.String()), + )) + case version.GreaterThan(closestAvailable): + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + summaryIncompatible, + fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), + version, tfversion.String(), closestAvailable, closestAvailable, + getproviders.VersionConstraintsString(reqs[provider]), + ), + )) + default: // version is less than closestAvailable + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + summaryIncompatible, + fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), + version, tfversion.String(), closestAvailable, closestAvailable, + getproviders.VersionConstraintsString(reqs[provider]), + ), + )) + } + case getproviders.ErrPlatformNotSupported: + switch { + case err.MirrorURL != nil: + // If we're installing from a mirror then it may just be + // the mirror lacking the package, rather than it being + // unavailable from upstream. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + summaryIncompatible, + fmt.Sprintf( + "Your chosen provider mirror at %s does not have a %s v%s package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so this provider might not support your current platform. Alternatively, the mirror itself might have only a subset of the plugin packages available in the origin registry, at %s.", + err.MirrorURL, err.Provider, err.Version, err.Platform, + err.Provider.Hostname, + ), + )) + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + summaryIncompatible, + fmt.Sprintf( + "Provider %s v%s does not have a package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.", + err.Provider, err.Version, err.Platform, + ), + )) + } + + case getproviders.ErrRequestCanceled: + // We don't attribute cancellation to any particular operation, + // but rather just emit a single general message about it at + // the end, by checking ctx.Err(). + + default: + // We can potentially end up in here under cancellation too, + // in spite of our getproviders.ErrRequestCanceled case above, + // because not all of the outgoing requests we do under the + // "fetch package" banner are source metadata requests. + // In that case we will emit a redundant error here about + // the request being cancelled, but we'll still detect it + // as a cancellation after the installer returns and do the + // normal cancellation handling. + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to install provider", + fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), + )) + } + }, + FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { + var keyID string + if authResult != nil && authResult.ThirdPartySigned() { + keyID = authResult.KeyID + } + if keyID != "" { + keyID = view.PrepareMessage(views.KeyID, keyID) + } + + view.LogInitMessage(views.InstalledProviderVersionInfo, provider.ForDisplay(), version, authResult, keyID) + }, + ProvidersLockUpdated: func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { + // We're going to use this opportunity to track if we have any + // "incomplete" installs of providers. An incomplete install is + // when we are only going to write the local hashes into our lock + // file which means a `terraform init` command will fail in future + // when used on machines of a different architecture. + // + // We want to print a warning about this. + + if len(signedHashes) > 0 { + // If we have any signedHashes hashes then we don't worry - as + // we know we retrieved all available hashes for this version + // anyway. + return + } + + // If local hashes and prior hashes are exactly the same then + // it means we didn't record any signed hashes previously, and + // we know we're not adding any extra in now (because we already + // checked the signedHashes), so that's a problem. + // + // In the actual check here, if we have any priorHashes and those + // hashes are not the same as the local hashes then we're going to + // accept that this provider has been configured correctly. + if len(priorHashes) > 0 && !reflect.DeepEqual(localHashes, priorHashes) { + return + } + + // Now, either signedHashes is empty, or priorHashes is exactly the + // same as our localHashes which means we never retrieved the + // signedHashes previously. + // + // Either way, this is bad. Let's complain/warn. + 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 @@ -457,81 +740,364 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config diags = diags.Append(err) } - return true, nil, diags - } + return true, nil, diags + } + + return true, configLocks, diags +} + +// getProvidersFromState determines what providers are required by the given state data. +// The method downloads any missing providers that aren't already downloaded and then returns +// dependency lock data based on the state. +// The calling code is assumed to have already called getProvidersFromConfig, which is used to +// supply the configLocks argument. +// The dependency lock file itself isn't updated here. +func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.State, configLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { + ctx, span := tracer.Start(ctx, "install providers from state") + defer span.End() + + if state == nil { + // if there is no state there are no providers to get + return true, depsfile.NewLocks(), nil + } + reqs := state.ProviderRequirements() + + for providerAddr := range reqs { + if providerAddr.IsLegacy() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid legacy provider address", + fmt.Sprintf( + "This configuration or its associated state refers to the unqualified provider %q.\n\nYou must complete the Terraform 0.13 upgrade process before upgrading to later versions.", + providerAddr.Type, + ), + )) + } + } + if diags.HasErrors() { + return false, nil, diags + } + + // The locks below are used to avoid re-downloading any providers in the + // second download step. + // We combine any locks from the dependency lock file and locks identified + // from the configuration + var moreDiags tfdiags.Diagnostics + previousLocks, moreDiags := c.lockedDependencies() + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return false, nil, diags + } + inProgressLocks := c.mergeLockedDependencies(configLocks, previousLocks) + + var inst *providercache.Installer + if len(pluginDirs) == 0 { + // By default we use a source that looks for providers in all of the + // standard locations, possibly customized by the user in CLI config. + inst = c.providerInstaller() + } else { + // If the user passes at least one -plugin-dir then that circumvents + // the usual sources and forces Terraform to consult only the given + // directories. Anything not available in one of those directories + // is not available for installation. + source := c.providerCustomLocalDirectorySource(pluginDirs) + inst = c.providerInstallerCustomSource(source) + + // The default (or configured) search paths are logged earlier, in provider_source.go + // Log that those are being overridden by the `-plugin-dir` command line options + log.Println("[DEBUG] init: overriding provider plugin search paths") + log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) + } + + // 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. + initMsg := views.InitializingProviderPluginFromStateMessage + reuseMsg := views.ReusingVersionIdentifiedFromConfig + evts := &providercache.InstallerEvents{ + PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { + view.Output(initMsg) + }, + 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(reuseMsg, provider.ForDisplay()) + } else { + if len(versionConstraints) > 0 { + view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) + } else { + view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) + } + } + }, + LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { + view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) + }, + FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { + view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) + }, + QueryPackagesFailure: func(provider addrs.Provider, err error) { + switch errorTy := err.(type) { + case getproviders.ErrProviderNotFound: + sources := errorTy.Sources + displaySources := make([]string, len(sources)) + for i, source := range sources { + displaySources[i] = fmt.Sprintf(" - %s", source) + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to query available provider packages", + fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", + provider.ForDisplay(), err, strings.Join(displaySources, "\n"), + ), + )) + case getproviders.ErrRegistryProviderNotKnown: + // We might be able to suggest an alternative provider to use + // instead of this one. + suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) + alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) + if alternative != provider { + suggestion = fmt.Sprintf( + "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", + alternative.ForDisplay(), provider.ForDisplay(), + ) + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to query available provider packages", + fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", + provider.ForDisplay(), err, suggestion, + ), + )) + case getproviders.ErrHostNoProviders: + switch { + case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: + // If a user copies the URL of a GitHub repository into + // the source argument and removes the schema to make it + // provider-address-shaped then that's one way we can end up + // here. We'll use a specialized error message in anticipation + // of that mistake. We only do this if github.com isn't a + // provider registry, to allow for the (admittedly currently + // rather unlikely) possibility that github.com starts being + // a real Terraform provider registry in the future. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider registry host", + fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", + provider.String(), + ), + )) + + case errorTy.HasOtherVersion: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider registry host", + fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", + errorTy.Hostname, provider.String(), + ), + )) - return true, configLocks, diags -} + 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(), + ), + )) + } -// getProvidersFromState determines what providers are required by the given state data. -// The method downloads any missing providers that aren't already downloaded and then returns -// dependency lock data based on the state. -// The calling code is assumed to have already called getProvidersFromConfig, which is used to -// supply the configLocks argument. -// The dependency lock file itself isn't updated here. -func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.State, configLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "install providers from state") - defer span.End() + 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(). - if state == nil { - // if there is no state there are no providers to get - return true, depsfile.NewLocks(), nil - } - reqs := state.ProviderRequirements() + 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) + } - for providerAddr := range reqs { - if providerAddr.IsLegacy() { diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid legacy provider address", - fmt.Sprintf( - "This configuration or its associated state refers to the unqualified provider %q.\n\nYou must complete the Terraform 0.13 upgrade process before upgrading to later versions.", - providerAddr.Type, + tfdiags.Warning, + "Additional provider information from registry", + fmt.Sprintf("The remote registry returned warnings for %s:\n%s", + provider.String(), + strings.Join(displayWarnings, "\n"), ), )) - } - } - if diags.HasErrors() { - return false, nil, diags - } + }, + 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, + ), + )) + } - // The locks below are used to avoid re-downloading any providers in the - // second download step. - // We combine any locks from the dependency lock file and locks identified - // from the configuration - var moreDiags tfdiags.Diagnostics - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - if diags.HasErrors() { - return false, nil, diags - } - inProgressLocks := c.mergeLockedDependencies(configLocks, previousLocks) + 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(). - var inst *providercache.Installer - if len(pluginDirs) == 0 { - // By default we use a source that looks for providers in all of the - // standard locations, possibly customized by the user in CLI config. - inst = c.providerInstaller() - } else { - // If the user passes at least one -plugin-dir then that circumvents - // the usual sources and forces Terraform to consult only the given - // directories. Anything not available in one of those directories - // is not available for installation. - source := c.providerCustomLocalDirectorySource(pluginDirs) - inst = c.providerInstallerCustomSource(source) + 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. - // The default (or configured) search paths are logged earlier, in provider_source.go - // Log that those are being overridden by the `-plugin-dir` command line options - log.Println("[DEBUG] init: overriding provider plugin search paths") - log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) - } + 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) + } - // 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 := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) + 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 From 6c71dfe5090afd84a054e22beb8549f6128941ec Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 16 Apr 2026 15:31:39 +0100 Subject: [PATCH 03/14] refactor: Remove unused prepareInstallerEvents method --- internal/command/init.go | 296 --------------------------------------- 1 file changed, 296 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index b09d1b16a3e7..4eb262fd9079 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -31,7 +31,6 @@ import ( "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/didyoumean" "github.com/hashicorp/terraform/internal/getproviders" - "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/getproviders/reattach" "github.com/hashicorp/terraform/internal/providercache" "github.com/hashicorp/terraform/internal/states" @@ -1197,301 +1196,6 @@ func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLo return output, diags } -// prepareInstallerEvents returns an instance of *providercache.InstallerEvents. This struct defines callback functions that will be executed -// when a specific type of event occurs during provider installation. -// The calling code needs to provide a tfdiags.Diagnostics collection, so that provider installation code returns diags to the calling code using closures -func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags *tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { - // 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. - events := &providercache.InstallerEvents{ - PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(initMsg) - }, - 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(reuseMsg, provider.ForDisplay()) - } else { - if len(versionConstraints) > 0 { - view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) - } else { - view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) - } - } - }, - LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) - }, - FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { - view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) - }, - QueryPackagesFailure: func(provider addrs.Provider, err error) { - switch errorTy := err.(type) { - case getproviders.ErrProviderNotFound: - sources := errorTy.Sources - displaySources := make([]string, len(sources)) - for i, source := range sources { - displaySources[i] = fmt.Sprintf(" - %s", source) - } - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", - provider.ForDisplay(), err, strings.Join(displaySources, "\n"), - ), - )) - case getproviders.ErrRegistryProviderNotKnown: - // We might be able to suggest an alternative provider to use - // instead of this one. - suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) - alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) - if alternative != provider { - suggestion = fmt.Sprintf( - "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", - alternative.ForDisplay(), provider.ForDisplay(), - ) - } - - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - case getproviders.ErrHostNoProviders: - switch { - case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: - // If a user copies the URL of a GitHub repository into - // the source argument and removes the schema to make it - // provider-address-shaped then that's one way we can end up - // here. We'll use a specialized error message in anticipation - // of that mistake. We only do this if github.com isn't a - // provider registry, to allow for the (admittedly currently - // rather unlikely) possibility that github.com starts being - // a real Terraform provider registry in the future. - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", - provider.String(), - ), - )) - - case errorTy.HasOtherVersion: - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", - errorTy.Hostname, provider.String(), - ), - )) - - default: - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", - errorTy.Hostname, provider.String(), - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - } - }, - QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { - displayWarnings := make([]string, len(warnings)) - for i, warning := range warnings { - displayWarnings[i] = fmt.Sprintf("- %s", warning) - } - - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Additional provider information from registry", - fmt.Sprintf("The remote registry returned warnings for %s:\n%s", - provider.String(), - strings.Join(displayWarnings, "\n"), - ), - )) - }, - LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider from shared cache", - fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), - )) - }, - FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - const summaryIncompatible = "Incompatible provider version" - switch err := err.(type) { - case getproviders.ErrProtocolNotSupported: - closestAvailable := err.Suggestion - switch { - case closestAvailable == getproviders.UnspecifiedVersion: - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(errProviderVersionIncompatible, provider.String()), - )) - case version.GreaterThan(closestAvailable): - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - default: // version is less than closestAvailable - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - } - case getproviders.ErrPlatformNotSupported: - switch { - case err.MirrorURL != nil: - // If we're installing from a mirror then it may just be - // the mirror lacking the package, rather than it being - // unavailable from upstream. - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Your chosen provider mirror at %s does not have a %s v%s package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so this provider might not support your current platform. Alternatively, the mirror itself might have only a subset of the plugin packages available in the origin registry, at %s.", - err.MirrorURL, err.Provider, err.Version, err.Platform, - err.Provider.Hostname, - ), - )) - default: - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Provider %s v%s does not have a package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.", - err.Provider, err.Version, err.Platform, - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - // We can potentially end up in here under cancellation too, - // in spite of our getproviders.ErrRequestCanceled case above, - // because not all of the outgoing requests we do under the - // "fetch package" banner are source metadata requests. - // In that case we will emit a redundant error here about - // the request being cancelled, but we'll still detect it - // as a cancellation after the installer returns and do the - // normal cancellation handling. - - *diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider", - fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), - )) - } - }, - FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { - var keyID string - if authResult != nil && authResult.ThirdPartySigned() { - keyID = authResult.KeyID - } - if keyID != "" { - keyID = view.PrepareMessage(views.KeyID, keyID) - } - - view.LogInitMessage(views.InstalledProviderVersionInfo, provider.ForDisplay(), version, authResult, keyID) - }, - ProvidersLockUpdated: func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { - // We're going to use this opportunity to track if we have any - // "incomplete" installs of providers. An incomplete install is - // when we are only going to write the local hashes into our lock - // file which means a `terraform init` command will fail in future - // when used on machines of a different architecture. - // - // We want to print a warning about this. - - if len(signedHashes) > 0 { - // If we have any signedHashes hashes then we don't worry - as - // we know we retrieved all available hashes for this version - // anyway. - return - } - - // If local hashes and prior hashes are exactly the same then - // it means we didn't record any signed hashes previously, and - // we know we're not adding any extra in now (because we already - // checked the signedHashes), so that's a problem. - // - // In the actual check here, if we have any priorHashes and those - // hashes are not the same as the local hashes then we're going to - // accept that this provider has been configured correctly. - if len(priorHashes) > 0 && !reflect.DeepEqual(localHashes, priorHashes) { - return - } - - // Now, either signedHashes is empty, or priorHashes is exactly the - // same as our localHashes which means we never retrieved the - // signedHashes previously. - // - // Either way, this is bad. Let's complain/warn. - 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) - } - }, - } - - return events -} - // backendConfigOverrideBody interprets the raw values of -backend-config // arguments into a hcl Body that should override the backend settings given // in the configuration. From 2d491c024b5c56a545bacbfa5422f74523874be3 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 16 Apr 2026 16:18:13 +0100 Subject: [PATCH 04/14] feat: Users are prompted to approve a provider used for PSS on first use, and only if downloaded via HTTP. --- internal/command/init.go | 78 +++++++- internal/command/init_run.go | 67 ++++++- internal/command/init_test.go | 322 +++++++++++++++++++++++++++++++++ internal/command/views/init.go | 10 + 4 files changed, 468 insertions(+), 9 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 4eb262fd9079..4c50eeab152b 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -359,11 +359,23 @@ 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' + 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() @@ -379,7 +391,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) @@ -397,7 +409,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 @@ -419,6 +431,15 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) } + // 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) + initMsg := views.InitializingProviderPluginFromConfigMessage reuseMsg := views.ReusingPreviousVersionInfo evts := &providercache.InstallerEvents{ @@ -454,6 +475,14 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config }, 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) { @@ -710,7 +739,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 @@ -720,7 +749,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 @@ -729,7 +758,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 @@ -739,10 +768,43 @@ 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 + } else { + location, ok := providerLocations[config.Module.StateStore.ProviderAddr] + if !ok { + // 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 { + // 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. diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 9fed22213360..82b2d586aa51 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -4,16 +4,20 @@ 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/getproviders" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -210,7 +214,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 +224,24 @@ 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 already trusted and there's no need to notify the user. + case SafeInitActionPromptForInput: + diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks)) + if diags.HasErrors() { + view.Output(views.StateStoreProviderRejectedMessage) + view.Diagnostics(diags) + return 1 + } + 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)) + } + // The init command is not allowed to upgrade the provider used for PSS (unless we're reconfiguring the state store). // Unless users choose to reconfigure, they must upgrade the state store provider separately using `terraform state migrate -upgrade`. if initArgs.Upgrade && !initArgs.Reconfigure && config.Module.StateStore != nil { @@ -391,3 +413,46 @@ If you do not intend to upgrade the state store provider, please update your con } 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) + + 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? +Platform: %s +Hashes: +%s +`, + lock.Provider().Type, + lock.Provider(), + lock.Version(), + getproviders.CurrentPlatform.String(), + hashList.String(), + ), + 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)) + } + if v != "yes" { + return diags.Append( + fmt.Errorf("State store provider %q (%s) was not approved, so init cannot continue.", + lock.Provider().Type, + lock.Provider(), + ), + ) + } + return diags +} diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 312d8422cde5..4d572216384c 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3842,6 +3842,328 @@ 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("users do not need to approve trusting a state store provider if it's installed from local archive", 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 := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) + + 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 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("users can interactively approve trusting a state store provider downloaded 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, map[string][]string{ + "hashicorp/test": {"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": "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", + } + 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 via view + output := testOutput.All() + expectedOutputs := []string{ + "Initializing the state store...", + "The state store provider was approved", + "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) + } + } + // 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 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("users can reject a state store provider downloaded 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, map[string][]string{ + "hashicorp/test": {"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", + } + 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("if user rejects a provider and re-attempts the init command they're prompted 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. + mockProviderAddress := addrs.NewDefaultProvider("test") + mockProviderVersion := getproviders.MustParseVersion("1.2.3") + source := newMockProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) + + // Set up providers for use in the second init attempt after the user adds the -safe-init flag. + mockProvider := mockPluggableStateStorageProvider() + + 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 - reject the provider + _ = testInputMap(t, map[string]string{ + "approve": "no", + }) + 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{ + "The state store provider was rejected", + } + for _, expectedOutput := range expectedOutputs { + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) + } + } + + // The rejected provider is present in the local cache after being rejected. + // However, this doesn't stop the user being prompted again. + cacheDir := meta.providerLocalCacheDir() + gotPackages := cacheDir.AllAvailablePackages() + wantPackages := map[addrs.Provider][]providercache.CachedProvider{ + // "between" wasn't previously installed at all, so we installed + // the newest available version that matched the version constraints. + mockProviderAddress: { + { + Provider: mockProviderAddress, + Version: mockProviderVersion, + PackageDir: expectedPackageInstallPath(mockProviderAddress.Type, mockProviderVersion.String(), false), + }, + }, + } + if diff := cmp.Diff(wantPackages, gotPackages); diff != "" { + t.Errorf("wrong cache directory contents after upgrade\n%s", diff) + } + + // Init number 2 - re-prompted for approval + _ = testInputMap(t, map[string]string{ + "approve": "yes", + }) + args = []string{ + "-enable-pluggable-state-storage-experiment=true", + } + 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...", + "The state store provider was approved", + "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 the default workspace is not made by default", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() diff --git a/internal/command/views/init.go b/internal/command/views/init.go index ce4f1598cbc6..dcc6a9bc9e85 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...", }, + "state_store_provider_approved_message": { + HumanValue: "\n[reset][bold]The state store provider was approved.", + JSONValue: "The state store provider was approved.", + }, + "state_store_provider_rejected_message": { + HumanValue: "\n[reset][bold]The state store provider was rejected.", + JSONValue: "The state store provider was rejected.", + }, "dependencies_lock_changes_info": { HumanValue: dependenciesLockChangesInfo, JSONValue: dependenciesLockChangesInfo, @@ -339,6 +347,8 @@ const ( InitializingModulesMessage InitMessageCode = "initializing_modules_message" InitializingBackendMessage InitMessageCode = "initializing_backend_message" InitializingStateStoreMessage InitMessageCode = "initializing_state_store_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 bde9564c173c091cfe4f3244c9bb6b24aa34f6ca Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 17 Apr 2026 11:47:23 +0100 Subject: [PATCH 05/14] Prefer "state storage" over "pluggable state storage" --- internal/command/init_run.go | 6 ++++-- internal/command/init_test.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 82b2d586aa51..eef2a9754ab2 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -224,6 +224,8 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { header = true } + // Prompt the user about trusting the provider used for state storage. + // Course of action depends on the safeInitAction returned from getProvidersFromConfig switch safeInitAction { case SafeInitActionNotRelevant: // do nothing; security features aren't relevant. @@ -242,7 +244,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { 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)) } - // The init command is not allowed to upgrade the provider used for PSS (unless we're reconfiguring the state store). + // The init command is not allowed to upgrade the provider used for state storage (unless we're reconfiguring the state store). // Unless users choose to reconfigure, they must upgrade the state store provider separately using `terraform state migrate -upgrade`. if initArgs.Upgrade && !initArgs.Reconfigure && config.Module.StateStore != nil { pAddr := config.Module.StateStore.ProviderAddr @@ -257,7 +259,7 @@ new lock: %#v`, pAddr.ForDisplay(), old, new)) // The upgrade has impacted the provider diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Cannot upgrade the provider used for pluggable state storage during \"terraform init -upgrade\"", + "Cannot upgrade the provider used for state storage during \"terraform init -upgrade\"", fmt.Sprintf(`While upgrading providers Terraform attempted to upgrade the %s (%q) provider, which is used by the state_store block in your configuration. Please use \"terraform state migrate -upgrade\" to upgrade the state store provider and navigate migrating your state between the two versions. You can then re-attempt \"terraform init -upgrade\" to upgrade the rest of your providers. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 4d572216384c..9bb09f92ca60 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -2492,7 +2492,7 @@ terraform { t.Fatalf("command was not expected to complete successfully, but it did:\n%s", done(t).All()) } output := done(t).Stderr() - expectedError := "Error: Cannot upgrade the provider used for pluggable state storage during \"terraform init -upgrade\"" + expectedError := "Error: Cannot upgrade the provider used for state storage during \"terraform init -upgrade\"" if !strings.Contains(output, expectedError) { t.Fatalf("expected error message not found:\n%s", output) } From cbd221d0fddce608e944a6e053e48d0b104041a5 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 17 Apr 2026 19:47:24 +0100 Subject: [PATCH 06/14] Add signer details to the prompt shown to users --- internal/command/init.go | 29 +++++++++++++------ internal/command/init_run.go | 8 +++-- internal/command/init_test.go | 2 ++ .../getproviders/package_authentication.go | 13 +++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 4c50eeab152b..7bfb43dd8fdb 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -375,7 +375,7 @@ const ( // 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, safeInitAction SafeInitAction, 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, authResult *getproviders.PackageAuthenticationResult, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from config") defer span.End() @@ -391,7 +391,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config reqs, hclDiags := config.ProviderRequirements() diags = diags.Append(hclDiags) if hclDiags.HasErrors() { - return false, nil, SafeInitActionInvalid, diags + return false, nil, SafeInitActionInvalid, nil, diags } reqs = c.removeDevOverrides(reqs) @@ -409,7 +409,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } if diags.HasErrors() { - return false, nil, SafeInitActionInvalid, diags + return false, nil, SafeInitActionInvalid, nil, diags } var inst *providercache.Installer @@ -435,10 +435,14 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // 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 + // The callbacks help create diagnostics based on installation events, output + // messages to the user, + // + // 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) + var stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult initMsg := views.InitializingProviderPluginFromConfigMessage reuseMsg := views.ReusingPreviousVersionInfo @@ -674,6 +678,13 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } }, FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { + // 1. Capture auth result if this provider is used for state storage. + if config.Module.StateStore != nil && provider.Equals(config.Module.StateStore.ProviderAddr) { + log.Printf("[TRACE] getProvidersFromConfig: state storage provider %s (%q) auth result: %q", config.Module.StateStore.ProviderAddr.Type, config.Module.StateStore.ProviderAddr.ForDisplay(), stateStoreProviderAuthResult.String()) + stateStoreProviderAuthResult = authResult + } + + // 2. Log a message about the installed provider. var keyID string if authResult != nil && authResult.ThirdPartySigned() { keyID = authResult.KeyID @@ -739,7 +750,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, SafeInitActionInvalid, diags + return true, nil, SafeInitActionInvalid, nil, diags } mode = providercache.InstallUpgrades @@ -749,7 +760,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) if diags.HasErrors() { - return false, nil, SafeInitActionInvalid, diags + return false, nil, SafeInitActionInvalid, nil, diags } // Determine which required providers are already downloaded, and download any @@ -758,7 +769,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, SafeInitActionInvalid, diags + return true, nil, SafeInitActionInvalid, nil, diags } if err != nil { // The errors captured in "err" should be redundant with what we @@ -768,7 +779,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config diags = diags.Append(err) } - return true, nil, SafeInitActionInvalid, diags + return true, nil, SafeInitActionInvalid, nil, diags } // Return advice to the calling code about what to do regarding safe init feature related to state storage providers @@ -804,7 +815,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } - return true, configLocks, safeInitAction, diags + return true, configLocks, safeInitAction, authResult, diags } // getProvidersFromState determines what providers are required by the given state data. diff --git a/internal/command/init_run.go b/internal/command/init_run.go index eef2a9754ab2..fe2e99421d4b 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -214,7 +214,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) - configProvidersOutput, configLocks, safeInitAction, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + configProvidersOutput, configLocks, safeInitAction, stateStoreProviderAuthResult, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { view.Diagnostics(diags) @@ -232,7 +232,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { case SafeInitActionProceed: // do nothing; provider is already trusted and there's no need to notify the user. case SafeInitActionPromptForInput: - diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks)) + diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks, stateStoreProviderAuthResult)) if diags.HasErrors() { view.Output(views.StateStoreProviderRejectedMessage) view.Diagnostics(diags) @@ -418,7 +418,7 @@ If you do not intend to upgrade the state store provider, please update your con // 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 { +func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider addrs.Provider, configLocks *depsfile.Locks, authResult *getproviders.PackageAuthenticationResult) tfdiags.Diagnostics { var diags tfdiags.Diagnostics // If we can receive input then we prompt for ok from the user @@ -433,6 +433,7 @@ func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider ad Id: "approve", Query: fmt.Sprintf(`Do you want to use provider %q (%s), version %s, for managing state? Platform: %s +Authentication: %s Hashes: %s `, @@ -440,6 +441,7 @@ Hashes: lock.Provider(), lock.Version(), getproviders.CurrentPlatform.String(), + authResult.String(), hashList.String(), ), Description: fmt.Sprintf(`Check the details above for provider %q and confirm that you trust the provider. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 9bb09f92ca60..9ab5f5d4f76a 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3967,6 +3967,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { expectedInputPromptMsg := []string{ "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", getproviders.CurrentPlatform.String(), + "Authentication: unauthenticated", "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", } for _, expected := range expectedInputPromptMsg { @@ -4044,6 +4045,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { expectedInputPromptMsg := []string{ "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", getproviders.CurrentPlatform.String(), + "Authentication: unauthenticated", "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", } for _, expected := range expectedInputPromptMsg { diff --git a/internal/getproviders/package_authentication.go b/internal/getproviders/package_authentication.go index 478dc99cee04..e28e6c1b6b17 100644 --- a/internal/getproviders/package_authentication.go +++ b/internal/getproviders/package_authentication.go @@ -71,6 +71,19 @@ func (t *PackageAuthenticationResult) SignedByHashiCorp() bool { return false } +// SignedByHashiCorpPartner returns whether the package was authenticated as signed +// by a HashiCorp partner. +func (t *PackageAuthenticationResult) SignedByHashiCorpPartner() bool { + if t == nil { + return false + } + if t.result == partnerProvider { + return true + } + + return false +} + // SignedByAnyParty returns whether the package was authenticated as signed // by either HashiCorp or by a third-party. func (t *PackageAuthenticationResult) SignedByAnyParty() bool { From c7084f0745964afa261877cab093cb3fddb1b19c Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 20 Apr 2026 10:13:23 +0100 Subject: [PATCH 07/14] update user prompt to include key ID data --- internal/command/init_run.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/command/init_run.go b/internal/command/init_run.go index fe2e99421d4b..2282d66b86cc 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -429,6 +429,13 @@ func (c *InitCommand) promptStateStorageProviderApproval(stateStorageProvider ad hashList.WriteString(fmt.Sprintf("- %s\n", hash)) } + var authentication string + if authResult != nil && authResult.KeyID != "" { + authentication = fmt.Sprintf("%s, key ID %s", authResult.String(), authResult.KeyID) + } else { + authentication = authResult.String() + } + 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? @@ -441,7 +448,7 @@ Hashes: lock.Provider(), lock.Version(), getproviders.CurrentPlatform.String(), - authResult.String(), + authentication, hashList.String(), ), Description: fmt.Sprintf(`Check the details above for provider %q and confirm that you trust the provider. From 56635175c39e4ea533ffb41dc3a38f79f4ad3ea2 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Thu, 23 Apr 2026 13:52:09 +0100 Subject: [PATCH 08/14] Remove unused (*PackageAuthenticationResult) SignedByHashiCorpPartner method I originally added this to have more control over what we render in the prompt, but I'm walking this back before seeking feedback in review. Could (*PackageAuthenticationResult) .String() be sufficient? --- internal/getproviders/package_authentication.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/getproviders/package_authentication.go b/internal/getproviders/package_authentication.go index e28e6c1b6b17..478dc99cee04 100644 --- a/internal/getproviders/package_authentication.go +++ b/internal/getproviders/package_authentication.go @@ -71,19 +71,6 @@ func (t *PackageAuthenticationResult) SignedByHashiCorp() bool { return false } -// SignedByHashiCorpPartner returns whether the package was authenticated as signed -// by a HashiCorp partner. -func (t *PackageAuthenticationResult) SignedByHashiCorpPartner() bool { - if t == nil { - return false - } - if t.result == partnerProvider { - return true - } - - return false -} - // SignedByAnyParty returns whether the package was authenticated as signed // by either HashiCorp or by a third-party. func (t *PackageAuthenticationResult) SignedByAnyParty() bool { From b549ba2c88ae0664451840893e518121ac369c6e Mon Sep 17 00:00:00 2001 From: Sarah French Date: Fri, 24 Apr 2026 18:30:33 +0100 Subject: [PATCH 09/14] fix: Return correct auth result from getProvidersFromConfig, update test Ahh! --- internal/command/init.go | 2 +- internal/command/init_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 7bfb43dd8fdb..7c437dfed10e 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -815,7 +815,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } - return true, configLocks, safeInitAction, authResult, diags + return true, configLocks, safeInitAction, stateStoreProviderAuthResult, diags } // getProvidersFromState determines what providers are required by the given state data. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 9ab5f5d4f76a..4551ff4f96e7 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3967,7 +3967,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { expectedInputPromptMsg := []string{ "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", getproviders.CurrentPlatform.String(), - "Authentication: unauthenticated", + "Authentication: verified checksum", "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", } for _, expected := range expectedInputPromptMsg { @@ -4045,7 +4045,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { expectedInputPromptMsg := []string{ "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", getproviders.CurrentPlatform.String(), - "Authentication: unauthenticated", + "Authentication: verified checksum", "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", } for _, expected := range expectedInputPromptMsg { From 269bf169763850776c2be5e282d908f493f64fd0 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Tue, 28 Apr 2026 13:07:55 +0100 Subject: [PATCH 10/14] test: Users see "Authentication: unauthenticated" in prompt if network mirror doesn't include hashes --- internal/command/init_test.go | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 4551ff4f96e7..18a33a8e2a97 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3984,6 +3984,88 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) + t.Run("approval prompt reports provider as unauthorized if no hashes returned from the HTTP mirror", 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) + + // The network mirror the provider will be downloaded from will not return any hashes, so + // Terraform won't have any way to check the provider's authenticity. + // This affects the prompt for approval, which this test case focuses on. + returnApprovedHashes := false + source := newHTTPMirrorProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }, returnApprovedHashes) + + 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": "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", + } + 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 via view + output := testOutput.All() + expectedOutputs := []string{ + "Initializing the state store...", + "The state store provider was approved", + "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) + } + } + // 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(), + "Authentication: unauthenticated", + "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") + _, err := os.Stat(lockFile) + if os.IsNotExist(err) { + t.Fatal("expected dependency lock file to exist, but it doesn't") + } + }) + t.Run("users can reject a state store provider downloaded via HTTP", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() From f077eff38e113a3adb99318c8f7a39e1d1a86598 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Tue, 28 Apr 2026 13:09:15 +0100 Subject: [PATCH 11/14] rename test cases --- internal/command/init_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 18a33a8e2a97..e6d8c02d65c4 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3842,7 +3842,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("users do not need to approve trusting a state store provider if it's installed from local archive", func(t *testing.T) { + t.Run("no need to interactively approve a state store provider installed from local archive", 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) @@ -3904,7 +3904,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("users can interactively approve trusting a state store provider downloaded via HTTP", func(t *testing.T) { + t.Run("prompted to approve a state store provider downloaded 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) @@ -4144,7 +4144,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("if user rejects a provider and re-attempts the init command they're prompted on the second attempt", func(t *testing.T) { + t.Run("re-prompt to approve a provider after rejecting that provider in a previous init", 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 b91304d5cc3ad26b8e374ec5668b4d359e2cd8e2 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Tue, 28 Apr 2026 17:18:07 +0100 Subject: [PATCH 12/14] refactor: Simplify how we prepare installation event callbacks by defining reused callbacks Co-authored-by: Copilot --- internal/command/init.go | 614 +++++++++++++++++++++------------------ 1 file changed, 334 insertions(+), 280 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 7c437dfed10e..8a71148d7eb8 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -443,29 +443,16 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // what 'safe init' actions need to take place. providerLocations := make(map[addrs.Provider]getproviders.PackageLocation) var stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult - - initMsg := views.InitializingProviderPluginFromConfigMessage - reuseMsg := views.ReusingPreviousVersionInfo evts := &providercache.InstallerEvents{ PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(initMsg) - }, - 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), - )) + view.Output(views.InitializingProviderPluginFromConfigMessage) // Message is specific to provide download from config }, + ProviderAlreadyInstalled: providerAlreadyInstalledCallback(view), + BuiltInProviderAvailable: builtInProviderAvailableCallback(view), + BuiltInProviderFailure: builtInProviderFailureCallback(view, &diags), QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { if locked { - view.LogInitMessage(reuseMsg, provider.ForDisplay()) + view.LogInitMessage(views.ReusingPreviousVersionInfo, provider.ForDisplay()) // Message is specific to provide download from config } else { if len(versionConstraints) > 0 { view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) @@ -474,209 +461,24 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } }, - LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) - }, + LinkFromCacheBegin: linkFromCacheBeginCallback(view), FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { - view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) - - // Record the location of this provider. + // 1) 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), - )) - } + // 2) Call the shared callback for FetchPackageBegin. + cb := fetchPackageBeginCallback(view) + cb(provider, version, location) }, + QueryPackagesFailure: queryPackagesFailureCallback(view, &diags, ctx, inst.ProviderSource(), reqs), + QueryPackagesWarning: queryPackagesWarningCallback(view, &diags), + LinkFromCacheFailure: linkFromCacheFailureCallback(view, &diags), + FetchPackageFailure: fetchPackageFailureCallback(&diags, reqs), FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { // 1. Capture auth result if this provider is used for state storage. if config.Module.StateStore != nil && provider.Equals(config.Module.StateStore.ProviderAddr) { @@ -684,64 +486,12 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config stateStoreProviderAuthResult = authResult } - // 2. Log a message about the installed provider. - 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) - } + // 2. Log a message about the installed provider, using shared logic. + cb := fetchPackageSuccessCallback(view) + cb(provider, version, localDir, authResult) }, + ProvidersLockUpdated: providersLockUpdatedCallback(&c.incompleteProviders), + ProvidersFetched: providersFetchedCallback(view), } ctx = evts.OnContext(ctx) @@ -1111,17 +861,7 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S )) } }, - 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) - }, + FetchPackageSuccess: fetchPackageSuccessCallback(view), 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 @@ -1504,6 +1244,320 @@ func (c *InitCommand) Synopsis() string { return "Prepare your working directory for other commands" } +// Returns a reused callback function for the ProviderAlreadyInstalled event in a providercache.InstallerEvents struct. +func providerAlreadyInstalledCallback(view views.Init) func(provider addrs.Provider, selectedVersion getproviders.Version) { + return func(provider addrs.Provider, selectedVersion getproviders.Version) { + view.LogInitMessage(views.ProviderAlreadyInstalledMessage, provider.ForDisplay(), selectedVersion) + } +} + +// Returns a reused callback function for the BuiltInProviderAvailable event in a providercache.InstallerEvents struct. +func builtInProviderAvailableCallback(view views.Init) func(provider addrs.Provider) { + return func(provider addrs.Provider) { + view.LogInitMessage(views.BuiltInProviderAvailableMessage, provider.ForDisplay()) + } +} + +// Returns a reused callback function for the BuiltinProviderFailure event in a providercache.InstallerEvents struct. +func builtInProviderFailureCallback(view views.Init, diags *tfdiags.Diagnostics) func(provider addrs.Provider, err error) { + return 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), + )) + } +} + +// Returns a reused callback function for the LinkFromCacheBegin event in a providercache.InstallerEvents struct. +func linkFromCacheBeginCallback(view views.Init) func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { + return func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { + view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) + } +} + +// Returns a reused callback function for the FetchPackageBegin event in a providercache.InstallerEvents struct. +func fetchPackageBeginCallback(view views.Init) func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { + return func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { + view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) + } +} + +// Returns a reused callback function for the QueryPackagesFailure event in a providercache.InstallerEvents struct. +func queryPackagesFailureCallback(view views.Init, diags *tfdiags.Diagnostics, ctx context.Context, source getproviders.Source, reqs getproviders.Requirements) func(provider addrs.Provider, err error) { + return 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, source, 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, + ), + )) + } + } +} + +// Returns a reused callback function for the QueryPackagesWarning event in a providercache.InstallerEvents struct. +func queryPackagesWarningCallback(view views.Init, diags *tfdiags.Diagnostics) func(provider addrs.Provider, warnings []string) { + return 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"), + ), + )) + } +} + +// Returns a reused callback function for the LinkFromCacheFailure event in a providercache.InstallerEvents struct. +func linkFromCacheFailureCallback(view views.Init, diags *tfdiags.Diagnostics) func(provider addrs.Provider, version getproviders.Version, err error) { + return 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), + )) + } +} + +// Returns a reused callback function for the FetchPackageFailure event in a providercache.InstallerEvents struct. +func fetchPackageFailureCallback(diags *tfdiags.Diagnostics, reqs getproviders.Requirements) func(provider addrs.Provider, version getproviders.Version, err error) { + return 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), + )) + } + } +} + +// Returns a reused callback function for the FetchPackageSuccess event in a providercache.InstallerEvents struct. +func fetchPackageSuccessCallback(view views.Init) func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { + return 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) + } +} + +// Returns a reused callback function for the ProvidersLockUpdated event in a providercache.InstallerEvents struct. +func providersLockUpdatedCallback(incompleteProviders *[]string) func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { + return func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { + // We're going to use this opportunity to track if we have any + // "incomplete" installs of providers. An incomplete install is + // when we are only going to write the local hashes into our lock + // file which means a `terraform init` command will fail in future + // when used on machines of a different architecture. + // + // We want to print a warning about this. + + if len(signedHashes) > 0 { + // If we have any signedHashes hashes then we don't worry - as + // we know we retrieved all available hashes for this version + // anyway. + return + } + + // If local hashes and prior hashes are exactly the same then + // it means we didn't record any signed hashes previously, and + // we know we're not adding any extra in now (because we already + // checked the signedHashes), so that's a problem. + // + // In the actual check here, if we have any priorHashes and those + // hashes are not the same as the local hashes then we're going to + // accept that this provider has been configured correctly. + if len(priorHashes) > 0 && !reflect.DeepEqual(localHashes, priorHashes) { + return + } + + // Now, either signedHashes is empty, or priorHashes is exactly the + // same as our localHashes which means we never retrieved the + // signedHashes previously. + // + // Either way, this is bad. Let's complain/warn. + *incompleteProviders = append(*incompleteProviders, provider.ForDisplay()) + } +} + +// Returns a reused callback function for the ProvidersFetched event in a providercache.InstallerEvents struct. +func providersFetchedCallback(view views.Init) func(authResults map[addrs.Provider]*getproviders.PackageAuthenticationResult) { + return 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) + } + } +} + const errInitCopyNotEmpty = ` The working directory already contains files. The -from-module option requires an empty directory into which a copy of the referenced module will be placed. From 95bd13fd68293db147d1e87228ba4adb0164f0b7 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Tue, 28 Apr 2026 17:20:00 +0100 Subject: [PATCH 13/14] refactor: Update `getProvidersFromState` to use the shared callbacks --- internal/command/init.go | 278 +++------------------------------------ 1 file changed, 16 insertions(+), 262 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 8a71148d7eb8..340d0c648bab 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -445,7 +445,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config var stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult evts := &providercache.InstallerEvents{ PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(views.InitializingProviderPluginFromConfigMessage) // Message is specific to provide download from config + view.Output(views.InitializingProviderPluginFromConfigMessage) // Message is specific to provider download from config }, ProviderAlreadyInstalled: providerAlreadyInstalledCallback(view), BuiltInProviderAvailable: builtInProviderAvailableCallback(view), @@ -486,7 +486,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config stateStoreProviderAuthResult = authResult } - // 2. Log a message about the installed provider, using shared logic. + // 2. Call the shared callback for FetchPackageSuccess cb := fetchPackageSuccessCallback(view) cb(provider, version, localDir, authResult) }, @@ -636,28 +636,16 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // things relatively concise. Later it'd be nice to have a progress UI // where statuses update in-place, but we can't do that as long as we // are shimming our vt100 output to the legacy console API on Windows. - initMsg := views.InitializingProviderPluginFromStateMessage - reuseMsg := views.ReusingVersionIdentifiedFromConfig evts := &providercache.InstallerEvents{ PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(initMsg) - }, - 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), - )) + view.Output(views.InitializingProviderPluginFromStateMessage) // Message is specific to provider download from state }, + ProviderAlreadyInstalled: providerAlreadyInstalledCallback(view), + BuiltInProviderAvailable: builtInProviderAvailableCallback(view), + BuiltInProviderFailure: builtInProviderFailureCallback(view, &diags), QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { if locked { - view.LogInitMessage(reuseMsg, provider.ForDisplay()) + view.LogInitMessage(views.ReusingVersionIdentifiedFromConfig, provider.ForDisplay()) // Message is specific to provider download from state } else { if len(versionConstraints) > 0 { view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) @@ -666,249 +654,15 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S } } }, - LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) - }, - FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { - view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) - }, - QueryPackagesFailure: func(provider addrs.Provider, err error) { - switch errorTy := err.(type) { - case getproviders.ErrProviderNotFound: - sources := errorTy.Sources - displaySources := make([]string, len(sources)) - for i, source := range sources { - displaySources[i] = fmt.Sprintf(" - %s", source) - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", - provider.ForDisplay(), err, strings.Join(displaySources, "\n"), - ), - )) - case getproviders.ErrRegistryProviderNotKnown: - // We might be able to suggest an alternative provider to use - // instead of this one. - suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) - alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) - if alternative != provider { - suggestion = fmt.Sprintf( - "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", - alternative.ForDisplay(), provider.ForDisplay(), - ) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - case getproviders.ErrHostNoProviders: - switch { - case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: - // If a user copies the URL of a GitHub repository into - // the source argument and removes the schema to make it - // provider-address-shaped then that's one way we can end up - // here. We'll use a specialized error message in anticipation - // of that mistake. We only do this if github.com isn't a - // provider registry, to allow for the (admittedly currently - // rather unlikely) possibility that github.com starts being - // a real Terraform provider registry in the future. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", - provider.String(), - ), - )) - - case errorTy.HasOtherVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", - errorTy.Hostname, provider.String(), - ), - )) - - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", - errorTy.Hostname, provider.String(), - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - } - }, - QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { - displayWarnings := make([]string, len(warnings)) - for i, warning := range warnings { - displayWarnings[i] = fmt.Sprintf("- %s", warning) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Additional provider information from registry", - fmt.Sprintf("The remote registry returned warnings for %s:\n%s", - provider.String(), - strings.Join(displayWarnings, "\n"), - ), - )) - }, - LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider from shared cache", - fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), - )) - }, - FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - const summaryIncompatible = "Incompatible provider version" - switch err := err.(type) { - case getproviders.ErrProtocolNotSupported: - closestAvailable := err.Suggestion - switch { - case closestAvailable == getproviders.UnspecifiedVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(errProviderVersionIncompatible, provider.String()), - )) - case version.GreaterThan(closestAvailable): - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - default: // version is less than closestAvailable - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - } - case getproviders.ErrPlatformNotSupported: - switch { - case err.MirrorURL != nil: - // If we're installing from a mirror then it may just be - // the mirror lacking the package, rather than it being - // unavailable from upstream. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Your chosen provider mirror at %s does not have a %s v%s package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so this provider might not support your current platform. Alternatively, the mirror itself might have only a subset of the plugin packages available in the origin registry, at %s.", - err.MirrorURL, err.Provider, err.Version, err.Platform, - err.Provider.Hostname, - ), - )) - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Provider %s v%s does not have a package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.", - err.Provider, err.Version, err.Platform, - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - // We can potentially end up in here under cancellation too, - // in spite of our getproviders.ErrRequestCanceled case above, - // because not all of the outgoing requests we do under the - // "fetch package" banner are source metadata requests. - // In that case we will emit a redundant error here about - // the request being cancelled, but we'll still detect it - // as a cancellation after the installer returns and do the - // normal cancellation handling. - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider", - fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), - )) - } - }, - FetchPackageSuccess: fetchPackageSuccessCallback(view), - 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) - } - }, + LinkFromCacheBegin: linkFromCacheBeginCallback(view), + FetchPackageBegin: fetchPackageBeginCallback(view), + QueryPackagesFailure: queryPackagesFailureCallback(view, &diags, ctx, inst.ProviderSource(), reqs), + QueryPackagesWarning: queryPackagesWarningCallback(view, &diags), + LinkFromCacheFailure: linkFromCacheFailureCallback(view, &diags), + FetchPackageFailure: fetchPackageFailureCallback(&diags, reqs), + FetchPackageSuccess: fetchPackageSuccessCallback(view), + ProvidersLockUpdated: providersLockUpdatedCallback(&c.incompleteProviders), + ProvidersFetched: providersFetchedCallback(view), } ctx = evts.OnContext(ctx) From 163546b7d30360ca14d6a0547ef1074821ff4c03 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Wed, 29 Apr 2026 14:54:36 +0100 Subject: [PATCH 14/14] fix borked code comment --- internal/command/init.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 340d0c648bab..bf1b61dd5617 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -435,12 +435,12 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // These allow us to send output to the terminal as events happen, catch // diagnostics, etc. // - // The callbacks help create diagnostics based on installation events, output - // messages to the user, - // - // 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. + // We use some callbacks to capture data that's surfaced during the + // installation process: + // - provider authentication info. + // - info about what type of location a provider is sourced from. + // These pieces of data are used to determine if additional security features + // need to be enabled. providerLocations := make(map[addrs.Provider]getproviders.PackageLocation) var stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult evts := &providercache.InstallerEvents{