diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go index fa49b1adf4cb..aed455010532 100644 --- a/internal/command/arguments/init.go +++ b/internal/command/arguments/init.go @@ -6,6 +6,7 @@ package arguments import ( "fmt" "os" + "path/filepath" "time" "github.com/hashicorp/terraform/internal/tfdiags" @@ -76,6 +77,8 @@ type Init struct { Args []string + StateStoreProviderLockFile string + // The -enable-pluggable-state-storage-experiment flag is used in control flow logic in the init command. // TODO(SarahFrench/radeksimko): Remove this once the feature is no longer // experimental @@ -113,6 +116,7 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti cmdFlags.BoolVar(&init.Json, "json", false, "json") cmdFlags.Var(&init.BackendConfig, "backend-config", "") cmdFlags.Var(&init.PluginPath, "plugin-dir", "plugin directory") + cmdFlags.StringVar(&init.StateStoreProviderLockFile, "state-provider-lock-file", "", "path to the dependency lock file to use when establishing trust in the provider used for state storage. This is only used when input is disabled. Otherwise, users will be shown interactive prompts instead. Defaults to the working directory's .terraform.lock.hcl file.") // Used for enabling experimental code that's invoked before configuration is parsed. cmdFlags.BoolVar(&init.EnablePssExperiment, "enable-pluggable-state-storage-experiment", false, "Enable the pluggable state storage experiment") @@ -138,6 +142,57 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti "Terraform cannot use the -enable-pluggable-state-storage-experiment flag (or TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable) unless experiments are enabled.", )) } + if init.StateStoreProviderLockFile != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Cannot use -state-provider-lock-file flag without experiments enabled", + "Terraform cannot use the -state-provider-lock-file flag unless experiments are enabled.", + )) + } + } + + if !init.EnablePssExperiment && init.StateStoreProviderLockFile != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Cannot use -state-provider-lock-file flag unless the pluggable state storage experiment is enabled", + "Terraform cannot use the -state-provider-lock-file flag unless the pluggable state storage experiment is enabled. Add the -enable-pluggable-state-storage-experiment flag to your command.", + )) + } + + if init.StateStoreProviderLockFile != "" { + if init.InputEnabled { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Cannot use -state-provider-lock-file flag when input is enabled", + "The -state-provider-lock-file flag is only intended to be used when input is disabled. Either remove the flag or add -input=false to your command.", + )) + } + if init.Upgrade { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "The -state-provider-lock-file and -upgrade options are mutually-exclusive", + "The -upgrade flag causes Terraform to ignore prior dependency locks, so it cannot be used in conjunction with the -state-provider-lock-file flag. Remove one flag and try again.", + )) + } + + // Validate that the path uses the expected file name: .terraform.lock.hcl + srcFilename := filepath.Base(init.StateStoreProviderLockFile) + if srcFilename != lockFileName { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid -state-provider-lock-file value", + fmt.Sprintf("Expected lock file name to be %s, got: %s", lockFileName, srcFilename), + )) + } + + // Validate that the file exists + if _, err := os.Stat(init.StateStoreProviderLockFile); os.IsNotExist(err) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Lock file supplied by -state-provider-lock-file does not exist", + fmt.Sprintf("Terraform cannot find the dependency lock file at %s. Please ensure the file exists and the path is correct.", init.StateStoreProviderLockFile), + )) + } } if init.MigrateState && init.Json { diff --git a/internal/command/arguments/init_test.go b/internal/command/arguments/init_test.go index bb15ab07c758..a2bba958eeb9 100644 --- a/internal/command/arguments/init_test.go +++ b/internal/command/arguments/init_test.go @@ -4,6 +4,8 @@ package arguments import ( + "fmt" + "os" "strings" "testing" "time" @@ -40,10 +42,12 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - CompactWarnings: false, - TargetFlags: nil, + Vars: &Vars{}, + InputEnabled: true, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: false, + StateStoreProviderLockFile: "", }, }, "setting multiple options": { @@ -74,11 +78,13 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - Args: []string{}, - CompactWarnings: true, - TargetFlags: nil, + Vars: &Vars{}, + InputEnabled: true, + Args: []string{}, + CompactWarnings: true, + TargetFlags: nil, + EnablePssExperiment: false, + StateStoreProviderLockFile: "", }, }, "with cloud option": { @@ -103,11 +109,13 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &[]FlagNameValue{{Name: "-backend-config", Value: "backend.config"}}, }, - Vars: &Vars{}, - InputEnabled: false, - Args: []string{}, - CompactWarnings: false, - TargetFlags: []string{"foo_bar.baz"}, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: []string{"foo_bar.baz"}, + EnablePssExperiment: false, + StateStoreProviderLockFile: "", }, }, } @@ -129,6 +137,212 @@ func TestParseInit_basicValid(t *testing.T) { } } +func TestParseInit_stateProviderLockFile(t *testing.T) { + td := t.TempDir() + t.Chdir(td) + + // Create a dummy lock file to test with + lockFileName := ".terraform.lock.hcl" + if err := os.WriteFile(lockFileName, []byte("content doesn't matter!"), 0600); err != nil { + t.Fatalf("unable to create dependency lock file: %v", err) + } + + // Create an existing file with an incorrect file name + invalidLockFileName := "foobar.lock.hcl" + if err := os.WriteFile(invalidLockFileName, []byte("content doesn't matter!"), 0600); err != nil { + t.Fatalf("unable to create dependency lock file: %v", err) + } + + var flagNameValue []FlagNameValue + testCases := map[string]struct { + args []string + expectErr bool + want *Init + }{ + "error when -state-provider-lock-file is used alongside -input=true": { + []string{"-state-provider-lock-file=foobar.lock.hcl", "-upgrade", "-input=true", "-enable-pluggable-state-storage-experiment"}, + true, + &Init{ + FromModule: "", + Lockfile: "", + TestsDirectory: "tests", + ViewType: ViewHuman, + Backend: true, + Cloud: true, + Get: true, + ForceInitCopy: false, + StateLock: true, + StateLockTimeout: 0, + Reconfigure: false, + MigrateState: false, + Upgrade: true, + Json: false, + IgnoreRemoteVersion: false, + BackendConfig: FlagNameValueSlice{ + FlagName: "-backend-config", + Items: &flagNameValue, + }, + Vars: &Vars{}, + InputEnabled: true, + Args: []string{}, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: true, + StateStoreProviderLockFile: "foobar.lock.hcl", + }, + }, + "error when -state-provider-lock-file is used alongside -upgrade": { + []string{"-state-provider-lock-file=foobar.lock.hcl", "-upgrade", "-input=false", "-enable-pluggable-state-storage-experiment"}, + true, + &Init{ + FromModule: "", + Lockfile: "", + TestsDirectory: "tests", + ViewType: ViewHuman, + Backend: true, + Cloud: true, + Get: true, + ForceInitCopy: false, + StateLock: true, + StateLockTimeout: 0, + Reconfigure: false, + MigrateState: false, + Upgrade: true, + Json: false, + IgnoreRemoteVersion: false, + BackendConfig: FlagNameValueSlice{ + FlagName: "-backend-config", + Items: &flagNameValue, + }, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: true, + StateStoreProviderLockFile: "foobar.lock.hcl", + }, + }, + "error when -state-provider-lock-file references a non-existent file": { + []string{"-state-provider-lock-file=nonexistent.lock.hcl", "-input=false", "-enable-pluggable-state-storage-experiment"}, + true, + &Init{ + FromModule: "", + Lockfile: "", + TestsDirectory: "tests", + ViewType: ViewHuman, + Backend: true, + Cloud: true, + Get: true, + ForceInitCopy: false, + StateLock: true, + StateLockTimeout: 0, + Reconfigure: false, + MigrateState: false, + Upgrade: false, + Json: false, + IgnoreRemoteVersion: false, + BackendConfig: FlagNameValueSlice{ + FlagName: "-backend-config", + Items: &flagNameValue, + }, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: true, + StateStoreProviderLockFile: "nonexistent.lock.hcl", + }, + }, + "error when -state-provider-lock-file references a file that is not called .terraform.lock.hcl": { + []string{fmt.Sprintf("-state-provider-lock-file=%s", invalidLockFileName), "-input=false", "-enable-pluggable-state-storage-experiment"}, + true, + &Init{ + FromModule: "", + Lockfile: "", + TestsDirectory: "tests", + ViewType: ViewHuman, + Backend: true, + Cloud: true, + Get: true, + ForceInitCopy: false, + StateLock: true, + StateLockTimeout: 0, + Reconfigure: false, + MigrateState: false, + Upgrade: false, + Json: false, + IgnoreRemoteVersion: false, + BackendConfig: FlagNameValueSlice{ + FlagName: "-backend-config", + Items: &flagNameValue, + }, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: true, + StateStoreProviderLockFile: invalidLockFileName, + }, + }, + "valid when -state-provider-lock-file references a file that exists": { + []string{fmt.Sprintf("-state-provider-lock-file=%s", lockFileName), "-input=false", "-enable-pluggable-state-storage-experiment"}, + false, + &Init{ + FromModule: "", + Lockfile: "", + TestsDirectory: "tests", + ViewType: ViewHuman, + Backend: true, + Cloud: true, + Get: true, + ForceInitCopy: false, + StateLock: true, + StateLockTimeout: 0, + Reconfigure: false, + MigrateState: false, + Upgrade: false, + Json: false, + IgnoreRemoteVersion: false, + BackendConfig: FlagNameValueSlice{ + FlagName: "-backend-config", + Items: &flagNameValue, + }, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: nil, + EnablePssExperiment: true, + StateStoreProviderLockFile: lockFileName, + }, + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + experimentsEnabled := true + got, diags := ParseInit(tc.args, experimentsEnabled) + if len(diags) > 0 { + if !tc.expectErr { + t.Fatalf("unexpected diags: %v", diags) + } + } + if tc.expectErr && len(diags) == 0 { + t.Fatal("expected error diags but got none") + } + + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) + } + }) + } +} + func TestParseInit_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -173,7 +387,7 @@ func TestParseInit_invalid(t *testing.T) { t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) } if got.ViewType != tc.wantViewType { - t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman) + t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, tc.wantViewType) } }) } @@ -198,6 +412,16 @@ func TestParseInit_experimentalFlags(t *testing.T) { experimentsEnabled: false, wantErr: "Cannot use -enable-pluggable-state-storage-experiment flag without experiments enabled", }, + "error: -state-provider-lock-file and experiments are disabled": { + args: []string{"-state-provider-lock-file=.terraform.lock.hcl"}, + experimentsEnabled: false, + wantErr: "Cannot use -state-provider-lock-file flag without experiments enabled", + }, + "error: -state-provider-lock-file and -enable-pluggable-state-storage-experiment isn't set": { + args: []string{"-state-provider-lock-file=.terraform.lock.hcl"}, + experimentsEnabled: true, + wantErr: "Cannot use -state-provider-lock-file flag unless the pluggable state storage experiment is enabled", + }, } for name, tc := range testCases { diff --git a/internal/command/init.go b/internal/command/init.go index fdfe291cf31b..b539560284c5 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -366,10 +366,10 @@ the backend configuration is present and valid. 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! + SafeInitActionInvalid SafeInitAction = 0 + SafeInitActionProceed SafeInitAction = 'P' + SafeInitActionRequireApproval 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. @@ -378,7 +378,7 @@ const ( // The dependency lock file itself isn't updated here. // // Calling code is responsible for validating inputs to this method, e.g. mutually exclusive flags. -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) { +func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *configs.Config, previousLocks *depsfile.Locks, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output bool, resultingLocks *depsfile.Locks, safeInitAction SafeInitAction, authResult *getproviders.PackageAuthenticationResult, diags tfdiags.Diagnostics) { if config == nil { return false, nil, SafeInitActionNotRelevant, nil, diags } @@ -507,13 +507,6 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config mode = providercache.InstallUpgrades } - // Previous locks from dep locks file are needed so we don't re-download any providers - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - if diags.HasErrors() { - return false, nil, SafeInitActionInvalid, nil, diags - } - // Determine which required providers are already downloaded, and download any // new providers or newer versions of providers configLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) @@ -559,7 +552,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config 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 + safeInitAction = SafeInitActionRequireApproval default: panic(fmt.Sprintf("init (getProvidersFromConfig): unexpected provider location type for state storage provider %q: %T", config.Module.StateStore.ProviderAddr, location)) } @@ -1005,6 +998,13 @@ Options: -enable-pluggable-state-storage-experiment [EXPERIMENTAL] A flag to enable an alternative init command that allows use of pluggable state storage. Only usable with experiments enabled. + + -state-provider-lock-file [EXPERIMENTAL] + Specifies a lock file Terraform should use to establish trust in + a provider before initializing a state store for the first time. + Only usable when input is disabled through -input=false. + Only usable with experiments enabled and the + -enable-pluggable-state-storage-experiment flag present. ` return strings.TrimSpace(helpText) } diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 228a95416476..22c95c9056e6 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -214,7 +214,49 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { previousLocks, moreDiags := c.lockedDependencies() diags = diags.Append(moreDiags) - configProvidersOutput, configLocks, safeInitAction, stateStoreProviderAuthResult, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + // If -state-provider-lock-file is set, we'll use that to obtain a new lock used for the state store provider + // This will be 'upserted': it may be that the previous locks don't contain the provider being added. potentially due to being empty, or contain a different version. + // The lock added will be used in the first step of provider download. + // + // We leave `previousLocks` unchanged so it can be used to accurately detect changes to the locks when the lock file is updated later. + alteredPreviousLocks := previousLocks.DeepCopy() + if initArgs.StateStoreProviderLockFile != "" { + stateStoreLocks, lockDiags := depsfile.LoadLocksFromFile(initArgs.StateStoreProviderLockFile) + if lockDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error loading -state-provider-lock-file lock file", + fmt.Sprintf("Terraform experienced an error loading the file at %q: %s", initArgs.StateStoreProviderLockFile, lockDiags.Err()), + )) + view.Diagnostics(diags) + return 1 + } + diags = diags.Append(lockDiags) // capture any warnings + + lock := stateStoreLocks.Provider(config.Module.StateStore.ProviderAddr) + if lock == nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "State store provider not found in -state-provider-lock-file dependency lock file", + fmt.Sprintf("Terraform could not find the state store provider %q (%s) in the dependency lock file %q provided via the -state-provider-lock-file flag. Please ensure the lock file contains a lock for the state store provider and try again.", + config.Module.StateStore.ProviderAddr.Type, + config.Module.StateStore.ProviderAddr.ForDisplay(), + initArgs.StateStoreProviderLockFile, + ), + )) + view.Diagnostics(diags) + return 1 + } + + // Overwrite or add the state store provider lock to the other locks for this project + alteredPreviousLocks.SetProvider( + lock.Provider(), + lock.Version(), + lock.VersionConstraints(), + lock.PreferredHashes(), + ) + } + configProvidersOutput, configLocks, safeInitAction, stateStoreProviderAuthResult, configProviderDiags := c.getProvidersFromConfig(ctx, config, alteredPreviousLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) diags = diags.Append(configProviderDiags) if configProviderDiags.HasErrors() { view.Diagnostics(diags) @@ -224,21 +266,37 @@ 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. 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 + case SafeInitActionRequireApproval: + if c.input { + // Prompt the user about trusting the provider used for state storage. + diags = diags.Append(c.promptStateStorageProviderApproval(config.Module.StateStore.ProviderAddr, configLocks, stateStoreProviderAuthResult)) + if diags.HasErrors() { + view.Output(views.StateStoreProviderInteractiveRejectedMessage) + view.Diagnostics(diags) + return 1 + } + view.Output(views.StateStoreProviderInteractiveApprovedMessage) + } else { + // Confirm that a lock was used to control download. + // Note: we have to wait and do that here because at this point we know the provider was downloaded from a source that requires additional info about trust. + if alteredPreviousLocks.Provider(config.Module.StateStore.ProviderAddr) == nil { + // No lock was provided for the state store provider either through pre-existing locks or through the -state-provider-lock-file flag. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Missing lock for state store provider", + "Terraform is initializing a state store for the first time in a non-interactive mode. In this scenario Terraform needs a pre-existing dependency lock for the state store provider to be present in the working directory's dependency lock file, or present in another file supplied via the -state-provider-lock-file flag. No lock was found for the state store provider. Please re-run the command using the -state-provider-lock-file flag.", + )) + view.Diagnostics(diags) + return 1 + } + view.Output(views.StateStoreProviderAutomationApprovedMessage) } - 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)) @@ -248,7 +306,7 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { // 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 - old := previousLocks.Provider(pAddr) + old := alteredPreviousLocks.Provider(pAddr) new := configLocks.Provider(pAddr) if old == nil || new == nil { panic(fmt.Sprintf(`Unexpected missing provider lock for %s during init -upgrade: diff --git a/internal/command/init_test.go b/internal/command/init_test.go index c5f34aa81678..77581e6ea16a 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3908,9 +3908,299 @@ 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("no need to interactively approve a state store provider installed from local archive", func(t *testing.T) { +// Testing init's basic behaviors with `state_store` when run in an empty working directory: backend state file creation, behaviour based on the selected workspace +func TestInit_stateStore_newWorkingDir_basic(t *testing.T) { + 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() + testCopyDir(t, testFixturePath("init-with-state-store"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource := 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"}, + }) + + 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"} + 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 default workspace was not created + if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { + t.Fatal("expected the default workspace to not be created during init, but it exists") + } + + // Assert contents of the backend state file + statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + if err := sMgr.RefreshState(); err != nil { + t.Fatal("Failed to load state:", err) + } + s := sMgr.State() + if s == nil { + t.Fatal("expected backend state file to be created, but there isn't one") + } + v1_2_3, _ := version.NewVersion("1.2.3") + expectedState := &workdir.StateStoreConfigState{ + Type: "test_store", + ConfigRaw: []byte("{\n \"value\": \"foobar\"\n }"), + Hash: uint64(4158988729), + ProviderSupplyMode: getproviders.ManagedByTerraform, + Provider: &workdir.ProviderConfigState{ + Version: v1_2_3, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "hashicorp", + Type: "test", + }, + ConfigRaw: []byte("{\n \"region\": null\n }"), + }, + } + if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { + t.Fatalf("unexpected diff in backend state file's description of state store:\n%s", diff) + } + }) + + // This scenario would be rare, but protecting against it is easy and avoids assumptions. + t.Run("error when a custom workspace is selected but no workspaces exist", func(t *testing.T) { + // Create a temporary, uninitialized working directory with configuration including a state store + td := t.TempDir() + testCopyDir(t, testFixturePath("init-with-state-store"), td) + t.Chdir(td) + + // Select a custom workspace (which will not exist) + customWorkspace := "my-custom-workspace" + t.Setenv(WorkspaceNameEnvVar, customWorkspace) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + + 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"} + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedOutputs := []string{ + fmt.Sprintf("Workspace %q has not been created yet", customWorkspace), + fmt.Sprintf("To create the custom workspace %q use the command `terraform workspace new %s`", customWorkspace, customWorkspace), + } + for _, expected := range expectedOutputs { + if !strings.Contains(cleanString(output), expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, cleanString(output)) + } + } + + // Assert no workspaces exist + if len(mockProvider.MockStates) != 0 { + t.Fatalf("expected no workspaces, but got: %#v", mockProvider.MockStates) + } + + // Assert no backend state file made due to the error + statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) + _, err := os.Stat(statePath) + if pathErr, ok := err.(*os.PathError); !ok || !os.IsNotExist(pathErr.Err) { + t.Fatalf("expected backend state file to not be created, but it exists") + } + }) + + // Test what happens when the selected workspace doesn't exist, but there are other workspaces available. + // + // When input is disabled (in automation, etc) Terraform cannot prompts the user to select an alternative. + // Instead, an error is returned. + t.Run("error when input is disabled and the selected workspace doesn't exist and other custom workspaces do exist.", func(t *testing.T) { + // Create a temporary, uninitialized working directory with configuration including a state store + td := t.TempDir() + testCopyDir(t, testFixturePath("init-with-state-store"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProvider.GetStatesResponse = &providers.GetStatesResponse{ + States: []string{ + "foobar1", + "foobar2", + // Force provider to report workspaces exist + // But default workspace doesn't exist + }, + } + + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + + 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, + } + + // If input is disabled users receive an error about the missing workspace + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-input=false", + } + 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() + expectedOutput := "Failed to select a workspace: Currently selected workspace \"default\" does not exist" + if !strings.Contains(cleanString(output), expectedOutput) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, cleanString(output)) + } + statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) + _, err := os.Stat(statePath) + if _, ok := err.(*os.PathError); !ok { + if err == nil { + t.Fatalf("expected backend state file to not be created, but it exists") + } + + t.Fatalf("unexpected error: %s", err) + } + }) + + // Test what happens when the selected workspace doesn't exist, but there are other workspaces available. + // + // When input is enabled Terraform prompts the user to select an alternative. + t.Run("if the selected workspace doesn't exist and other custom workspaces do exist, Terraform prompts the user to select a 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() + mockProvider.GetStatesResponse = &providers.GetStatesResponse{ + States: []string{ + "foobar1", + "foobar2", + // Force provider to report workspaces exist + // But default workspace doesn't exist + }, + } + + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + + // Allow the test to respond to the prompt to pick an + // existing workspace, given the selected one doesn't exist. + _ = testInputMap(t, map[string]string{ + "select-workspace": "1", // foobar1 in numbered list + }) + + 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", + } + 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()) + } + + // The init command should have caused the selected workspace to change, based on the input + // provided by the user. + currentWorkspace, err := c.Meta.Workspace() + if err != nil { + t.Fatal(err) + } + if currentWorkspace != "foobar1" { + t.Fatalf("expected init command to alter the selected workspace from 'default' to 'foobar1', but got: %s", currentWorkspace) + } + }) +} + +// Testing init's behaviors when approving a new state store provider when a workspace is initialized for the first time. +func TestInit_stateStore_newWorkingDir_interactiveProviderApproval(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) @@ -4315,19 +4605,25 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } } }) +} - t.Run("the init command creates a backend state file, and the default workspace is not made by default", func(t *testing.T) { +// Testing init's behaviors when, in automation, we're approving a new state store provider when a workspace is initialized for the first time. +func TestInit_stateStore_newWorkingDir_inAutomationProviderApproval(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) - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource := newMockProviderSource(t, map[string][]string{ + // 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{ @@ -4339,13 +4635,16 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { mockProviderAddress: providers.FactoryFixed(mockProvider), }, }, - ProviderSource: providerSource, + ProviderSource: source, } c := &InitCommand{ Meta: meta, } - args := []string{"-enable-pluggable-state-storage-experiment=true"} + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-input=false", // Simulate running in automation where input is disabled + } code := c.Run(args) testOutput := done(t) if code != 0 { @@ -4364,58 +4663,28 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } } - // Assert the default workspace was not created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { - t.Fatal("expected the default workspace to not be created during init, but it exists") - } - - // Assert contents of the backend state file - statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) - sMgr := &clistate.LocalState{Path: statePath} - if err := sMgr.RefreshState(); err != nil { - t.Fatal("Failed to load state:", err) - } - s := sMgr.State() - if s == nil { - t.Fatal("expected backend state file to be created, but there isn't one") - } - v1_2_3, _ := version.NewVersion("1.2.3") - expectedState := &workdir.StateStoreConfigState{ - Type: "test_store", - ConfigRaw: []byte("{\n \"value\": \"foobar\"\n }"), - Hash: uint64(4158988729), - ProviderSupplyMode: getproviders.ManagedByTerraform, - Provider: &workdir.ProviderConfigState{ - Version: v1_2_3, - Source: &tfaddr.Provider{ - Hostname: tfaddr.DefaultProviderRegistryHost, - Namespace: "hashicorp", - Type: "test", - }, - ConfigRaw: []byte("{\n \"region\": null\n }"), - }, - } - if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { - t.Fatalf("unexpected diff in backend state file's description of state store:\n%s", diff) + // 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") } }) - // This scenario would be rare, but protecting against it is easy and avoids assumptions. - t.Run("if a custom workspace is selected but no workspaces exist an error is returned", func(t *testing.T) { + t.Run("users approve trusting a state store provider downloaded via HTTP by supplying locks via -state-provider-lock flag", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() testCopyDir(t, testFixturePath("init-with-state-store"), td) t.Chdir(td) - // Select a custom workspace (which will not exist) - customWorkspace := "my-custom-workspace" - t.Setenv(WorkspaceNameEnvVar, customWorkspace) - - mockProvider := mockPluggableStateStorageProvider() + // Set up mock provider source that mocks out hashicorp/test via HTTP. + // This stops Terraform auto-approving the provider installation. mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, + expectedVersion := "1.2.3" + source := newMockProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {expectedVersion, "9.9.9"}, // Extra version - expected version is downloaded, not the latest }) + mockProvider := mockPluggableStateStorageProvider() ui := new(cli.MockUi) view, done := testView(t) @@ -4428,68 +4697,156 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { mockProviderAddress: providers.FactoryFixed(mockProvider), }, }, - ProviderSource: providerSource, + ProviderSource: source, } c := &InitCommand{ Meta: meta, } - args := []string{"-enable-pluggable-state-storage-experiment=true"} + // Create supplemental lock file to be used with the -state-provider-lock flag + // To avoid this being confused with the lock file in the working directory, + // this is made in a second temp directory away from other files in this test. + td2 := t.TempDir() + lockFileName := filepath.Join(td2, ".terraform.lock.hcl") + suppliedLockFileVersion := getproviders.MustParseVersion(expectedVersion) + locks := depsfile.NewLocks() + locks.SetProvider( + mockProviderAddress, + suppliedLockFileVersion, + getproviders.MustParseVersionConstraints("> 1.0.0"), + []getproviders.Hash{ + getproviders.HashScheme1.New("wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno="), + }, + ) + depsfile.SaveLocksToFile(locks, lockFileName) + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-input=false", // Simulate running in automation where input is disabled + fmt.Sprintf("-state-provider-lock-file=%s", lockFileName), + } 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()) + 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{ - fmt.Sprintf("Workspace %q has not been created yet", customWorkspace), - fmt.Sprintf("To create the custom workspace %q use the command `terraform workspace new %s`", customWorkspace, customWorkspace), + "Initializing the state store...", + "The state store provider was approved automatically", + "Terraform has been successfully initialized!", } for _, expected := range expectedOutputs { - if !strings.Contains(cleanString(output), expected) { - t.Fatalf("expected output to include %q, but got':\n %s", expected, cleanString(output)) + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) } } - // Assert no workspaces exist - if len(mockProvider.MockStates) != 0 { - t.Fatalf("expected no workspaces, but got: %#v", mockProvider.MockStates) + // Assert the dependency lock file was created + // and it contains the state store provider version described by the -state-provider-lock-file flag + lockFile := filepath.Join(td, ".terraform.lock.hcl") + locks, lockDiags := depsfile.LoadLocksFromFile(lockFile) + if lockDiags.HasErrors() { + t.Fatal("expected dependency lock file to exist, but got errors loading it:", lockDiags.Err()) } - - // Assert no backend state file made due to the error - statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) - _, err := os.Stat(statePath) - if pathErr, ok := err.(*os.PathError); !ok || !os.IsNotExist(pathErr.Err) { - t.Fatalf("expected backend state file to not be created, but it exists") + gotLock := locks.Provider(mockProviderAddress) + if gotLock == nil { + t.Fatalf("expected dependency lock file to contain the state store provider %s, but it doesn't", mockProviderAddress.ForDisplay()) + } + if !gotLock.Version().Same(suppliedLockFileVersion) { + t.Fatalf("expected dependency lock file to contain version %s for provider %s that was supplied via the -state-provider-lock-file flag, but got version %s", suppliedLockFileVersion, mockProviderAddress.ForDisplay(), gotLock.Version()) } }) - // Test what happens when the selected workspace doesn't exist, but there are other workspaces available. - // - // When input is disabled (in automation, etc) Terraform cannot prompts the user to select an alternative. - // Instead, an error is returned. - t.Run("init: returns an error when input is disabled and the selected workspace doesn't exist and other custom workspaces do exist.", func(t *testing.T) { + t.Run("a state store provider downloaded via HTTP can be automatically approved if it already exists in the .terraform.lock.hcl file", 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 hashicorp/test via HTTP. + // This stops Terraform auto-approving the provider installation. + mockProviderAddress := addrs.NewDefaultProvider("test") + expectedVersion := "1.2.3" + source := newMockProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {expectedVersion, "9.9.9"}, // Extra version - expected version is downloaded, not the latest + }) mockProvider := mockPluggableStateStorageProvider() - mockProvider.GetStatesResponse = &providers.GetStatesResponse{ - States: []string{ - "foobar1", - "foobar2", - // Force provider to report workspaces exist - // But default workspace doesn't exist + + 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, + } + + // Create a local .terraform.lock.hcl file that already contains the state store provider version + lockFileName := ".terraform.lock.hcl" + suppliedLockFileVersion := getproviders.MustParseVersion(expectedVersion) + locks := depsfile.NewLocks() + locks.SetProvider( + mockProviderAddress, + suppliedLockFileVersion, + getproviders.MustParseVersionConstraints("> 1.0.0"), + []getproviders.Hash{ + getproviders.HashScheme1.New("wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno="), }, + ) + depsfile.SaveLocksToFile(locks, lockFileName) + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-input=false", // Simulate running in automation where input is disabled + // -state-provider-lock-file flag isn't used; this test shows it can fall back to using the .terraform.lock.hcl file in the working directory + } + 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 automatically", + "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) + } } + // No need for assertions about the dependency lock file + // as it was created during test setup. + }) + + t.Run("error if the lock file supplied by the -state-provider-lock-file flag doesn't contain the state store 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) + t.Chdir(td) + // Set up mock provider source that mocks out hashicorp/test via HTTP. + // This stops Terraform auto-approving the provider installation. mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, + expectedVersion := "1.2.3" + source := newMockProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {expectedVersion}, }) + mockProvider := mockPluggableStateStorageProvider() ui := new(cli.MockUi) view, done := testView(t) @@ -4502,67 +4859,68 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { mockProviderAddress: providers.FactoryFixed(mockProvider), }, }, - ProviderSource: providerSource, + ProviderSource: source, } c := &InitCommand{ Meta: meta, } - // If input is disabled users receive an error about the missing workspace + // Create supplemental lock file to be used with the -state-provider-lock flag + // To avoid this being confused with the lock file in the working directory, + // this is made in a second temp directory away from other files in this test. + td2 := t.TempDir() + lockFileName := filepath.Join(td2, ".terraform.lock.hcl") + + // It DOESNT contain the state store provider hashicorp/test though, causing an error. + locks := depsfile.NewLocks() + locks.SetProvider( + addrs.NewDefaultProvider("notusedprovider"), + getproviders.MustParseVersion("9.9.9"), + getproviders.MustParseVersionConstraints("> 1.0.0"), + []getproviders.Hash{ + getproviders.HashScheme1.New("wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno="), + }, + ) + depsfile.SaveLocksToFile(locks, lockFileName) + args := []string{ "-enable-pluggable-state-storage-experiment=true", - "-input=false", + "-input=false", // Simulate running in automation where input is disabled + fmt.Sprintf("-state-provider-lock-file=%s", lockFileName), } 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() - expectedOutput := "Failed to select a workspace: Currently selected workspace \"default\" does not exist" - if !strings.Contains(cleanString(output), expectedOutput) { - t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, cleanString(output)) + + // Check output via view + output := cleanString(testOutput.All()) + expectedOutputs := []string{ + "Error: State store provider not found in -state-provider-lock-file dependency lock file", + fmt.Sprintf("Terraform could not find the state store provider \"test\" (hashicorp/test) in the dependency lock file \"%s\" provided via the -state-provider-lock-file flag", lockFileName), } - statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) - _, err := os.Stat(statePath) - if _, ok := err.(*os.PathError); !ok { - if err == nil { - t.Fatalf("expected backend state file to not be created, but it exists") + for _, expected := range expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) } - - t.Fatalf("unexpected error: %s", err) } }) - // Test what happens when the selected workspace doesn't exist, but there are other workspaces available. - // - // When input is enabled Terraform prompts the user to select an alternative. - t.Run("init: prompts user to select a workspace if the selected workspace doesn't exist and other custom workspaces do exist.", func(t *testing.T) { + t.Run("error if the state store lock is supplied by neither a pre-existing lock nor the -state-provider-lock-file flag", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() testCopyDir(t, testFixturePath("init-with-state-store"), td) t.Chdir(td) - mockProvider := mockPluggableStateStorageProvider() - mockProvider.GetStatesResponse = &providers.GetStatesResponse{ - States: []string{ - "foobar1", - "foobar2", - // Force provider to report workspaces exist - // But default workspace doesn't exist - }, - } - + // Set up mock provider source that mocks out hashicorp/test via HTTP. + // This stops Terraform auto-approving the provider installation. mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, - }) - - // Allow the test to respond to the prompt to pick an - // existing workspace, given the selected one doesn't exist. - _ = testInputMap(t, map[string]string{ - "select-workspace": "1", // foobar1 in numbered list + expectedVersion := "1.2.3" + source := newMockProviderSourceUsingTestHttpServer(t, map[string][]string{ + "hashicorp/test": {expectedVersion}, }) + mockProvider := mockPluggableStateStorageProvider() ui := new(cli.MockUi) view, done := testView(t) @@ -4575,35 +4933,42 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { mockProviderAddress: providers.FactoryFixed(mockProvider), }, }, - ProviderSource: providerSource, + ProviderSource: source, } c := &InitCommand{ Meta: meta, } + // Confirm the .terraform.lock.hcl file doesn't exist before the test runs + // Therefore this isn't an adequate fallback source of locks for the state store provider, causing an error. + _, err := os.Stat(filepath.Join(td, ".terraform.lock.hcl")) + if !os.IsNotExist(err) { + t.Fatal("expected .terraform.lock.hcl file to not exist, but got an unrelated error:", err) + } + args := []string{ "-enable-pluggable-state-storage-experiment=true", + "-input=false", // Simulate running in automation where input is disabled + //-state-provider-lock-file is not used, and there's no .terraform.lock.hcl file, so no locks are supplied for the state store 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()) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) } - // The init command should have caused the selected workspace to change, based on the input - // provided by the user. - currentWorkspace, err := c.Meta.Workspace() - if err != nil { - t.Fatal(err) + // Check output via view + output := cleanString(testOutput.All()) + expectedOutputs := []string{ + "Error: Missing lock for state store provider", + "Terraform is initializing a state store for the first time in a non-interactive mode. In this scenario Terraform needs a pre-existing dependency lock for the state store provider to be present in the working directory's dependency lock file, or present in another file supplied via the -state-provider-lock-file flag. No lock was found for the state store provider. Please re-run the command using the -state-provider-lock-file flag.", } - if currentWorkspace != "foobar1" { - t.Fatalf("expected init command to alter the selected workspace from 'default' to 'foobar1', but got: %s", currentWorkspace) + for _, expected := range expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } } }) - - // TODO(SarahFrench/radeksimko): Add test cases below: - // 1) "during a non-init command, the command ends in with an error telling the user to run an init command" - // >>> Currently this is handled at a lower level in `internal/command/meta_backend_test.go` } // Testing init's behaviors with `state_store` when run in a working directory where the configuration diff --git a/internal/command/views/init.go b/internal/command/views/init.go index dcc6a9bc9e85..d1ffd97d7a58 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -199,13 +199,17 @@ 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_interactive_approved_message": { + HumanValue: "\n[reset][bold]The state store provider was approved by the user.", + JSONValue: "The state store provider was approved by the user.", }, - "state_store_provider_rejected_message": { - HumanValue: "\n[reset][bold]The state store provider was rejected.", - JSONValue: "The state store provider was rejected.", + "state_store_provider_interactive_rejected_message": { + HumanValue: "\n[reset][bold]The state store provider was rejected by the user.", + JSONValue: "The state store provider was rejected by the user.", + }, + "state_store_provider_automation_approved_message": { + HumanValue: "\n[reset][bold]The state store provider was approved automatically.", + JSONValue: "The state store provider was approved automatically.", }, "dependencies_lock_changes_info": { HumanValue: dependenciesLockChangesInfo, @@ -335,25 +339,26 @@ const ( // Following message codes are used and documented EXTERNALLY // Keep docs/internals/machine-readable-ui.mdx up to date with // this list when making changes here. - CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" - EmptyMessage InitMessageCode = "empty_message" - OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" - OutputInitSuccessMessage InitMessageCode = "output_init_success_message" - OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" - OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" - OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" - UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" - InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" - 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" - LockInfo InitMessageCode = "lock_info" - DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" + CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" + EmptyMessage InitMessageCode = "empty_message" + OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" + OutputInitSuccessMessage InitMessageCode = "output_init_success_message" + OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" + OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" + OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" + UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" + InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" + InitializingModulesMessage InitMessageCode = "initializing_modules_message" + InitializingBackendMessage InitMessageCode = "initializing_backend_message" + InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" + StateStoreProviderInteractiveApprovedMessage InitMessageCode = "state_store_provider_interactive_approved_message" + StateStoreProviderInteractiveRejectedMessage InitMessageCode = "state_store_provider_interactive_rejected_message" + StateStoreProviderAutomationApprovedMessage InitMessageCode = "state_store_provider_automation_approved_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" + LockInfo InitMessageCode = "lock_info" + DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" //// Message codes below are ONLY used INTERNALLY (for now)