diff --git a/internal/command/init.go b/internal/command/init.go index 178e2fd4bc8a..bf1b61dd5617 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" @@ -360,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, authResult *getproviders.PackageAuthenticationResult, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from config") defer span.End() @@ -380,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, nil, diags } reqs = c.removeDevOverrides(reqs) @@ -398,7 +409,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config } } if diags.HasErrors() { - return false, nil, diags + return false, nil, SafeInitActionInvalid, nil, diags } var inst *providercache.Installer @@ -420,7 +431,68 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) } - evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) + // Prepare callback functions for the installer. + // These allow us to send output to the terminal as events happen, catch + // diagnostics, etc. + // + // 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{ + PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { + view.Output(views.InitializingProviderPluginFromConfigMessage) // Message is specific to provider 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(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)) + } else { + view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) + } + } + }, + LinkFromCacheBegin: linkFromCacheBeginCallback(view), + FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { + // 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 + + // 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) { + 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. Call the shared callback for FetchPackageSuccess + cb := fetchPackageSuccessCallback(view) + cb(provider, version, localDir, authResult) + }, + ProvidersLockUpdated: providersLockUpdatedCallback(&c.incompleteProviders), + ProvidersFetched: providersFetchedCallback(view), + } ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -428,7 +500,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, nil, diags } mode = providercache.InstallUpgrades @@ -438,7 +510,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, nil, diags } // Determine which required providers are already downloaded, and download any @@ -447,7 +519,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, nil, diags } if err != nil { // The errors captured in "err" should be redundant with what we @@ -457,10 +529,43 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config diags = diags.Append(err) } - return true, nil, diags + return true, nil, SafeInitActionInvalid, nil, diags } - return true, configLocks, 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, safeInitAction, stateStoreProviderAuthResult, diags } // getProvidersFromState determines what providers are required by the given state data. @@ -531,7 +636,34 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // things relatively concise. Later it'd be nice to have a progress UI // where statuses update in-place, but we can't do that as long as we // are shimming our vt100 output to the legacy console API on Windows. - evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) + evts := &providercache.InstallerEvents{ + PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { + 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(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)) + } else { + view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) + } + } + }, + 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) mode := providercache.InstallNewProvidersOnly @@ -631,301 +763,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. @@ -1161,6 +998,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. diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 9fed22213360..2282d66b86cc 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, stateStoreProviderAuthResult, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { view.Diagnostics(diags) @@ -220,7 +224,27 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { header = true } - // The init command is not allowed to upgrade the provider used for PSS (unless we're reconfiguring the state store). + // 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. + 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, stateStoreProviderAuthResult)) + 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 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 @@ -235,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. @@ -391,3 +415,55 @@ 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, authResult *getproviders.PackageAuthenticationResult) 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)) + } + + 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? +Platform: %s +Authentication: %s +Hashes: +%s +`, + lock.Provider().Type, + lock.Provider(), + lock.Version(), + getproviders.CurrentPlatform.String(), + authentication, + 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 6316dc3f8036..e6d8c02d65c4 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) } @@ -3842,22 +3842,87 @@ 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) { + 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) t.Chdir(td) - // Mock provider still needs to be supplied via testingOverrides despite the mock HTTP source + // 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("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) + 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{ - // 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"}, + "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) @@ -3877,17 +3942,20 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { Meta: meta, } - args := []string{"-enable-pluggable-state-storage-experiment=true"} + 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 + // 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 { @@ -3895,22 +3963,49 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { t.Fatalf("expected output to include %q, but got':\n %s", expected, output) } } + // Check output when prompting for approval + expectedInputPromptMsg := []string{ + "Do you want to use provider \"test\" (registry.terraform.io/hashicorp/test), version 1.2.3, for managing state?", + getproviders.CurrentPlatform.String(), + "Authentication: verified checksum", + "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("temporary: test showing use of network mirror in mock provider source", func(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) - // Mock provider still needs to be supplied via testingOverrides despite the mock network mirror + // 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") - // 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) + // 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) @@ -3929,18 +4024,221 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { Meta: meta, } - args := []string{"-enable-pluggable-state-storage-experiment=true"} + 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 + // Check output via view 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. + "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() + 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(), + "Authentication: verified checksum", + "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("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) + 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 { 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"