diff --git a/internal/command/init.go b/internal/command/init.go index 4c77dd4a87..34656d974a 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "reflect" + "slices" "sort" "strings" @@ -434,7 +435,6 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, ), )) } - }, QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { displayWarnings := make([]string, len(warnings)) @@ -694,7 +694,16 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, // 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 arguments.FlagStringSlice, // Not needed for PSS stuff - for installer sources. + flagLockfile string, + backendInit bool, + safeInit bool, + view views.Init, +) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from config") defer span.End() @@ -746,7 +755,16 @@ 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) + // TODO: + // if backendInit = false then either we're using a previously installed provider for state storage, + // or the config is being ignored and an implied local backend will be used instead. + // Think about use of backendInit here and edge cases. + safeOpts := &safeInitOptions{} + if config.Module.StateStore != nil { + safeOpts.providers = []addrs.Provider{config.Module.StateStore.ProviderAddr} + safeOpts.safeInitEnabled = safeInit + } + evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, safeOpts, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -795,7 +813,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config // 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) { +func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.State, configLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, safeInit bool, view views.Init) (output bool, resultingLocks *depsfile.Locks, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "install providers from state") defer span.End() @@ -863,7 +881,8 @@ 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) + var safeOpts *safeInitOptions + evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, safeOpts, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -898,7 +917,6 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // The calling code is expected to provide the previous locks (if any) and the two sets of locks determined from // configuration and state data. func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLocks *depsfile.Locks, flagLockfile string, view views.Init) (output bool, diags tfdiags.Diagnostics) { - // Get the combination of config and state locks newLocks := c.mergeLockedDependencies(configLocks, stateLocks) @@ -964,11 +982,16 @@ func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLo return output, diags } +// safeInitOptions +type safeInitOptions struct { + providers []addrs.Provider + safeInitEnabled bool +} + // prepareInstallerEvents returns an instance of *providercache.InstallerEvents. This struct defines callback functions that will be executed // when a specific type of event occurs during provider installation. // The calling code needs to provide a tfdiags.Diagnostics collection, so that provider installation code returns diags to the calling code using closures -func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { - +func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags tfdiags.Diagnostics, inst *providercache.Installer, safeOpts *safeInitOptions, 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 @@ -1007,6 +1030,37 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr }, FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) + view.Log("location = " + location.String()) + }, + FetchPackageSafetyCheck: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) error { + // If there are no safe opts the calling code has decided there's no need to be cautious + // in the current scenario. + if safeOpts != nil { + if slices.Contains(safeOpts.providers, provider) { + switch location.(type) { + case getproviders.PackageLocalDir: + // Ok, as providers sourced from the local directory are considered trusted. + return nil + case getproviders.PackageLocalArchive: + // Ok, as providers sourced from a local archive are considered trusted. + return nil + case getproviders.PackageHTTPURL: + if safeOpts.safeInitEnabled { + // Code elsewhere will enforce safe installation - allow + return nil + } else { + // Unsafe - block + return getproviders.ErrUnsafeStateStorageProviderDownload{ + Provider: provider, + Version: version, + } + } + default: + panic(fmt.Sprintf("unexpected install location for provider %s %s: %s", provider, version, location)) + } + } + } + return nil }, QueryPackagesFailure: func(provider addrs.Provider, err error) { switch errorTy := err.(type) { @@ -1095,7 +1149,6 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) } - }, QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { displayWarnings := make([]string, len(warnings)) @@ -1181,6 +1234,16 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // but rather just emit a single general message about it at // the end, by checking ctx.Err(). + case getproviders.ErrUnsafeStateStorageProviderDownload: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsafe init - TODO", + fmt.Sprintf( + "TODO - blocked download of provider %s v%s.", + err.Provider, err.Version, + ), + )) + default: // We can potentially end up in here under cancellation too, // in spite of our getproviders.ErrRequestCanceled case above, diff --git a/internal/command/init_run_experiment.go b/internal/command/init_run_experiment.go index c9ef0abda9..87e747f1c7 100644 --- a/internal/command/init_run_experiment.go +++ b/internal/command/init_run_experiment.go @@ -188,7 +188,7 @@ func (c *InitCommand) runPssInit(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, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, initArgs.Backend, initArgs.SafeInitWithPluggableStateStore, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { view.Diagnostics(diags) @@ -257,7 +257,7 @@ func (c *InitCommand) runPssInit(initArgs *arguments.Init, view views.Init) int // Now the resource state is loaded, we can download the providers specified in the state but not the configuration. // This is step two of a two-step provider download process - stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, initArgs.SafeInitWithPluggableStateStore, view) diags = diags.Append(configProviderDiags) if stateProvidersDiags.HasErrors() { view.Diagnostics(diags) @@ -522,7 +522,6 @@ However, if you intended to override a defined backend, please verify that the backend configuration is present and valid. `, )) - } opts = &BackendOpts{ diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 68f7000d54..ecfb2ed84d 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3381,7 +3381,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("the init command creates a backend state file, and creates the default workspace by default", func(t *testing.T) { + t.Run("int: return error if -safe-init isn't set when downloading the state storage provider", 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) @@ -3413,7 +3413,66 @@ 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", + // -safe-init is omitted to create the test scenario + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedOutput := "Error: State storage providers must be downloaded using -safe-init flag" + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) + } + }) + + t.Run("init: can safely use a new provider, create backend state, and create the default workspace", func(t *testing.T) { + // Create a temporary, uninitialized working directory with configuration including a state store + td := t.TempDir() + testCopyDir(t, testFixturePath("init-with-state-store"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + // The test fixture config has no version constraints, so the latest version will + // be used; below is the 'latest' version in the test world. + "hashicorp/test": {"1.2.3"}, + }) + defer close() + + // Allow the test to respond to the pause in provider installation for + // checking the state storage provider. + defer 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: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-safe-init", + } code := c.Run(args) testOutput := done(t) if code != 0 { @@ -3433,6 +3492,13 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } } + // Assert the dependency lock file was created + lockFile := filepath.Join(td, ".terraform.lock.hcl") + _, err := os.Stat(lockFile) + if os.IsNotExist(err) { + t.Fatal("expected dependency lock file to not exist, but it doesn't") + } + // Assert the default workspace was created if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { t.Fatal("expected the default workspace to be created during init, but it is missing") diff --git a/internal/getproviders/errors.go b/internal/getproviders/errors.go index c0f81a27df..c90cc91f2f 100644 --- a/internal/getproviders/errors.go +++ b/internal/getproviders/errors.go @@ -220,8 +220,7 @@ func (err ErrQueryFailed) Unwrap() error { // This error type doesn't include information about what was cancelled, // because the expected treatment of this error type is to quickly abort and // exit with minimal ceremony. -type ErrRequestCanceled struct { -} +type ErrRequestCanceled struct{} func (err ErrRequestCanceled) Error() string { return "request canceled" @@ -247,3 +246,19 @@ func ErrIsNotExist(err error) bool { return false } } + +// ErrUnsafeStateStorageProviderDownload is an error type used to indicate that... TOTO +// +// This is returned when ... TODO +type ErrUnsafeStateStorageProviderDownload struct { + Provider addrs.Provider + Version Version +} + +func (err ErrUnsafeStateStorageProviderDownload) Error() string { + return fmt.Sprintf( + "Terraform wanted to download %s %s to be used for pluggable state storage. Please provide -safe-init flag for safe install", // TODO improve wording + err.Provider, + err.Version, + ) +} diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index 00907548c4..405d7b40a3 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -556,6 +556,21 @@ NeedProvider: // Step 3c: Retrieve the package indicated by the metadata we received, // either directly into our target directory or via the global cache // directory. + + // Before we start the download process, perform and safety checks defined + // by the calling code. + if cb := evts.FetchPackageSafetyCheck; cb != nil { + err := cb(provider, version, meta.Location) + // A returned error means we're blocking download of a provider. + if err != nil { + errs[provider] = err + if cb := evts.FetchPackageFailure; cb != nil { + cb(provider, version, err) + } + continue + } + } + if cb := evts.FetchPackageBegin; cb != nil { cb(provider, version, meta.Location) } @@ -784,5 +799,7 @@ func (err InstallerError) Error() string { providerErr := err.ProviderErrors[addr] fmt.Fprintf(&b, "- %s: %s\n", addr, providerErr) } + + // Render a PSS-specific security error separate to the list above. return strings.TrimSpace(b.String()) } diff --git a/internal/providercache/installer_events.go b/internal/providercache/installer_events.go index 8b632a77ea..7930b9c0ff 100644 --- a/internal/providercache/installer_events.go +++ b/internal/providercache/installer_events.go @@ -104,10 +104,16 @@ type InstallerEvents struct { // // The Query, Begin, Success, and Failure events will each occur only once // per distinct provider. - FetchPackageMeta func(provider addrs.Provider, version getproviders.Version) // fetching metadata prior to real download - FetchPackageBegin func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) - FetchPackageSuccess func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) - FetchPackageFailure func(provider addrs.Provider, version getproviders.Version, err error) + // + // The SafetyCheck event allows calling code to inject custom logic that inspects + // a provider that is about to be downloaded. Currently this is used to manage scenarios + // where a provider binary may be downloaded and immediately executed during the + // same init command. + FetchPackageMeta func(provider addrs.Provider, version getproviders.Version) // fetching metadata prior to real download + FetchPackageBegin func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) + FetchPackageSuccess func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) + FetchPackageFailure func(provider addrs.Provider, version getproviders.Version, err error) + FetchPackageSafetyCheck func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) error // The ProvidersLockUpdated event is called whenever the lock file will be // updated. It provides the following information: