From 5603b8a27c9afe4f6811ef8d24c4820b410c768a Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Wed, 11 Feb 2026 16:29:31 +0200 Subject: [PATCH] Refactor init command to use View instead of Ui (#3749) Signed-off-by: Andrei Ciobanu --- cmd/tofu/main.go | 18 +- cmd/tofu/main_test.go | 3 +- internal/command/arguments/backend.go | 59 ++ internal/command/arguments/backend_test.go | 412 ++++++++ internal/command/arguments/init.go | 121 +++ internal/command/arguments/init_test.go | 505 ++++++++++ internal/command/cli_ui.go | 27 + internal/command/e2etest/init_test.go | 6 +- internal/command/init.go | 393 +++----- internal/command/init_test.go | 932 +++++++++--------- internal/command/meta.go | 33 + internal/command/providers_schema_test.go | 25 +- internal/command/providers_test.go | 22 +- internal/command/show_test.go | 65 +- internal/command/test_test.go | 99 +- internal/command/views/hook_module_install.go | 82 ++ .../command/views/hook_module_install_test.go | 147 +++ internal/command/views/init.go | 592 +++++++++++ internal/command/views/init_test.go | 678 +++++++++++++ internal/command/views/json_view_test.go | 7 +- internal/command/views/view.go | 33 + internal/command/views/view_ui.go | 183 ++++ internal/command/views/view_ui_test.go | 149 +++ 23 files changed, 3774 insertions(+), 817 deletions(-) create mode 100644 internal/command/arguments/backend.go create mode 100644 internal/command/arguments/backend_test.go create mode 100644 internal/command/arguments/init.go create mode 100644 internal/command/arguments/init_test.go create mode 100644 internal/command/views/hook_module_install.go create mode 100644 internal/command/views/hook_module_install_test.go create mode 100644 internal/command/views/init.go create mode 100644 internal/command/views/init_test.go create mode 100644 internal/command/views/view_ui.go create mode 100644 internal/command/views/view_ui_test.go diff --git a/cmd/tofu/main.go b/cmd/tofu/main.go index 1e2379ea21..ab5a210ac8 100644 --- a/cmd/tofu/main.go +++ b/cmd/tofu/main.go @@ -23,6 +23,7 @@ import ( "github.com/mattn/go-shellwords" "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" + "github.com/opentofu/opentofu/internal/command" "github.com/opentofu/opentofu/internal/command/workdir" "github.com/opentofu/opentofu/internal/addrs" @@ -47,23 +48,8 @@ const ( EnvCPUProfile = "TOFU_CPU_PROFILE" ) -// ui wraps the primary output cli.Ui, and redirects Warn calls to Output -// calls. This ensures that warnings are sent to stdout, and are properly -// serialized within the stdout stream. -type ui struct { - cli.Ui -} - -func (u *ui) Warn(msg string) { - u.Ui.Output(msg) -} - func init() { - Ui = &ui{&cli.BasicUi{ - Writer: os.Stdout, - ErrorWriter: os.Stderr, - Reader: os.Stdin, - }} + Ui = command.NewBasicUI() } func main() { diff --git a/cmd/tofu/main_test.go b/cmd/tofu/main_test.go index 45cac2292b..db5171b0e3 100644 --- a/cmd/tofu/main_test.go +++ b/cmd/tofu/main_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/mitchellh/cli" + "github.com/opentofu/opentofu/internal/command" ) func TestMain_cliArgsFromEnv(t *testing.T) { @@ -291,7 +292,7 @@ func (c *testCommandCLI) Help() string { return "" } func TestWarnOutput(t *testing.T) { mock := cli.NewMockUi() - wrapped := &ui{mock} + wrapped := command.NewWrappedUi(mock) wrapped.Warn("WARNING") stderr := mock.ErrorWriter.String() diff --git a/internal/command/arguments/backend.go b/internal/command/arguments/backend.go new file mode 100644 index 0000000000..1f1abc7c81 --- /dev/null +++ b/internal/command/arguments/backend.go @@ -0,0 +1,59 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package arguments + +import ( + "flag" + "time" + + "github.com/opentofu/opentofu/internal/tfdiags" +) + +type Backend struct { + IgnoreRemoteVersion bool + // StateLock indicates if the state should be locked or not. + StateLock bool + // StateLockTimeout configures the duration that it waits for the state lock to be acquired. + StateLockTimeout time.Duration + // ForceInitCopy controls if the prompts for state migration should be skipped or not. + ForceInitCopy bool + // Reconfigure controls if the reconfiguration of the backend should happen with discarding the old configurations. + Reconfigure bool + // MigrateState controls if during the reconfiguration of the backend a migration should be attempted. + MigrateState bool +} + +func (b *Backend) AddIgnoreRemoteVersionFlag(f *flag.FlagSet) { + f.BoolVar(&b.IgnoreRemoteVersion, "ignore-remote-version", false, "continue even if remote and local OpenTofu versions are incompatible") +} + +func (b *Backend) AddStateFlags(f *flag.FlagSet) { + f.BoolVar(&b.StateLock, "lock", true, "lock state") + f.DurationVar(&b.StateLockTimeout, "lock-timeout", 0, "lock timeout") +} + +func (b *Backend) AddMigrationFlags(f *flag.FlagSet) { + f.BoolVar(&b.ForceInitCopy, "force-copy", false, "suppress prompts about copying state data") + f.BoolVar(&b.Reconfigure, "reconfigure", false, "reconfigure") + f.BoolVar(&b.MigrateState, "migrate-state", false, "migrate state") +} + +func (b *Backend) migrationFlagsCheck() (diags tfdiags.Diagnostics) { + if b.MigrateState && b.Reconfigure { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Wrong combination of options", + "The -migrate-state and -reconfigure options are mutually-exclusive", + )) + } + + // Copying the state only happens during backend migration, so setting + // -force-copy implies -migrate-state + if b.ForceInitCopy { + b.MigrateState = true + } + return diags +} diff --git a/internal/command/arguments/backend_test.go b/internal/command/arguments/backend_test.go new file mode 100644 index 0000000000..1197183309 --- /dev/null +++ b/internal/command/arguments/backend_test.go @@ -0,0 +1,412 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package arguments + +import ( + "flag" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +func TestBackend_AddIgnoreRemoteVersionFlag(t *testing.T) { + testCases := map[string]struct { + args []string + want bool + }{ + "default value": { + args: nil, + want: false, + }, + "flag not provided": { + args: []string{}, + want: false, + }, + "flag set to true": { + args: []string{"-ignore-remote-version"}, + want: true, + }, + "flag explicitly set to false": { + args: []string{"-ignore-remote-version=false"}, + want: false, + }, + "flag explicitly set to true": { + args: []string{"-ignore-remote-version=true"}, + want: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + backend := &Backend{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + backend.AddIgnoreRemoteVersionFlag(fs) + + if err := fs.Parse(tc.args); err != nil { + t.Fatalf("unexpected error parsing flags: %v", err) + } + + if got := backend.IgnoreRemoteVersion; got != tc.want { + t.Errorf("IgnoreRemoteVersion = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBackend_AddStateFlags(t *testing.T) { + testCases := map[string]struct { + args []string + wantLock bool + wantLockTimeout time.Duration + }{ + "default values": { + args: nil, + wantLock: true, + wantLockTimeout: 0, + }, + "lock set to false": { + args: []string{"-lock=false"}, + wantLock: false, + wantLockTimeout: 0, + }, + "lock set to true explicitly": { + args: []string{"-lock=true"}, + wantLock: true, + wantLockTimeout: 0, + }, + "lock-timeout set": { + args: []string{"-lock-timeout=10s"}, + wantLock: true, + wantLockTimeout: 10 * time.Second, + }, + "lock-timeout set in minutes": { + args: []string{"-lock-timeout=5m"}, + wantLock: true, + wantLockTimeout: 5 * time.Minute, + }, + "both flags set": { + args: []string{"-lock=false", "-lock-timeout=30s"}, + wantLock: false, + wantLockTimeout: 30 * time.Second, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + backend := &Backend{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + backend.AddStateFlags(fs) + + if err := fs.Parse(tc.args); err != nil { + t.Fatalf("unexpected error parsing flags: %v", err) + } + + if got := backend.StateLock; got != tc.wantLock { + t.Errorf("StateLock = %v, want %v", got, tc.wantLock) + } + + if got := backend.StateLockTimeout; got != tc.wantLockTimeout { + t.Errorf("StateLockTimeout = %v, want %v", got, tc.wantLockTimeout) + } + }) + } +} + +func TestBackend_AddMigrationFlags(t *testing.T) { + testCases := map[string]struct { + args []string + wantForceInitCopy bool + wantReconfigure bool + wantMigrateState bool + }{ + "default values": { + args: nil, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: false, + }, + "force-copy set": { + args: []string{"-force-copy"}, + wantForceInitCopy: true, + wantReconfigure: false, + wantMigrateState: false, + }, + "force-copy explicitly true": { + args: []string{"-force-copy=true"}, + wantForceInitCopy: true, + wantReconfigure: false, + wantMigrateState: false, + }, + "force-copy explicitly false": { + args: []string{"-force-copy=false"}, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: false, + }, + "reconfigure set": { + args: []string{"-reconfigure"}, + wantForceInitCopy: false, + wantReconfigure: true, + wantMigrateState: false, + }, + "reconfigure explicitly true": { + args: []string{"-reconfigure=true"}, + wantForceInitCopy: false, + wantReconfigure: true, + wantMigrateState: false, + }, + "reconfigure explicitly false": { + args: []string{"-reconfigure=false"}, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: false, + }, + "migrate-state set": { + args: []string{"-migrate-state"}, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: true, + }, + "migrate-state explicitly true": { + args: []string{"-migrate-state=true"}, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: true, + }, + "migrate-state explicitly false": { + args: []string{"-migrate-state=false"}, + wantForceInitCopy: false, + wantReconfigure: false, + wantMigrateState: false, + }, + "force-copy and migrate-state set": { + args: []string{"-force-copy", "-migrate-state"}, + wantForceInitCopy: true, + wantReconfigure: false, + wantMigrateState: true, + }, + "all flags set": { + args: []string{"-force-copy", "-reconfigure", "-migrate-state"}, + wantForceInitCopy: true, + wantReconfigure: true, + wantMigrateState: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + backend := &Backend{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + backend.AddMigrationFlags(fs) + + if err := fs.Parse(tc.args); err != nil { + t.Fatalf("unexpected error parsing flags: %v", err) + } + + if got := backend.ForceInitCopy; got != tc.wantForceInitCopy { + t.Errorf("ForceInitCopy = %v, want %v", got, tc.wantForceInitCopy) + } + + if got := backend.Reconfigure; got != tc.wantReconfigure { + t.Errorf("Reconfigure = %v, want %v", got, tc.wantReconfigure) + } + + if got := backend.MigrateState; got != tc.wantMigrateState { + t.Errorf("MigrateState = %v, want %v", got, tc.wantMigrateState) + } + }) + } +} + +func TestBackend_migrationFlagsCheck(t *testing.T) { + testCases := map[string]struct { + backend Backend + wantDiags bool + wantMigrateState bool + diagsSummary string + }{ + "no flags set": { + backend: Backend{ + ForceInitCopy: false, + Reconfigure: false, + MigrateState: false, + }, + wantDiags: false, + wantMigrateState: false, + }, + "only migrate-state set": { + backend: Backend{ + ForceInitCopy: false, + Reconfigure: false, + MigrateState: true, + }, + wantDiags: false, + wantMigrateState: true, + }, + "only reconfigure set": { + backend: Backend{ + ForceInitCopy: false, + Reconfigure: true, + MigrateState: false, + }, + wantDiags: false, + wantMigrateState: false, + }, + "only force-copy set": { + backend: Backend{ + ForceInitCopy: true, + Reconfigure: false, + MigrateState: false, + }, + wantDiags: false, + wantMigrateState: true, // force-copy implies migrate-state + }, + "force-copy and migrate-state set": { + backend: Backend{ + ForceInitCopy: true, + Reconfigure: false, + MigrateState: true, + }, + wantDiags: false, + wantMigrateState: true, + }, + "migrate-state and reconfigure set (mutually exclusive)": { + backend: Backend{ + ForceInitCopy: false, + Reconfigure: true, + MigrateState: true, + }, + wantDiags: true, + wantMigrateState: true, + diagsSummary: "Wrong combination of options", + }, + "all flags set (error due to reconfigure + migrate-state)": { + backend: Backend{ + ForceInitCopy: true, + Reconfigure: true, + MigrateState: true, + }, + wantDiags: true, + wantMigrateState: true, + diagsSummary: "Wrong combination of options", + }, + "force-copy and reconfigure set (no error - check happens before force-copy sets migrate-state)": { + backend: Backend{ + ForceInitCopy: true, + Reconfigure: true, + MigrateState: false, + }, + wantDiags: false, // No error because MigrateState is false when check happens + wantMigrateState: true, // force-copy sets this to true after the check + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + backend := tc.backend + diags := backend.migrationFlagsCheck() + + if tc.wantDiags && len(diags) == 0 { + t.Fatal("expected diagnostics but got none") + } + + if !tc.wantDiags && len(diags) != 0 { + t.Fatalf("unexpected diagnostics: %v", diags) + } + + if tc.wantDiags && len(diags) == 1 { + diag := diags[0] + if diag.Description().Summary != tc.diagsSummary { + t.Errorf("diagnostic summary = %q, want %q", + diag.Description().Summary, tc.diagsSummary) + } + + // Verify it's an error + if diag.Severity() != tfdiags.Error { + t.Errorf("diagnostic severity = %v, want %v", + diag.Severity(), tfdiags.Error) + } + } + + // Verify that MigrateState is set correctly + if got := backend.MigrateState; got != tc.wantMigrateState { + t.Errorf("MigrateState after check = %v, want %v", got, tc.wantMigrateState) + } + }) + } +} + +func TestBackend_AllFlags(t *testing.T) { + testCases := map[string]struct { + args []string + want Backend + }{ + "all defaults": { + args: nil, + want: Backend{ + IgnoreRemoteVersion: false, + StateLock: true, + StateLockTimeout: 0, + ForceInitCopy: false, + Reconfigure: false, + MigrateState: false, + }, + }, + "all flags set": { + args: []string{ + "-ignore-remote-version", + "-lock=false", + "-lock-timeout=1m", + "-force-copy", + "-reconfigure", + "-migrate-state", + }, + want: Backend{ + IgnoreRemoteVersion: true, + StateLock: false, + StateLockTimeout: time.Minute, + ForceInitCopy: true, + Reconfigure: true, + MigrateState: true, + }, + }, + "mixed flags": { + args: []string{ + "-ignore-remote-version=true", + "-lock-timeout=30s", + "-migrate-state", + }, + want: Backend{ + IgnoreRemoteVersion: true, + StateLock: true, + StateLockTimeout: 30 * time.Second, + ForceInitCopy: false, + Reconfigure: false, + MigrateState: true, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + backend := &Backend{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + backend.AddIgnoreRemoteVersionFlag(fs) + backend.AddStateFlags(fs) + backend.AddMigrationFlags(fs) + + if err := fs.Parse(tc.args); err != nil { + t.Fatalf("unexpected error parsing flags: %v", err) + } + + if diff := cmp.Diff(tc.want, *backend); diff != "" { + t.Errorf("unexpected result (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go new file mode 100644 index 0000000000..69fb2e0f69 --- /dev/null +++ b/internal/command/arguments/init.go @@ -0,0 +1,121 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package arguments + +import ( + "github.com/opentofu/opentofu/internal/command/flags" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +// Init represents the command-line arguments for the init command. +type Init struct { + // Copy the contents of the given module into the target directory before initialisation + FlagFromModule string + // Lockfile operation mode. Currently only "readonly" is valid. + FlagLockfile string + // Set the OpenTofu test directory. When set, the + // test command will search for test files in the current directory and + // in the one specified by the flag. + TestsDirectory string + // When set to false, disables modules downloading for the current configuration + FlagGet bool + // Install the latest module and provider versions allowed within configured constraints, overriding the + // default behavior of selecting exactly the version recorded in the dependency lockfile. + FlagUpgrade bool + // Directory containing plugin binaries. This overrides all default search paths for plugins, and prevents the + // automatic installation of plugins. This flag can be used multiple times. + FlagPluginPath flags.FlagStringSlice + // Configuration to be merged with what is in the configuration file's 'backend' block. This can be + // either a path to an HCL file with key/value assignments (same format as terraform.tfvars) or a + // 'key=value' format, and can be specified multiple times. The backend type must be in the configuration itself. + FlagConfigExtra flags.RawFlags + // Disable backend or cloud backend initialization for this configuration and use what was previously + // initialized instead. This and the FlagCloud cannot be toggled in the same time. + FlagBackend bool + FlagCloud bool + + // Bools indicating that the FlagBackend and FlagCloud have been found into the arguments list of the + // process. + BackendFlagSet bool + CloudFlagSet bool + + // ViewOptions specifies which view options to use + ViewOptions ViewOptions + + // Vars holds and provides information for the flags related to variables that a user can give into the process + Vars *Vars + // Backend holds and providers information for the flags related to the backend operations, like locking + // locking timeout, force migration, etc. + Backend *Backend +} + +// ParseInit processes CLI arguments, returning an Init value, a closer function, and errors. +// If errors are encountered, an Init value is still returned representing +// the best effort interpretation of the arguments. +func ParseInit(args []string) (*Init, func(), tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + init := &Init{ + Vars: &Vars{}, + Backend: &Backend{}, + FlagConfigExtra: flags.NewRawFlags("-backend-config"), + } + + cmdFlags := extendedFlagSet("init", nil, nil, init.Vars) + init.Backend.AddIgnoreRemoteVersionFlag(cmdFlags) + init.Backend.AddStateFlags(cmdFlags) + init.Backend.AddMigrationFlags(cmdFlags) + cmdFlags.BoolVar(&init.FlagBackend, "backend", true, "") + cmdFlags.BoolVar(&init.FlagCloud, "cloud", true, "") + cmdFlags.Var(init.FlagConfigExtra, "backend-config", "") + cmdFlags.StringVar(&init.FlagFromModule, "from-module", "", "copy the source of the given module into the directory before init") + cmdFlags.BoolVar(&init.FlagGet, "get", true, "") + cmdFlags.BoolVar(&init.FlagUpgrade, "upgrade", false, "") + cmdFlags.Var(&init.FlagPluginPath, "plugin-dir", "plugin directory") + cmdFlags.StringVar(&init.FlagLockfile, "lockfile", "", "Set a dependency lockfile mode") + cmdFlags.StringVar(&init.TestsDirectory, "test-directory", "tests", "test-directory") + + init.ViewOptions.AddFlags(cmdFlags, true) + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + closer, moreDiags := init.ViewOptions.Parse() + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return init, closer, diags + } + init.BackendFlagSet = flags.FlagIsSet(cmdFlags, "backend") + init.CloudFlagSet = flags.FlagIsSet(cmdFlags, "cloud") + + switch { + case init.BackendFlagSet && init.CloudFlagSet: + return init, closer, diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Wrong combination of options", + "The -backend and -cloud options are aliases of one another and mutually-exclusive in their use", + )) + case init.BackendFlagSet: + init.FlagCloud = init.FlagBackend + case init.CloudFlagSet: + init.FlagBackend = init.FlagCloud + } + + diags = diags.Append(init.Backend.migrationFlagsCheck()) + + if len(cmdFlags.Args()) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unexpected argument", + "Too many command line arguments. Did you mean to use -chdir?", + )) + } + return init, closer, diags +} diff --git a/internal/command/arguments/init_test.go b/internal/command/arguments/init_test.go new file mode 100644 index 0000000000..4aecf17f56 --- /dev/null +++ b/internal/command/arguments/init_test.go @@ -0,0 +1,505 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package arguments + +import ( + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/opentofu/opentofu/internal/command/flags" +) + +func TestParseInit_basicValidation(t *testing.T) { + testCases := map[string]struct { + args []string + want *Init + }{ + "defaults": { + nil, + initArgsWithDefaults(nil), + }, + "upgrade flag": { + []string{"-upgrade"}, + initArgsWithDefaults(func(init *Init) { + init.FlagUpgrade = true + }), + }, + "get flag disabled": { + []string{"-get=false"}, + initArgsWithDefaults(func(init *Init) { + init.FlagGet = false + }), + }, + "from-module flag with value": { + []string{"-from-module=/path/to/module"}, + initArgsWithDefaults(func(init *Init) { + init.FlagFromModule = "/path/to/module" + }), + }, + "lockfile readonly": { + []string{"-lockfile=readonly"}, + initArgsWithDefaults(func(init *Init) { + init.FlagLockfile = "readonly" + }), + }, + "custom test-directory": { + []string{"-test-directory=integration"}, + initArgsWithDefaults(func(init *Init) { + init.TestsDirectory = "integration" + }), + }, + "backend disabled": { + []string{"-backend=false"}, + initArgsWithDefaults(func(init *Init) { + init.FlagBackend = false + init.FlagCloud = false + init.BackendFlagSet = true + }), + }, + "cloud disabled": { + []string{"-cloud=false"}, + initArgsWithDefaults(func(init *Init) { + init.FlagBackend = false + init.FlagCloud = false + init.CloudFlagSet = true + }), + }, + "multiple flags combined": { + []string{"-upgrade", "-lockfile=readonly", "-get=false", "-from-module=/tmp/mod"}, + initArgsWithDefaults(func(init *Init) { + init.FlagFromModule = "/tmp/mod" + init.FlagLockfile = "readonly" + init.FlagGet = false + init.FlagUpgrade = true + }), + }, + "one plugin dir configured": { + []string{"-plugin-dir=/test1"}, + initArgsWithDefaults(func(init *Init) { + _ = init.FlagPluginPath.Set("/test1") + }), + }, + "multiple plugin dirs configured": { + []string{"-plugin-dir=/test1", "-plugin-dir=/test2"}, + initArgsWithDefaults(func(init *Init) { + _ = init.FlagPluginPath.Set("/test1") + _ = init.FlagPluginPath.Set("/test2") + }), + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Vars{}, ViewOptions{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) + } + }) + } +} + +func TestParseInit_backendCloudErrors(t *testing.T) { + testCases := map[string]struct { + args []string + wantBackend bool + wantCloud bool + }{ + "both explicitly set to true": { + args: []string{"-backend=true", "-cloud=true"}, + wantBackend: true, + wantCloud: true, + }, + "both explicitly set to false": { + args: []string{"-backend=false", "-cloud=false"}, + wantBackend: false, + wantCloud: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) == 0 { + t.Fatal("expected diagnostics but got none") + } + if got, want := diags.Err().Error(), "Wrong combination of options"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got, want := diags.Err().Error(), "mutually-exclusive"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got.FlagBackend != tc.wantBackend { + t.Errorf("wrong FlagBackend. wanted %t but got %t", tc.wantBackend, got.FlagBackend) + } + if got.FlagCloud != tc.wantCloud { + t.Errorf("wrong FlagCloud. wanted %t, want %t", tc.wantCloud, got.FlagCloud) + } + }) + } +} + +func TestParseInit_backendCloudSynchronization(t *testing.T) { + testCases := map[string]struct { + args []string + wantBackend bool + wantCloud bool + wantBackendSet bool + wantCloudSet bool + }{ + "backend=false only": { + args: []string{"-backend=false"}, + wantBackend: false, + wantCloud: false, + wantBackendSet: true, + wantCloudSet: false, + }, + "backend=true only": { + args: []string{"-backend=true"}, + wantBackend: true, + wantCloud: true, + wantBackendSet: true, + wantCloudSet: false, + }, + "cloud=false only": { + args: []string{"-cloud=false"}, + wantBackend: false, + wantCloud: false, + wantBackendSet: false, + wantCloudSet: true, + }, + "cloud=true only": { + args: []string{"-cloud=true"}, + wantBackend: true, + wantCloud: true, + wantBackendSet: false, + wantCloudSet: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if got.FlagBackend != tc.wantBackend { + t.Errorf("wrong FlagBackend. wanted %t but got %t", tc.wantBackend, got.FlagBackend) + } + if got.FlagCloud != tc.wantCloud { + t.Errorf("wrong FlagCloud. wanted %t but got want %t", tc.wantCloud, got.FlagCloud) + } + if got.BackendFlagSet != tc.wantBackendSet { + t.Errorf("wrong BackendFlagSet. wanted %t but got %t", tc.wantBackendSet, got.BackendFlagSet) + } + if got.CloudFlagSet != tc.wantCloudSet { + t.Errorf("wrong CloudFlagSet. wanted %t but got %t", tc.wantCloudSet, got.CloudFlagSet) + } + if got.FlagBackend != got.FlagCloud { + t.Errorf("wrong FlagBackend. expected to be in sync with FlagCloud, instead got FlagBackend=%t and FlagCloud=%t", got.FlagCloud, got.FlagBackend) + } + }) + } +} + +func TestParseInit_backendFlags(t *testing.T) { + testCases := map[string]struct { + args []string + wantBackend Backend + }{ + "ignore-remote-version": { + args: []string{"-ignore-remote-version"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.IgnoreRemoteVersion = true + }), + }, + "lock disabled": { + args: []string{"-lock=false"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.StateLock = false + }), + }, + "lock-timeout": { + args: []string{"-lock-timeout=30s"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.StateLockTimeout = 30 * time.Second + }), + }, + "migrate-state": { + args: []string{"-migrate-state"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.MigrateState = true + }), + }, + "reconfigure": { + args: []string{"-reconfigure"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.Reconfigure = true + }), + }, + "force-copy": { + args: []string{"-force-copy"}, + wantBackend: backendWithDefaults(func(backend *Backend) { + backend.ForceInitCopy = true + backend.MigrateState = true + }), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.wantBackend, *got.Backend); diff != "" { + t.Errorf("unexpected Backend result (-want +got):\n%s", diff) + } + }) + } +} + +func TestParseInit_migrationFlagsValidation(t *testing.T) { + testCases := map[string]struct { + args []string + wantMigrateState bool + wantDiagDetails string + }{ + "force-copy implies migrate-state": { + args: []string{"-force-copy"}, + wantMigrateState: true, + }, + "migrate-state set": { + args: []string{"-migrate-state"}, + wantMigrateState: true, + }, + "reconfigure set": { + args: []string{"-reconfigure"}, + wantMigrateState: false, + }, + "migration-state and reconfigure set": { + args: []string{"-reconfigure", "-migrate-state"}, + wantMigrateState: true, + wantDiagDetails: "The -migrate-state and -reconfigure options are mutually-exclusive", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + switch { + case len(diags) == 0 && len(tc.wantDiagDetails) > 0: + t.Fatal("expected to have a diagnostic but got none") + case len(diags) > 0 && len(tc.wantDiagDetails) == 0: + t.Fatalf("expected no diagnostic but got: %s", diags) + case len(diags) > 0 && len(tc.wantDiagDetails) > 0: + diag := diags[0] + if diag.Description().Detail != tc.wantDiagDetails { + t.Fatalf("Diagnostic Detail = %q; want %q", diag.Description().Detail, tc.wantDiagDetails) + } + } + + if got.Backend.MigrateState != tc.wantMigrateState { + t.Errorf("Backend.MigrateState = %v, want %v", got.Backend.MigrateState, tc.wantMigrateState) + } + }) + } +} + +func TestParseInit_backendConfig(t *testing.T) { + testCases := map[string]struct { + args []string + wantCount int + wantValues []string + }{ + "no backend config": { + args: nil, + wantCount: 0, + wantValues: nil, + }, + "single backend config kv": { + args: []string{"-backend-config=key=value"}, + wantCount: 1, + wantValues: []string{"key=value"}, + }, + "backend config file": { + args: []string{"-backend-config=/path/to/config.hcl"}, + wantCount: 1, + wantValues: []string{"/path/to/config.hcl"}, + }, + "multiple backend configs": { + args: []string{"-backend-config=k1=v1", "-backend-config=k2=v2", "-backend-config=/path/config.hcl"}, + wantCount: 3, + wantValues: []string{"k1=v1", "k2=v2", "/path/config.hcl"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if tc.wantCount == 0 { + if !got.FlagConfigExtra.Empty() { + t.Error("FlagConfigExtra should be empty") + } + return + } + if got.FlagConfigExtra.Empty() { + t.Error("FlagConfigExtra should not be empty") + } + items := got.FlagConfigExtra.AllItems() + if len(items) != tc.wantCount { + t.Errorf("len(FlagConfigExtra.AllItems()) = %d, want %d", len(items), tc.wantCount) + } + for i, want := range tc.wantValues { + if items[i].Value != want { + t.Errorf("FlagConfigExtra.AllItems()[%d].Value = %q, want %q", i, items[i].Value, want) + } + if items[i].Name != "-backend-config" { + t.Errorf("FlagConfigExtra.AllItems()[%d].Name = %q, want %q", i, items[i].Name, "-backend-config") + } + } + }) + } +} + +func TestParseInit_vars(t *testing.T) { + testCases := map[string]struct { + args []string + wantCount int + wantEmpty bool + }{ + "no vars": { + args: nil, + wantCount: 0, + wantEmpty: true, + }, + "single var": { + args: []string{"-var", "foo=bar"}, + wantCount: 1, + wantEmpty: false, + }, + "single var-file": { + args: []string{"-var-file", "terraform.tfvars"}, + wantCount: 1, + wantEmpty: false, + }, + "multiple vars mixed": { + args: []string{"-var", "a=1", "-var-file", "f.tfvars", "-var", "b=2"}, + wantCount: 3, + wantEmpty: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if got.Vars.Empty() != tc.wantEmpty { + t.Errorf("Vars.Empty() = %v, want %v", got.Vars.Empty(), tc.wantEmpty) + } + if len(got.Vars.All()) != tc.wantCount { + t.Errorf("len(Vars.All()) = %d, want %d", len(got.Vars.All()), tc.wantCount) + } + }) + } +} + +func TestParseInit_tooManyArguments(t *testing.T) { + testCases := map[string]struct { + args []string + }{ + "one positional argument": { + args: []string{"mydir"}, + }, + "multiple positional arguments": { + args: []string{"dir1", "dir2"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _, closer, diags := ParseInit(tc.args) + defer closer() + + if len(diags) == 0 { + t.Fatal("expected diagnostics but got none") + } + if got, want := diags.Err().Error(), "Unexpected argument"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got, want := diags.Err().Error(), "Too many command line arguments"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + }) + } +} + +func initArgsWithDefaults(mutate func(init *Init)) *Init { + ret := &Init{ + FlagFromModule: "", + FlagLockfile: "", + TestsDirectory: "tests", + FlagGet: true, + FlagUpgrade: false, + FlagPluginPath: nil, + FlagConfigExtra: flags.NewRawFlags("-backend-config"), + FlagBackend: true, + FlagCloud: true, + BackendFlagSet: false, + CloudFlagSet: false, + ViewOptions: ViewOptions{ + ViewType: ViewHuman, + InputEnabled: true, + }, + Vars: &Vars{}, + Backend: &Backend{StateLock: true}, + } + if mutate != nil { + mutate(ret) + } + return ret +} + +func backendWithDefaults(mutate func(backend *Backend)) Backend { + ret := Backend{ + IgnoreRemoteVersion: false, + StateLock: true, + StateLockTimeout: 0, + ForceInitCopy: false, + Reconfigure: false, + MigrateState: false, + } + if mutate != nil { + mutate(&ret) + } + return ret +} diff --git a/internal/command/cli_ui.go b/internal/command/cli_ui.go index e3ebcb5939..a08096907a 100644 --- a/internal/command/cli_ui.go +++ b/internal/command/cli_ui.go @@ -7,6 +7,7 @@ package command import ( "fmt" + "os" "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" @@ -54,3 +55,29 @@ func (u *ColorizeUi) colorize(message string, color string) string { return u.Colorize.Color(fmt.Sprintf("%s%s[reset]", color, message)) } + +// ui wraps the primary output [cli.Ui], and redirects Warn calls to Output +// calls. This ensures that warnings are sent to stdout, and are properly +// serialized within the stdout stream. +type ui struct { + cli.Ui +} + +func (u *ui) Warn(msg string) { + u.Ui.Output(msg) +} + +// NewBasicUI returns a preconfigured [cli.Ui] that is meant to be used +// as the primary Ui for OpenTofu. +// TODO meta-refactor: this will have to be removed once everything is moved to views. +func NewBasicUI() cli.Ui { + return NewWrappedUi(&cli.BasicUi{ + Writer: os.Stdout, + ErrorWriter: os.Stderr, + Reader: os.Stdin, + }) +} + +func NewWrappedUi(u cli.Ui) cli.Ui { + return &ui{u} +} diff --git a/internal/command/e2etest/init_test.go b/internal/command/e2etest/init_test.go index 8d28d7b155..113b404d35 100644 --- a/internal/command/e2etest/init_test.go +++ b/internal/command/e2etest/init_test.go @@ -253,7 +253,7 @@ func TestInitProvidersLocalOnly(t *testing.T) { t.Errorf("success message is missing from output:\n%s", stdout) } - if !strings.Contains(stdout, `{"@level":"info","@message":"- Installing example.com/awesomecorp/happycloud v1.2.0...","@module":"tofu.ui"`) { + if !strings.Contains(stdout, `{"@level":"info","@message":"Installing example.com/awesomecorp/happycloud v1.2.0...","@module":"tofu.ui"`) { t.Errorf("provider download message is missing from output:\n%s", stdout) t.Logf("(this can happen if you have a conflicting copy of the plugin in one of the global plugin search dirs)") } @@ -577,7 +577,7 @@ Initializing provider plugins... // The following test is temporarily removed until the OpenTofu registry returns a deprecation warning // https://github.com/opentofu/registry/issues/108 -//func TestInitProviderWarnings(t *testing.T) { +// func TestInitProviderWarnings(t *testing.T) { // t.Parallel() // // // This test will reach out to registry.terraform.io as one of the possible @@ -596,7 +596,7 @@ Initializing provider plugins... // t.Errorf("expected warning message is missing from output:\n%s", stdout) // } // -//} +// } func escapeStringJSON(v string) string { b := &strings.Builder{} diff --git a/internal/command/init.go b/internal/command/init.go index 4b319b9b51..b7cf8a2bb2 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -7,6 +7,7 @@ package command import ( "context" + "errors" "fmt" "log" "reflect" @@ -45,116 +46,58 @@ type InitCommand struct { Meta } -func (c *InitCommand) Run(args []string) int { +func (c *InitCommand) Run(rawArgs []string) int { ctx := c.CommandContext() - ctx, span := tracing.Tracer().Start(ctx, "Init") defer span.End() - var flagFromModule, flagLockfile, testsDirectory string - var flagBackend, flagCloud, flagGet, flagUpgrade bool - var flagPluginPath flags.FlagStringSlice - flagConfigExtra := flags.NewRawFlags("-backend-config") + // new view + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) + // Because the legacy UI was using println to show diagnostics and the new view is using, by default, print, + // in order to keep functional parity, we setup the view to add a new line after each diagnostic. + c.View.DiagsWithNewline() - args = c.Meta.process(args) - cmdFlags := c.Meta.extendedFlagSet("init") - cmdFlags.BoolVar(&flagBackend, "backend", true, "") - cmdFlags.BoolVar(&flagCloud, "cloud", true, "") - cmdFlags.Var(flagConfigExtra, "backend-config", "") - cmdFlags.StringVar(&flagFromModule, "from-module", "", "copy the source of the given module into the directory before init") - cmdFlags.BoolVar(&flagGet, "get", true, "") - cmdFlags.BoolVar(&c.forceInitCopy, "force-copy", false, "suppress prompts about copying state data") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - cmdFlags.BoolVar(&c.reconfigure, "reconfigure", false, "reconfigure") - cmdFlags.BoolVar(&c.migrateState, "migrate-state", false, "migrate state") - cmdFlags.BoolVar(&flagUpgrade, "upgrade", false, "") - cmdFlags.Var(&flagPluginPath, "plugin-dir", "plugin directory") - cmdFlags.StringVar(&flagLockfile, "lockfile", "", "Set a dependency lockfile mode") - cmdFlags.BoolVar(&c.Meta.ignoreRemoteVersion, "ignore-remote-version", false, "continue even if remote and local OpenTofu versions are incompatible") - cmdFlags.StringVar(&testsDirectory, "test-directory", "tests", "test-directory") - cmdFlags.BoolVar(&c.outputInJSON, "json", false, "json") - cmdFlags.StringVar(&c.outputJSONInto, "json-into", "", "json-into") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. + c.Meta.color = !common.NoColor + c.Meta.Color = c.Meta.color + + // Parse and validate flags + args, closer, diags := arguments.ParseInit(rawArgs) + defer closer() + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + view := views.NewInit(args.ViewOptions, c.View) + // ... and initialise the Meta.Ui to wrap Meta.View into a new implementation + // that is able to print by using View abstraction and use the Meta.Ui + // to ask for the user input. + c.Meta.configureUiFromView(args.ViewOptions) + + if diags.HasErrors() { + view.Diagnostics(diags) + view.HelpPrompt() return 1 } - if c.outputInJSON { - c.Meta.color = false - c.Meta.Color = false - c.oldUi = c.Ui - c.Ui = &WrappedUi{ - cliUi: c.oldUi, - jsonView: views.NewJSONView(c.View, nil), - onlyOutputInJSON: true, - } + // FIXME: the -input flag value is needed to initialize the backend and the + // operation, but there is no clear path to pass this value down, so we + // continue to mutate the Meta object state for now. + c.Meta.input = args.ViewOptions.InputEnabled + c.configureBackendFlags(args.Backend) + + if len(args.FlagPluginPath) > 0 { + c.pluginPath = args.FlagPluginPath } + c.GatherVariables(args.Vars) - if c.outputJSONInto != "" { - if c.outputInJSON { - // Not a valid combination - c.Ui.Error("The -json and -json-into options are mutually-exclusive in their use") - return 1 - } - - // NOTE: see meta_ui.go for color stripping in this legacy situation - - out, closer, diags := arguments.OpenJSONIntoFile(c.outputJSONInto) - defer closer() - if diags.HasErrors() { - c.Ui.Error(diags.Err().Error()) - return 1 - } - - c.oldUi = c.Ui - c.Ui = &WrappedUi{ - cliUi: c.oldUi, - jsonView: views.NewJSONView(c.View, out), - onlyOutputInJSON: false, - } - } - - backendFlagSet := flags.FlagIsSet(cmdFlags, "backend") - cloudFlagSet := flags.FlagIsSet(cmdFlags, "cloud") - - switch { - case backendFlagSet && cloudFlagSet: - c.Ui.Error("The -backend and -cloud options are aliases of one another and mutually-exclusive in their use") - return 1 - case backendFlagSet: - flagCloud = flagBackend - case cloudFlagSet: - flagBackend = flagCloud - } - - if c.migrateState && c.reconfigure { - c.Ui.Error("The -migrate-state and -reconfigure options are mutually-exclusive") - return 1 - } - - // Copying the state only happens during backend migration, so setting - // -force-copy implies -migrate-state - if c.forceInitCopy { - c.migrateState = true - } - - var diags tfdiags.Diagnostics - - if len(flagPluginPath) > 0 { - c.pluginPath = flagPluginPath - } - - // Validate the arg count and get the working directory - args = cmdFlags.Args() - path, err := modulePath(args) - if err != nil { - c.Ui.Error(err.Error()) - return 1 - } + // This gets the current directory as full path. + path := c.WorkingDir.NormalizePath(c.WorkingDir.RootModuleDir()) if err := c.storePluginPath(c.pluginPath); err != nil { - c.Ui.Error(fmt.Sprintf("Error saving -plugin-path values: %s", err)) + view.Diagnostics(diags.Append(fmt.Errorf("Error saving -plugin-path values: %w", err))) return 1 } @@ -166,28 +109,24 @@ func (c *InitCommand) Run(args []string) int { // to output a newline before the success message var header bool - if flagFromModule != "" { - src := flagFromModule + if args.FlagFromModule != "" { + src := args.FlagFromModule empty, err := configs.IsEmptyDir(path) if err != nil { - c.Ui.Error(fmt.Sprintf("Error validating destination directory: %s", err)) + view.Diagnostics(diags.Append(fmt.Errorf("Error validating destination directory: %w", err))) return 1 } if !empty { - c.Ui.Error(strings.TrimSpace(errInitCopyNotEmpty)) + view.Diagnostics(diags.Append(errors.New(strings.TrimSpace(errInitCopyNotEmpty)))) return 1 } - c.Ui.Output(c.Colorize().Color(fmt.Sprintf( - "[reset][bold]Copying configuration[reset] from %q...", src, - ))) + view.CopyFromModule(src) header = true - hooks := uiModuleInstallHooks{ - Ui: c.Ui, - ShowLocalPaths: false, // since they are in a weird location for init - } + // do not show local directory, since they are in a weird location for init + hooks := view.Hooks(false) ctx, span := tracing.Tracer().Start(ctx, "From module", tracing.SpanAttributes( traceattrs.OpenTofuModuleSource(src), @@ -197,46 +136,45 @@ func (c *InitCommand) Run(args []string) int { initDirFromModuleAbort, initDirFromModuleDiags := c.initDirFromModule(ctx, path, src, hooks) diags = diags.Append(initDirFromModuleDiags) if initDirFromModuleAbort || initDirFromModuleDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) tracing.SetSpanError(span, initDirFromModuleDiags) span.End() return 1 } - c.Ui.Output("") + view.OutputNewline() } // If our directory is empty, then we're done. We can't get or set up // the backend with an empty directory. empty, err := configs.IsEmptyDir(path) if err != nil { - diags = diags.Append(fmt.Errorf("Error checking configuration: %w", err)) - c.showDiagnostics(diags) + view.Diagnostics(diags.Append(fmt.Errorf("Error checking configuration: %w", err))) return 1 } if empty { - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitEmpty))) + view.InitialisedFromEmptyDir() return 0 } // Load just the root module to begin backend and module initialization - rootModEarly, earlyConfDiags := c.loadSingleModuleWithTests(ctx, path, testsDirectory) + rootModEarly, earlyConfDiags := c.loadSingleModuleWithTests(ctx, path, args.TestsDirectory) // There may be parsing errors in config loading but these will be shown later _after_ // checking for core version requirement errors. Not meeting the version requirement should // be the first error displayed if that is an issue, but other operations are required // before being able to check core version requirements. if rootModEarly == nil { - c.Ui.Error(c.Colorize().Color(strings.TrimSpace(errInitConfigError))) + view.ConfigError() diags = diags.Append(earlyConfDiags) - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } var enc encryption.Encryption // If backend flag is explicitly set to false i.e -backend=false, we disable state and plan encryption - if backendFlagSet && !flagBackend { + if args.BackendFlagSet && !args.FlagBackend { enc = encryption.Disabled() } else { // Load the encryption configuration @@ -244,7 +182,7 @@ func (c *InitCommand) Run(args []string) int { enc, encDiags = c.EncryptionFromModule(ctx, rootModEarly) diags = diags.Append(encDiags) if encDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } } @@ -257,10 +195,10 @@ func (c *InitCommand) Run(args []string) int { var backendOutput bool switch { - case flagCloud && rootModEarly.CloudConfig != nil: - back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, flagConfigExtra, enc) - case flagBackend: - back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, flagConfigExtra, enc) + case args.FlagCloud && rootModEarly.CloudConfig != nil: + back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, args.FlagConfigExtra, enc, view) + case args.FlagBackend: + back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, args.FlagConfigExtra, enc, view) default: // load the previously-stored backend config back, backDiags = c.Meta.backendFromState(ctx, enc.State()) @@ -278,28 +216,28 @@ func (c *InitCommand) Run(args []string) int { c.ignoreRemoteVersionConflict(back) workspace, err := c.Workspace(ctx) if err != nil { - c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err)) + view.Diagnostics(diags.Append(fmt.Errorf("Error selecting workspace: %w", err))) return 1 } sMgr, err := back.StateMgr(ctx, workspace) if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) + view.Diagnostics(diags.Append(fmt.Errorf("Error loading state: %s", err))) return 1 } if err := sMgr.RefreshState(context.TODO()); err != nil { - c.Ui.Error(fmt.Sprintf("Error refreshing state: %s", err)) + view.Diagnostics(diags.Append(fmt.Errorf("Error refreshing state: %s", err))) return 1 } state = sMgr.State() } - if flagGet { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, testsDirectory, rootModEarly, flagUpgrade) + if args.FlagGet { + modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, args.TestsDirectory, rootModEarly, args.FlagUpgrade, view) diags = diags.Append(modsDiags) if modsAbort || modsDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } if modsOutput { @@ -309,7 +247,7 @@ func (c *InitCommand) Run(args []string) int { // With all of the modules (hopefully) installed, we can now try to load the // whole configuration tree. - config, confDiags := c.loadConfigWithTests(ctx, path, testsDirectory) + config, confDiags := c.loadConfigWithTests(ctx, path, args.TestsDirectory) // configDiags will be handled after the version constraint check, since an // incorrect version of tofu may be producing errors for configuration // constructs added in later versions. @@ -320,7 +258,7 @@ func (c *InitCommand) Run(args []string) int { // potentially-confusing downstream errors. versionDiags := tofu.CheckCoreVersionRequirements(config) if versionDiags.HasErrors() { - c.showDiagnostics(versionDiags) + view.Diagnostics(versionDiags) return 1 } @@ -331,8 +269,8 @@ func (c *InitCommand) Run(args []string) int { // backend. diags = diags.Append(earlyConfDiags.StrictDeduplicateMerge(backDiags)) if earlyConfDiags.HasErrors() { - c.Ui.Error(strings.TrimSpace(errInitConfigError)) - c.showDiagnostics(diags) + view.ConfigError() + view.Diagnostics(diags) return 1 } @@ -340,7 +278,7 @@ func (c *InitCommand) Run(args []string) int { // show the errInitConfigError preamble as we didn't detect problems with // the early configuration. if backDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } @@ -348,8 +286,8 @@ func (c *InitCommand) Run(args []string) int { // show other errors from loading the full configuration tree. diags = diags.Append(confDiags) if confDiags.HasErrors() { - c.Ui.Error(strings.TrimSpace(errInitConfigError)) - c.showDiagnostics(diags) + view.ConfigError() + view.Diagnostics(diags) return 1 } @@ -357,7 +295,7 @@ func (c *InitCommand) Run(args []string) int { if c.RunningInAutomation { if err := cb.AssertImportCompatible(config); err != nil { diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "Compatibility error", err.Error())) - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } } @@ -369,17 +307,17 @@ func (c *InitCommand) Run(args []string) int { migratedState, migrateDiags := tofumigrate.MigrateStateProviderAddresses(config, state) diags = diags.Append(migrateDiags) if migrateDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } state = migratedState } // Now that we have loaded all modules, check the module tree for missing providers. - providersOutput, providersAbort, providerDiags := c.getProviders(ctx, config, state, flagUpgrade, flagPluginPath, flagLockfile) + providersOutput, providersAbort, providerDiags := c.getProviders(ctx, config, state, args.FlagUpgrade, args.FlagPluginPath, args.FlagLockfile, view) diags = diags.Append(providerDiags) if providersAbort || providerDiags.HasErrors() { - c.showDiagnostics(diags) + view.Diagnostics(diags) return 1 } if providersOutput { @@ -389,35 +327,25 @@ func (c *InitCommand) Run(args []string) int { // If we outputted information, then we need to output a newline // so that our success message is nicely spaced out from prior text. if header { - c.Ui.Output("") + view.OutputNewline() } // If we accumulated any warnings along the way that weren't accompanied // by errors then we'll output them here so that the success message is // still the final thing shown. - c.showDiagnostics(diags) - _, cloud := back.(*cloud.Cloud) - output := outputInitSuccess - if cloud { - output = outputInitSuccessCloud - } - - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) - + view.Diagnostics(diags) + _, isCloud := back.(*cloud.Cloud) + view.InitSuccess(isCloud) if !c.RunningInAutomation { // If we're not running in an automation wrapper, give the user // some more detailed next steps that are appropriate for interactive // shell usage. - output = outputInitSuccessCLI - if cloud { - output = outputInitSuccessCLICloud - } - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) + view.InitSuccessCLI(isCloud) } return 0 } -func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool) (output bool, abort bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init) (output bool, abort bool, diags tfdiags.Diagnostics) { testModules := false // We can also have modules buried in test files. for _, file := range earlyRoot.Tests { for _, run := range file.Runs { @@ -437,16 +365,9 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear )) defer span.End() - if upgrade { - c.Ui.Output(c.Colorize().Color("[reset][bold]Upgrading modules...")) - } else { - c.Ui.Output(c.Colorize().Color("[reset][bold]Initializing modules...")) - } + view.InitializingModules(upgrade) - hooks := uiModuleInstallHooks{ - Ui: c.Ui, - ShowLocalPaths: true, - } + hooks := view.Hooks(true) installAbort, installDiags := c.installModules(ctx, path, testsDir, upgrade, false, hooks) diags = diags.Append(installDiags) @@ -471,12 +392,12 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear return true, installAbort, diags } -func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extraConfig flags.RawFlags, enc encryption.Encryption) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extraConfig flags.RawFlags, enc encryption.Encryption, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { ctx, span := tracing.Tracer().Start(ctx, "Cloud backend init") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() - c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing cloud backend...")) + view.InitializingCloudBackend() if len(extraConfig.AllItems()) != 0 { diags = diags.Append(tfdiags.Sourceless( @@ -499,12 +420,12 @@ func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extra return back, true, diags } -func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, extraConfig flags.RawFlags, enc encryption.Encryption) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, extraConfig flags.RawFlags, enc encryption.Encryption, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { ctx, span := tracing.Tracer().Start(ctx, "Backend init") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() - c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing the backend...")) + view.InitializingBackend() var backendConfig *configs.Backend var backendConfigOverride hcl.Body @@ -536,7 +457,7 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext return nil, true, diags } if backendType != canonType { - c.Ui.Output(fmt.Sprintf("- %q is an alias for backend type %q", backendType, canonType)) + view.BackendTypeAlias(backendType, canonType) } b := bf(nil) // This is only used to get the schema, encryption should panic if attempted @@ -587,7 +508,7 @@ the backend configuration is present and valid. // Load the complete module tree, and fetch any missing providers. // This method outputs its own Ui. -func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, state *states.State, upgrade bool, pluginDirs []string, flagLockfile string) (output, abort bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, state *states.State, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output, abort bool, diags tfdiags.Diagnostics) { ctx, span := tracing.Tracer().Start(ctx, "Get Providers") defer span.End() @@ -680,19 +601,13 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, // are shimming our vt100 output to the legacy console API on Windows. evts := &providercache.InstallerEvents{ PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - c.Ui.Output(c.Colorize().Color( - "\n[reset][bold]Initializing provider plugins...", - )) + view.InitializingProviderPlugins() }, ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version, inProviderCache bool) { - if inProviderCache { - c.Ui.Info(fmt.Sprintf("- Detected previously-installed %s v%s in the shared cache directory", provider.ForDisplay(), selectedVersion)) - } else { - c.Ui.Info(fmt.Sprintf("- Using previously-installed %s v%s", provider.ForDisplay(), selectedVersion)) - } + view.ProviderAlreadyInstalled(provider.ForDisplay(), selectedVersion.String(), inProviderCache) }, BuiltInProviderAvailable: func(provider addrs.Provider) { - c.Ui.Info(fmt.Sprintf("- %s is built in to OpenTofu", provider.ForDisplay())) + view.BuiltInProviderAvailable(provider.ForDisplay()) }, BuiltInProviderFailure: func(provider addrs.Provider, err error) { diags = diags.Append(tfdiags.Sourceless( @@ -703,24 +618,20 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, }, QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { if locked { - c.Ui.Info(fmt.Sprintf("- Reusing previous version of %s from the dependency lock file", provider.ForDisplay())) + view.ReusingLockFileVersion(provider.ForDisplay()) } else { if len(versionConstraints) > 0 { - c.Ui.Info(fmt.Sprintf("- Finding %s versions matching %q...", provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints))) + view.FindingProviderVersions(provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) } else { - c.Ui.Info(fmt.Sprintf("- Finding latest version of %s...", provider.ForDisplay())) + view.FindingLatestProviderVersion(provider.ForDisplay()) } } }, LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - c.Ui.Info(fmt.Sprintf("- Using %s v%s from the shared cache directory", provider.ForDisplay(), version)) + view.UsingProviderFromCache(provider.ForDisplay(), version.String()) }, FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation, inProviderCache bool) { - if inProviderCache { - c.Ui.Info(fmt.Sprintf("- Installing %s v%s to the shared cache directory...", provider.ForDisplay(), version)) - } else { - c.Ui.Info(fmt.Sprintf("- Installing %s v%s...", provider.ForDisplay(), version)) - } + view.InstallingProvider(provider.ForDisplay(), version.String(), inProviderCache) }, QueryPackagesFailure: func(provider addrs.Provider, err error) { switch errorTy := err.(type) { @@ -923,18 +834,15 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, if authResult != nil && authResult.Signed() { keyID = authResult.GPGKeyIDsString() } - if keyID != "" { - keyID = c.Colorize().Color(fmt.Sprintf(", key ID [reset][bold]%s[reset]", keyID)) - } if authResult != nil && authResult.SigningSkipped() { - c.Ui.Warn(fmt.Sprintf("- Installed %s v%s. Signature validation was skipped due to the registry not containing GPG keys for this provider", provider.ForDisplay(), version)) + view.ProviderInstalledSkippedSignature(provider.ForDisplay(), version.String()) } else { - c.Ui.Info(fmt.Sprintf("- Installed %s v%s (%s%s)", provider.ForDisplay(), version, authResult, keyID)) + view.ProviderInstalled(provider.ForDisplay(), version.String(), authResult.String(), keyID) } }, CacheDirLockContended: func(cacheDir string) { - c.Ui.Info(fmt.Sprintf("- Waiting for lock on cache directory %s", cacheDir)) + view.WaitingForCacheLock(cacheDir) }, 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 @@ -980,9 +888,7 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, } } if thirdPartySigned { - c.Ui.Info(fmt.Sprintf("\nProviders are signed by their developers.\n" + - "If you'd like to know more about provider signing, you can read about it here:\n" + - "https://opentofu.org/docs/cli/plugins/signing/")) + view.ProvidersSignedInfo() } }, } @@ -993,7 +899,7 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, mode := providercache.InstallNewProvidersOnly if upgrade { if flagLockfile == "readonly" { - c.Ui.Error("The -upgrade flag conflicts with -lockfile=readonly.") + view.ProviderUpgradeLockfileConflict() return true, true, diags } @@ -1001,8 +907,8 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, } newLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) if ctx.Err() == context.Canceled { - c.showDiagnostics(diags) - c.Ui.Error("Provider installation was canceled by an interrupt signal.") + view.Diagnostics(diags) + view.ProviderInstallationInterrupted() return true, true, diags } if err != nil { @@ -1069,16 +975,9 @@ func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, // say a little about what the dependency lock file is, for new // users or those who are upgrading from a previous Terraform // version that didn't have dependency lock files. - c.Ui.Output(c.Colorize().Color(` -OpenTofu has created a lock file [bold].terraform.lock.hcl[reset] to record the provider -selections it made above. Include this file in your version control repository -so that OpenTofu can guarantee to make the same selections by default when -you run "tofu init" in the future.`)) + view.LockFileCreated() } else { - c.Ui.Output(c.Colorize().Color(` -OpenTofu has made some changes to the provider dependency selections recorded -in the .terraform.lock.hcl file. Review those changes and commit them to your -version control system if they represent changes you intended to make.`)) + view.LockFileChanged() } moreDiags = c.replaceLockedDependencies(ctx, newLocks) @@ -1234,6 +1133,39 @@ func (c *InitCommand) AutocompleteArgs() complete.Predictor { return complete.PredictDirs("") } +// TODO meta-refactor: move this to arguments once all commands are using the same shim logic +func (c *InitCommand) GatherVariables(args *arguments.Vars) { + // FIXME the arguments package currently trivially gathers variable related + // arguments in a heterogeneous slice, in order to minimize the number of + // code paths gathering variables during the transition to this structure. + // Once all commands that gather variables have been converted to this + // structure, we could move the variable gathering code to the arguments + // package directly, removing this shim layer. + + varArgs := args.All() + items := make([]flags.RawFlag, len(varArgs)) + for i := range varArgs { + items[i].Name = varArgs[i].Name + items[i].Value = varArgs[i].Value + } + c.Meta.variableArgs = flags.RawFlags{Items: &items} +} + +// configureBackendFlags is a temporary shim until we move the backend migration logic away from the Meta fields. +// +// TODO meta-refactor: remove this when the Meta fields configured here will be removed and replaced +// with proper arguments for the backend. +func (c *InitCommand) configureBackendFlags(args *arguments.Backend) { + c.forceInitCopy = args.ForceInitCopy + c.reconfigure = args.Reconfigure + c.migrateState = args.MigrateState + c.Meta.ignoreRemoteVersion = args.IgnoreRemoteVersion + // TODO meta-refactor: unify these 2 args attributes with the state flags in arguments.extendedFlagSet + // https://github.com/opentofu/opentofu/blob/db8c872defd8666618649ef7e29fa2b809adfd5e/internal/command/arguments/extended.go#L320-L321 + c.Meta.stateLock = args.StateLock + c.Meta.stateLockTimeout = args.StateLockTimeout +} + func (c *InitCommand) AutocompleteFlags() complete.Flags { return complete.Flags{ "-backend": completePredictBoolean, @@ -1376,14 +1308,6 @@ func (c *InitCommand) Synopsis() string { return "Prepare your working directory for other commands" } -const errInitConfigError = ` -[reset]OpenTofu encountered problems during initialization, including problems -with the configuration, described below. - -The OpenTofu configuration must be valid before initialization so that -OpenTofu can determine which modules and providers need to be installed. -` - 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. @@ -1392,39 +1316,6 @@ To initialize the configuration already in this working directory, omit the -from-module option. ` -const outputInitEmpty = ` -[reset][bold]OpenTofu initialized in an empty directory![reset] - -The directory has no OpenTofu configuration files. You may begin working -with OpenTofu immediately by creating OpenTofu configuration files. -` - -const outputInitSuccess = ` -[reset][bold][green]OpenTofu has been successfully initialized![reset][green] -` - -const outputInitSuccessCloud = ` -[reset][bold][green]Cloud backend has been successfully initialized![reset][green] -` - -const outputInitSuccessCLI = `[reset][green] -You may now begin working with OpenTofu. Try running "tofu plan" to see -any changes that are required for your infrastructure. All OpenTofu commands -should now work. - -If you ever set or change modules or backend configuration for OpenTofu, -rerun this command to reinitialize your working directory. If you forget, other -commands will detect it and remind you to do so if necessary. -` - -const outputInitSuccessCLICloud = `[reset][green] -You may now begin working with cloud backend. Try running "tofu plan" to -see any changes that are required for your infrastructure. - -If you ever set or change modules or OpenTofu Settings, run "tofu init" -again to reinitialize your working directory. -` - // providerProtocolTooOld is a message sent to the CLI UI if the provider's // supported protocol versions are too old for the user's version of tofu, // but a newer version of the provider is compatible. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index cab1c0eeca..5c8e1285df 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -21,7 +21,6 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-version" - "github.com/mitchellh/cli" "github.com/opentofu/opentofu/internal/command/flags" "github.com/opentofu/opentofu/internal/command/workdir" "github.com/zclconf/go-cty/cty" @@ -44,20 +43,20 @@ func TestInit_empty(t *testing.T) { td := t.TempDir() t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } - args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + var args []string + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } } @@ -66,13 +65,11 @@ func TestInit_multipleArgs(t *testing.T) { td := t.TempDir() t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -81,8 +78,10 @@ func TestInit_multipleArgs(t *testing.T) { "bad", "bad", } - if code := c.Run(args); code != 1 { - t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: \n%s", output.Stdout()) } } @@ -91,13 +90,11 @@ func TestInit_fromModule_cwdDest(t *testing.T) { td := t.TempDir() t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, // This test relies on the module installer's legacy support for @@ -111,8 +108,10 @@ func TestInit_fromModule_cwdDest(t *testing.T) { args := []string{ "-from-module=" + testFixturePath("init"), } - if code := c.Run(args); code != 0 { - t.Fatalf("unexpected error\n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("unexpected error\n%s", output.Stderr()) } if _, err := os.Stat(filepath.Join(td, "hello.tf")); err != nil { @@ -140,13 +139,11 @@ func TestInit_fromModule_dstInSrc(t *testing.T) { } t.Chdir("foo") - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, // This test relies on the module installer's legacy support for @@ -160,8 +157,10 @@ func TestInit_fromModule_dstInSrc(t *testing.T) { args := []string{ "-from-module=./..", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } if _, err := os.Stat(filepath.Join(dir, "foo", "issue518.tf")); err != nil { @@ -175,26 +174,26 @@ func TestInit_get(t *testing.T) { testCopyDir(t, testFixturePath("init-get"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Check output - output := ui.OutputWriter.String() - if !strings.Contains(output, "foo in foo") { - t.Fatalf("doesn't look like we installed module 'foo': %s", output) + stdout := output.Stdout() + if !strings.Contains(stdout, "foo in foo") { + t.Fatalf("doesn't look like we installed module 'foo': %s", stdout) } } @@ -204,13 +203,11 @@ func TestInit_getUpgradeModules(t *testing.T) { testCopyDir(t, testFixturePath("init-get"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -219,14 +216,15 @@ func TestInit_getUpgradeModules(t *testing.T) { "-get=true", "-upgrade", } - if code := c.Run(args); code != 0 { - t.Fatalf("command did not complete successfully:\n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("command did not complete successfully:\n%s", output.Stderr()) } // Check output - output := ui.OutputWriter.String() - if !strings.Contains(output, "Upgrading modules...") { - t.Fatalf("doesn't look like get upgrade: %s", output) + if !strings.Contains(output.Stdout(), "Upgrading modules...") { + t.Fatalf("doesn't look like get upgrade: %s", output.Stdout()) } } @@ -236,20 +234,20 @@ func TestInit_backend(t *testing.T) { testCopyDir(t, testFixturePath("init-backend"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } if _, err := os.Stat(filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)); err != nil { @@ -266,25 +264,25 @@ func TestInit_backendUnset(t *testing.T) { { log.Printf("[TRACE] TestInit_backendUnset: beginning first init") - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } // Init args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } log.Printf("[TRACE] TestInit_backendUnset: first init complete") - t.Logf("First run output:\n%s", ui.OutputWriter.String()) - t.Logf("First run errors:\n%s", ui.ErrorWriter.String()) + t.Logf("First run output:\n%s", output.Stdout()) + t.Logf("First run errors:\n%s", output.Stderr()) if _, err := os.Stat(filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)); err != nil { t.Fatalf("err: %s", err) @@ -299,24 +297,24 @@ func TestInit_backendUnset(t *testing.T) { t.Fatalf("err: %s", err) } - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-force-copy"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } log.Printf("[TRACE] TestInit_backendUnset: second init complete") - t.Logf("Second run output:\n%s", ui.OutputWriter.String()) - t.Logf("Second run errors:\n%s", ui.ErrorWriter.String()) + t.Logf("Second run output:\n%s", output.Stdout()) + t.Logf("Second run errors:\n%s", output.Stderr()) s := testDataStateRead(t, filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)) if !s.Backend.Empty() { @@ -332,19 +330,19 @@ func TestInit_backendConfigFile(t *testing.T) { t.Chdir(td) t.Run("good-config-file", func(t *testing.T) { - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "input.config"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.All()) } // Read our saved backend config and verify we have our settings @@ -356,13 +354,11 @@ func TestInit_backendConfigFile(t *testing.T) { // the backend config file must not be a full tofu block t.Run("full-backend-config-file", func(t *testing.T) { - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -370,68 +366,69 @@ func TestInit_backendConfigFile(t *testing.T) { if code := c.Run(args); code != 1 { t.Fatalf("expected error, got success\n") } - if !strings.Contains(ui.ErrorWriter.String(), "Unsupported block type") { - t.Fatalf("wrong error: %s", ui.ErrorWriter) + output := done(t) + if !strings.Contains(output.Stderr(), "Unsupported block type") { + t.Fatalf("wrong error: %s", output.Stderr()) } }) // the backend config file must match the schema for the backend t.Run("invalid-config-file", func(t *testing.T) { - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "invalid.config"} - if code := c.Run(args); code != 1 { + code := c.Run(args) + output := done(t) + if code != 1 { t.Fatalf("expected error, got success\n") } - if !strings.Contains(ui.ErrorWriter.String(), "Unsupported argument") { - t.Fatalf("wrong error: %s", ui.ErrorWriter) + if !strings.Contains(output.Stderr(), "Unsupported argument") { + t.Fatalf("wrong error: %s", output.Stderr()) } }) // missing file is an error t.Run("missing-config-file", func(t *testing.T) { - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "missing.config"} - if code := c.Run(args); code != 1 { + code := c.Run(args) + output := done(t) + if code != 1 { t.Fatalf("expected error, got success\n") } - if !strings.Contains(ui.ErrorWriter.String(), "Failed to read file") { - t.Fatalf("wrong error: %s", ui.ErrorWriter) + if !strings.Contains(output.Stderr(), "Failed to read file") { + t.Fatalf("wrong error: %s", output.Stderr()) } }) // blank filename clears the backend config t.Run("blank-config-file", func(t *testing.T) { - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config=", "-migrate-state"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Read our saved backend config and verify the backend config is empty @@ -476,13 +473,11 @@ func TestInit_backendConfigFilePowershellConfusion(t *testing.T) { testCopyDir(t, testFixturePath("init-backend-config-file"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -496,12 +491,14 @@ func TestInit_backendConfigFilePowershellConfusion(t *testing.T) { // result in an early exit with a diagnostic that the provided // configuration file is not a directory. args := []string{"-backend-config=", "./input.config"} - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } - output := ui.ErrorWriter.String() - if got, want := output, `Too many command line arguments`; !strings.Contains(got, want) { + stderr := output.Stderr() + if got, want := stderr, `Too many command line arguments`; !strings.Contains(got, want) { t.Fatalf("wrong output\ngot:\n%s\n\nwant: message containing %q", got, want) } } @@ -517,14 +514,12 @@ func TestInit_backendReconfigure(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), ProviderSource: providerSource, - Ui: ui, View: view, }, } @@ -540,17 +535,18 @@ func TestInit_backendReconfigure(t *testing.T) { t.Fatalf("err: %s", err) } - args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // now run init again, changing the path. // The -reconfigure flag prevents init from migrating // Without -reconfigure, the test fails since the backend asks for input on migrating state - args = []string{"-reconfigure", "-backend-config", "path=changed"} + args := []string{"-reconfigure", "-backend-config", "path=changed"} if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + t.Fatalf("bad: \n%s", output.Stderr()) } } @@ -560,20 +556,20 @@ func TestInit_backendConfigFileChange(t *testing.T) { testCopyDir(t, testFixturePath("init-backend-config-file-change"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "input.config", "-migrate-state"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Read our saved backend config and verify we have our settings @@ -594,14 +590,12 @@ func TestInit_backendMigrateWhileLocked(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), ProviderSource: providerSource, - Ui: ui, View: view, }, } @@ -623,9 +617,11 @@ func TestInit_backendMigrateWhileLocked(t *testing.T) { t.Fatal(err) } // Attempt to migrate - args := []string{"-backend-config", "input.config", "-migrate-state", "-force-copy"} - if code := c.Run(args); code == 0 { - t.Fatalf("expected nonzero exit code: %s", ui.OutputWriter.String()) + args := []string{"-no-color", "-backend-config", "input.config", "-migrate-state", "-force-copy"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("expected nonzero exit code: %s", output.Stdout()) } // Unlock before trying to migrate again @@ -633,7 +629,7 @@ func TestInit_backendMigrateWhileLocked(t *testing.T) { args = []string{"-backend-config", "input.config", "-migrate-state", "-force-copy", "-lock=false"} if code := c.Run(args); code != 0 { - t.Fatalf("expected zero exit code, got %d: %s", code, ui.ErrorWriter.String()) + t.Fatalf("expected zero exit code, got %d: %s", code, output.Stderr()) } } @@ -643,12 +639,12 @@ func TestInit_backendConfigFileChangeWithExistingState(t *testing.T) { testCopyDir(t, testFixturePath("init-backend-config-file-change-migrate-existing"), td) t.Chdir(td) - ui := new(cli.MockUi) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, + View: view, }, } @@ -656,9 +652,14 @@ func TestInit_backendConfigFileChangeWithExistingState(t *testing.T) { // we deliberately do not provide the answer for backend-migrate-copy-to-empty to trigger error args := []string{"-migrate-state", "-backend-config", "input.config", "-input=true"} - if code := c.Run(args); code == 0 { + code := c.Run(args) + output := done(t) + if code == 0 { t.Fatal("expected error") } + if !strings.Contains(output.Stderr(), "input is disabled") { + t.Fatalf("the reason of failure is not the one expected: %s", output.Stderr()) + } // Read our backend config and verify new settings are not saved state := testDataStateRead(t, filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)) @@ -678,20 +679,20 @@ func TestInit_backendConfigKV(t *testing.T) { testCopyDir(t, testFixturePath("init-backend-config-kv"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "path=hello"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Read our saved backend config and verify we have our settings @@ -707,28 +708,26 @@ func TestInit_backendConfigKVReInit(t *testing.T) { testCopyDir(t, testFixturePath("init-backend-config-kv"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "path=test"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } - ui = new(cli.MockUi) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -736,7 +735,7 @@ func TestInit_backendConfigKVReInit(t *testing.T) { // a second init should require no changes, nor should it change the backend. args = []string{"-input=false"} if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + t.Fatalf("bad: \n%s", output.Stderr()) } // make sure the backend is configured how we expect @@ -752,7 +751,7 @@ func TestInit_backendConfigKVReInit(t *testing.T) { // override the -backend-config options by settings args = []string{"-input=false", "-backend-config", "", "-migrate-state"} if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + t.Fatalf("bad: \n%s", output.Stderr()) } // make sure the backend is configured how we expect @@ -772,28 +771,27 @@ func TestInit_backendConfigKVReInitWithConfigDiff(t *testing.T) { testCopyDir(t, testFixturePath("init-backend"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-input=false"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } - ui = new(cli.MockUi) + view, done = testView(t) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } @@ -801,8 +799,10 @@ func TestInit_backendConfigKVReInitWithConfigDiff(t *testing.T) { // a second init with identical config should require no changes, nor // should it change the backend. args = []string{"-input=false", "-backend-config", "path=foo"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code = c.Run(args) + output = done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // make sure the backend is configured how we expect @@ -822,23 +822,29 @@ func TestInit_backendCli_no_config_block(t *testing.T) { testCopyDir(t, testFixturePath("init"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } - args := []string{"-backend-config", "path=test"} - if code := c.Run(args); code != 0 { - t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + args := []string{"-no-color", "-backend-config", "path=test"} + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } - errMsg := ui.ErrorWriter.String() + // The previous used output here was the stderr. That was correct in terms of the MockUi (and cli.BasicUi) implementation. + // The was not correct in terms of OpenTofu configuration. The Ui configured for OpenTofu, sends the warning diagnostics + // to stdout and not stderr. (see https://github.com/opentofu/opentofu/blob/db8c872defd8666618649ef7e29fa2b809adfd5e/cmd/tofu/main.go#L49-L51) + // This change from stderr to stdout has been done while we migrated the init command to use views, which the underlying + // logic, writes the warnings to stdout. + // Before the change, the MockUi used in this test was not considering this particularity of OpenTofu. + errMsg := output.Stdout() if !strings.Contains(errMsg, "Warning: Missing backend configuration") { t.Fatal("expected missing backend block warning, got", errMsg) } @@ -862,20 +868,20 @@ func TestInit_backendReinitWithExtra(t *testing.T) { t.Fatal(err) } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-backend-config", "path=hello"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Read our saved backend config and verify we have our settings @@ -890,7 +896,7 @@ func TestInit_backendReinitWithExtra(t *testing.T) { // init again and make sure nothing changes if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + t.Fatalf("bad: \n%s", output.Stderr()) } state = testDataStateRead(t, filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)) if got, want := normalizeJSON(t, state.Backend.ConfigRaw), `{"path":"hello","workspace_dir":null}`; got != want { @@ -907,19 +913,19 @@ func TestInit_backendReinitConfigToExtra(t *testing.T) { testCopyDir(t, testFixturePath("init-backend"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } - if code := c.Run([]string{"-input=false"}); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run([]string{"-input=false"}) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Read our saved backend config and verify we have our settings @@ -938,18 +944,20 @@ func TestInit_backendReinitConfigToExtra(t *testing.T) { // We need a fresh InitCommand here because the old one now has our configuration // file cached inside it, so it won't re-read the modification we just made. + view, done = testView(t) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-input=false", "-backend-config=path=foo"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code = c.Run(args) + output = done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } state = testDataStateRead(t, filepath.Join(workdir.DefaultDataDir, DefaultStateFilename)) if got, want := normalizeJSON(t, state.Backend.ConfigRaw), `{"path":"foo","workspace_dir":null}`; got != want { @@ -1018,21 +1026,21 @@ func TestInit_backendCloudInvalidOptions(t *testing.T) { // operations and state work in that case, and so the Cloud // configuration is only about which workspaces we'll be working // with. - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-backend-config=anything"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-backend-config=anything"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1058,21 +1066,21 @@ Cloud configuration block in the root module. // steps to take care of more details automatically, and so // -reconfigure doesn't really make sense in that context, particularly // with its design bug with the handling of the implicit local backend. - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-reconfigure"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-reconfigure"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1098,21 +1106,21 @@ Cloud configuration settings. t.Fatal(err) } - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-reconfigure"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-reconfigure"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1130,21 +1138,21 @@ because activating cloud backend involves some additional steps. // In Cloud mode, migrating in or out always proposes migrating state // and changing configuration while staying in cloud mode never migrates // state, so this special option isn't relevant. - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-migrate-state"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-migrate-state"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1170,21 +1178,21 @@ storage location is not configurable. t.Fatal(err) } - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-migrate-state"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-migrate-state"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1205,21 +1213,21 @@ prompts. // In Cloud mode, migrating in or out always proposes migrating state // and changing configuration while staying in cloud mode never migrates // state, so this special option isn't relevant. - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-force-copy"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-force-copy"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1245,21 +1253,21 @@ storage location is not configurable. t.Fatal(err) } - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - args := []string{"-force-copy"} - if code := c.Run(args); code == 0 { - t.Fatalf("unexpected success\n%s", ui.OutputWriter.String()) + args := []string{"-no-color", "-force-copy"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("unexpected success\n%s", output.Stdout()) } - gotStderr := ui.ErrorWriter.String() + gotStderr := output.Stderr() wantStderr := ` Error: Invalid command-line option @@ -1283,20 +1291,20 @@ func TestInit_inputFalse(t *testing.T) { testCopyDir(t, testFixturePath("init-backend"), td) t.Chdir(td) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{"-input=false", "-backend-config=path=foo"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // write different states for foo and bar @@ -1323,40 +1331,42 @@ func TestInit_inputFalse(t *testing.T) { t.Fatal(err) } - ui = new(cli.MockUi) + view, done = testView(t) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args = []string{"-input=false", "-backend-config=path=bar", "-migrate-state"} - if code := c.Run(args); code == 0 { - t.Fatal("init should have failed", ui.OutputWriter) + code = c.Run(args) + output = done(t) + if code == 0 { + t.Fatal("init should have failed", output.Stdout()) } - errMsg := ui.ErrorWriter.String() + errMsg := output.Stderr() if !strings.Contains(errMsg, "interactive input is disabled") { t.Fatal("expected input disabled error, got", errMsg) } - ui = new(cli.MockUi) + view, done = testView(t) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } // A missing input=false should abort rather than loop infinitely args = []string{"-backend-config=path=baz"} - if code := c.Run(args); code == 0 { - t.Fatal("init should have failed", ui.OutputWriter) + code = c.Run(args) + output = done(t) + if code == 0 { + t.Fatal("init should have failed", output.Stdout()) } } @@ -1367,8 +1377,7 @@ func TestInit_getProvider(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) providerSource, close := newMockProviderSource(t, map[string][]string{ // looking for an exact version "exact": {"1.2.3"}, @@ -1381,7 +1390,6 @@ func TestInit_getProvider(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -1393,8 +1401,10 @@ func TestInit_getProvider(t *testing.T) { args := []string{ "-backend=false", // should be possible to install plugins without backend init } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // check that we got the providers for our config @@ -1447,19 +1457,18 @@ func TestInit_getProvider(t *testing.T) { t.Fatal(err) } - ui := new(cli.MockUi) - view, _ := testView(t) - m.Ui = ui + view, done := testView(t) m.View = view c := &InitCommand{ Meta: m, } - - if code := c.Run(nil); code == 0 { - t.Fatal("expected error, got:", ui.OutputWriter) + code := c.Run(nil) + output := done(t) + if code == 0 { + t.Fatal("expected error, got:", output.Stdout()) } - errMsg := ui.ErrorWriter.String() + errMsg := output.Stderr() if !strings.Contains(errMsg, "Unsupported state file format") { t.Fatal("unexpected error:", errMsg) } @@ -1473,8 +1482,7 @@ func TestInit_getProviderSource(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) providerSource, close := newMockProviderSource(t, map[string][]string{ // looking for an exact version "acme/alpha": {"1.2.3"}, @@ -1486,7 +1494,6 @@ func TestInit_getProviderSource(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -1498,8 +1505,10 @@ func TestInit_getProviderSource(t *testing.T) { args := []string{ "-backend=false", // should be possible to install plugins without backend init } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // check that we got the providers for our config @@ -1524,8 +1533,7 @@ func TestInit_getProviderLegacyFromState(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) providerSource, close := newMockProviderSource(t, map[string][]string{ "acme/alpha": {"1.2.3"}, }) @@ -1533,7 +1541,6 @@ func TestInit_getProviderLegacyFromState(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -1542,8 +1549,10 @@ func TestInit_getProviderLegacyFromState(t *testing.T) { Meta: m, } - if code := c.Run(nil); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } // Expect this diagnostic output @@ -1551,7 +1560,7 @@ func TestInit_getProviderLegacyFromState(t *testing.T) { "Invalid legacy provider address", "You must complete the Terraform 0.13 upgrade process", } - got := ui.ErrorWriter.String() + got := output.Stderr() for _, want := range wants { if !strings.Contains(got, want) { t.Fatalf("expected output to contain %q, got:\n\n%s", want, got) @@ -1566,8 +1575,7 @@ func TestInit_getProviderInvalidPackage(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) // create a provider source which allows installing an invalid package addr := addrs.MustParseProviderSourceString("invalid/package") @@ -1588,7 +1596,6 @@ func TestInit_getProviderInvalidPackage(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -1600,8 +1607,10 @@ func TestInit_getProviderInvalidPackage(t *testing.T) { args := []string{ "-backend=false", // should be possible to install plugins without backend init } - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } // invalid provider should be installed @@ -1614,7 +1623,7 @@ func TestInit_getProviderInvalidPackage(t *testing.T) { "Failed to install provider", "could not find executable file starting with terraform-provider-package", } - got := ui.ErrorWriter.String() + got := output.Stderr() for _, wantError := range wantErrors { if !strings.Contains(got, wantError) { t.Fatalf("missing error:\nwant: %q\ngot:\n%s", wantError, got) @@ -1644,11 +1653,9 @@ func TestInit_getProviderDetectedLegacy(t *testing.T) { {Source: registrySource}, } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, ProviderSource: multiSource, } @@ -1660,8 +1667,10 @@ func TestInit_getProviderDetectedLegacy(t *testing.T) { args := []string{ "-backend=false", // should be possible to install plugins without backend init } - if code := c.Run(args); code == 0 { - t.Fatalf("expected error, got output: \n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("expected error, got output: \n%s", output.Stdout()) } // foo should be installed @@ -1676,7 +1685,7 @@ func TestInit_getProviderDetectedLegacy(t *testing.T) { } // error output is the main focus of this test - errOutput := ui.ErrorWriter.String() + errOutput := output.Stderr() errors := []string{ "Failed to query available provider packages", "Could not retrieve the list of available versions", @@ -1713,11 +1722,9 @@ func TestInit_getProviderDetectedDuplicate(t *testing.T) { {Source: registrySource}, } - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, ProviderSource: multiSource, } @@ -1727,15 +1734,25 @@ func TestInit_getProviderDetectedDuplicate(t *testing.T) { } args := []string{ + "-no-color", "-backend=false", // should be possible to install plugins without backend init } - if code := c.Run(args); code != 0 { - t.Fatalf("expected error, got output: \n%s\n%s", ui.OutputWriter.String(), ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("expected error, got output: \n%s\n%s", output.Stdout(), output.Stderr()) } // error output is the main focus of this test - errOutput := ui.ErrorWriter.String() - errors := []string{ + // + // The previous used output here was the stderr. That was correct in terms of the MockUi (and cli.BasicUi) implementation. + // The was not correct in terms of OpenTofu configuration. The Ui configured for OpenTofu, sends the warning diagnostics + // to stdout and not stderr. (see https://github.com/opentofu/opentofu/blob/db8c872defd8666618649ef7e29fa2b809adfd5e/cmd/tofu/main.go#L49-L51) + // This change from stderr to stdout has been done while we migrated the init command to use views, which the underlying + // logic, writes the warnings to stdout. + // Before the change, the MockUi used in this test was not considering this particularity of OpenTofu. + stdOutput := output.Stdout() + warnings := []string{ "Warning: Potential provider misconfiguration", "OpenTofu has detected multiple providers of type foo", "If this is intentional you can ignore this warning", @@ -1743,14 +1760,14 @@ func TestInit_getProviderDetectedDuplicate(t *testing.T) { unexpected := []string{ "OpenTofu has detected multiple providers of type bar", } - for _, want := range errors { - if !strings.Contains(errOutput, want) { - t.Fatalf("expected error %q: %s", want, errOutput) + for _, want := range warnings { + if !strings.Contains(stdOutput, want) { + t.Fatalf("expected error %q: %s", want, stdOutput) } } for _, unwanted := range unexpected { - if strings.Contains(errOutput, unwanted) { - t.Fatalf("unexpected error %q: %s", unwanted, errOutput) + if strings.Contains(stdOutput, unwanted) { + t.Fatalf("unexpected error %q: %s", unwanted, stdOutput) } } @@ -1769,12 +1786,10 @@ func TestInit_providerSource(t *testing.T) { }) defer close() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -1783,12 +1798,14 @@ func TestInit_providerSource(t *testing.T) { Meta: m, } - args := []string{} + args := []string{"-no-color"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } - if strings.Contains(ui.OutputWriter.String(), "OpenTofu has initialized, but configuration upgrades may be needed") { + if strings.Contains(output.Stdout(), "OpenTofu has initialized, but configuration upgrades may be needed") { t.Fatalf("unexpected \"configuration upgrade\" warning in output") } @@ -1860,14 +1877,21 @@ func TestInit_providerSource(t *testing.T) { t.Errorf("wrong version selections after upgrade\n%s", diff) } - if got, want := ui.OutputWriter.String(), "Installed hashicorp/test v1.2.3 (verified checksum)"; !strings.Contains(got, want) { + if got, want := output.Stdout(), "Installed hashicorp/test v1.2.3 (verified checksum)"; !strings.Contains(got, want) { t.Fatalf("unexpected output: %s\nexpected to include %q", got, want) } - // On stderr we should've written a warning about the dependency lock file + // On stdout we should've written a warning about the dependency lock file // entry being incomplete for these three providers, because we installed // from a non-origin-registry source and so registry-promised hashes // are not available. - if got, want := ui.ErrorWriter.String(), "\n - hashicorp/source\n - hashicorp/test\n - hashicorp/test-beta"; !strings.Contains(got, want) { + // + // The previous used output here was the stderr. That was correct in terms of the MockUi (and cli.BasicUi) implementation. + // The was not correct in terms of OpenTofu configuration. The Ui configured for OpenTofu, sends the warning diagnostics + // to stdout and not stderr. (seehttps://github.com/opentofu/opentofu/blob/db8c872defd8666618649ef7e29fa2b809adfd5e/cmd/tofu/main.go#L49-L51) + // This change from stderr to stdout has been done while we migrated the init command to use views, which the underlying + // logic, writes the warnings to stdout. + // Before the change, the MockUi used in this test was not considering this particularity of OpenTofu. + if got, want := output.Stdout(), "\n - hashicorp/source\n - hashicorp/test\n - hashicorp/test-beta"; !strings.Contains(got, want) { t.Fatalf("wrong error message\nshould contain: %s\ngot:\n%s", want, got) } } @@ -1909,12 +1933,10 @@ func TestInit_cancelModules(t *testing.T) { server.CloseClientConnections() // force any active client request to fail }() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ShutdownCh: shutdownCh, @@ -1934,13 +1956,14 @@ func TestInit_cancelModules(t *testing.T) { t.Logf("attempting to install module package from %s", fakeModuleSourceAddr) args := []string{"-var=module_source=" + fakeModuleSourceAddr} code := c.Run(args) + output := done(t) if err := ctx.Err(); err != nil { t.Errorf("context error: %s", err) // probably reporting a timeout } if code == 0 { - t.Fatalf("succeeded; wanted error\n%s", ui.OutputWriter.String()) + t.Fatalf("succeeded; wanted error\n%s", output.Stdout()) } - if got, want := ui.ErrorWriter.String(), `Module installation was canceled by an interrupt signal`; !strings.Contains(got, want) { + if got, want := output.Stderr(), `Module installation was canceled by an interrupt signal`; !strings.Contains(got, want) { t.Fatalf("wrong error message\nshould contain: %s\ngot:\n%s", want, got) } } @@ -1963,12 +1986,10 @@ func TestInit_cancelProviders(t *testing.T) { shutdownCh := make(chan struct{}) close(shutdownCh) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, ShutdownCh: shutdownCh, @@ -1980,14 +2001,16 @@ func TestInit_cancelProviders(t *testing.T) { args := []string{} - if code := c.Run(args); code == 0 { - t.Fatalf("succeeded; wanted error\n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("succeeded; wanted error\n%s", output.Stdout()) } // Currently the first operation that is cancelable is provider // installation, so our error message comes from there. If we // make the earlier steps cancelable in future then it'd be // expected for this particular message to change. - if got, want := ui.ErrorWriter.String(), `Provider installation was canceled by an interrupt signal`; !strings.Contains(got, want) { + if got, want := output.Stderr(), `Provider installation was canceled by an interrupt signal`; !strings.Contains(got, want) { t.Fatalf("wrong error message\nshould contain: %s\ngot:\n%s", want, got) } } @@ -2008,12 +2031,10 @@ func TestInit_getUpgradePlugins(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2030,8 +2051,10 @@ func TestInit_getUpgradePlugins(t *testing.T) { args := []string{ "-upgrade=true", } - if code := c.Run(args); code != 0 { - t.Fatalf("command did not complete successfully:\n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("command did not complete successfully:\n%s", output.Stderr()) } cacheDir := providercache.NewDir(m.WorkingDir.ProviderLocalCacheDir()) @@ -2137,12 +2160,10 @@ func TestInit_getProviderMissing(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2152,12 +2173,14 @@ func TestInit_getProviderMissing(t *testing.T) { } args := []string{} - if code := c.Run(args); code == 0 { - t.Fatalf("expected error, got output: \n%s", ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("expected error, got output: \n%s", output.Stdout()) } - if !strings.Contains(ui.ErrorWriter.String(), "no available releases match") { - t.Fatalf("unexpected error output: %s", ui.ErrorWriter) + if !strings.Contains(output.Stderr(), "no available releases match") { + t.Fatalf("unexpected error output: %s", output.Stderr()) } } @@ -2167,22 +2190,22 @@ func TestInit_checkRequiredVersion(t *testing.T) { testCopyDir(t, testFixturePath("init-check-required-version"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } args := []string{} - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if !strings.Contains(errStr, `required_version = "~> 0.9.0"`) { t.Fatalf("output should point to unmet version constraint, but is:\n\n%s", errStr) } @@ -2199,22 +2222,21 @@ func TestInit_checkRequiredVersionFirst(t *testing.T) { testCopyDir(t, testFixturePath("init-check-required-version-first"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } - args := []string{} - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if !strings.Contains(errStr, `Unsupported OpenTofu Core version`) { t.Fatalf("output should point to unmet version constraint, but is:\n\n%s", errStr) } @@ -2224,22 +2246,21 @@ func TestInit_checkRequiredVersionFirst(t *testing.T) { testCopyDir(t, testFixturePath("init-check-required-version-first-module"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, }, } - args := []string{} - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if !strings.Contains(errStr, `Unsupported OpenTofu Core version`) { t.Fatalf("output should point to unmet version constraint, but is:\n\n%s", errStr) } @@ -2263,12 +2284,10 @@ func TestInit_providerLockFile(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2277,9 +2296,10 @@ func TestInit_providerLockFile(t *testing.T) { Meta: m, } - args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } lockFile := ".terraform.lock.hcl" @@ -2312,8 +2332,12 @@ provider "registry.opentofu.org/hashicorp/test" { if err := os.Chmod(".", 0555); err != nil { t.Fatal(err) } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + view, done = testView(t) + c.Meta.View = view + code = c.Run(nil) + output = done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } } @@ -2448,15 +2472,14 @@ provider "registry.opentofu.org/hashicorp/test" { td := t.TempDir() testCopyDir(t, testFixturePath(tc.fixture), td) t.Chdir(td) - + view, done := testView(t) providerSource, close := newMockProviderSource(t, tc.providers) defer close() - ui := new(cli.MockUi) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, + View: view, ProviderSource: providerSource, } @@ -2471,11 +2494,12 @@ provider "registry.opentofu.org/hashicorp/test" { } code := c.Run(tc.args) + output := done(t) if tc.ok && code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + t.Fatalf("bad: \n%s", output.Stderr()) } if !tc.ok && code == 0 { - t.Fatalf("expected error, got output: \n%s", ui.OutputWriter.String()) + t.Fatalf("expected error, got output: \n%s", output.Stdout()) } buf, err := os.ReadFile(lockFile) @@ -2499,13 +2523,11 @@ func TestInit_pluginDirReset(t *testing.T) { providerSource, close := newMockProviderSource(t, nil) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, }, @@ -2521,8 +2543,10 @@ func TestInit_pluginDirReset(t *testing.T) { // run once and save the -plugin-dir args := []string{"-plugin-dir", "a"} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } pluginDirs, err := c.loadPluginPath() @@ -2534,12 +2558,11 @@ func TestInit_pluginDirReset(t *testing.T) { t.Fatalf(`expected plugin dir ["a"], got %q`, pluginDirs) } - ui = new(cli.MockUi) + view, done = testView(t) c = &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, // still empty }, @@ -2547,8 +2570,10 @@ func TestInit_pluginDirReset(t *testing.T) { // make sure we remove the plugin-dir record args = []string{"-plugin-dir="} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter) + code = c.Run(args) + output = done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } pluginDirs, err = c.loadPluginPath() @@ -2571,12 +2596,10 @@ func TestInit_pluginDirProviders(t *testing.T) { providerSource, close := newMockProviderSource(t, nil) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2613,8 +2636,10 @@ func TestInit_pluginDirProviders(t *testing.T) { "-plugin-dir", "b", "-plugin-dir", "c", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } locks, err := m.lockedDependencies() @@ -2673,12 +2698,10 @@ func TestInit_pluginDirProvidersDoesNotGet(t *testing.T) { }) defer close() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2713,15 +2736,17 @@ func TestInit_pluginDirProvidersDoesNotGet(t *testing.T) { "-plugin-dir", "a", "-plugin-dir", "b", } - if code := c.Run(args); code == 0 { + code := c.Run(args) + output := done(t) + if code == 0 { // should have been an error - t.Fatalf("succeeded; want error\nstdout:\n%s\nstderr\n%s", ui.OutputWriter, ui.ErrorWriter) + t.Fatalf("succeeded; want error\nstdout:\n%s\nstderr\n%s", output.Stdout(), output.Stderr()) } // The error output should mention the "between" provider but should not // mention either the "exact" or "greater-than" provider, because the // latter two are available via the -plugin-dir directories. - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "hashicorp/between"; !strings.Contains(errStr, subStr) { t.Errorf("error output should mention the 'between' provider\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2747,12 +2772,10 @@ func TestInit_pluginDirWithBuiltIn(t *testing.T) { providerSource, close := newMockProviderSource(t, nil) defer close() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2762,11 +2785,13 @@ func TestInit_pluginDirWithBuiltIn(t *testing.T) { } args := []string{"-plugin-dir", "./"} - if code := c.Run(args); code != 0 { - t.Fatalf("error: %s", ui.ErrorWriter) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("error: %s", output.Stderr()) } - outputStr := ui.OutputWriter.String() + outputStr := output.Stdout() if subStr := "terraform.io/builtin/terraform is built in to OpenTofu"; !strings.Contains(outputStr, subStr) { t.Errorf("output should mention the tofu provider\nwant substr: %s\ngot:\n%s", subStr, outputStr) } @@ -2786,12 +2811,10 @@ func TestInit_invalidBuiltInProviders(t *testing.T) { providerSource, close := newMockProviderSource(t, nil) defer close() - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, View: view, ProviderSource: providerSource, } @@ -2800,11 +2823,13 @@ func TestInit_invalidBuiltInProviders(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run(nil) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "Cannot use terraform.io/builtin/terraform: built-in"; !strings.Contains(errStr, subStr) { t.Errorf("error output should mention the terraform provider\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2818,11 +2843,9 @@ func TestInit_invalidSyntaxNoBackend(t *testing.T) { testCopyDir(t, testFixturePath("init-syntax-invalid-no-backend"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, } @@ -2830,11 +2853,13 @@ func TestInit_invalidSyntaxNoBackend(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run([]string{"-no-color"}) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below."; !strings.Contains(errStr, subStr) { t.Errorf("Error output should include preamble\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2848,11 +2873,9 @@ func TestInit_invalidSyntaxWithBackend(t *testing.T) { testCopyDir(t, testFixturePath("init-syntax-invalid-with-backend"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, } @@ -2860,11 +2883,13 @@ func TestInit_invalidSyntaxWithBackend(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run([]string{"-no-color"}) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below."; !strings.Contains(errStr, subStr) { t.Errorf("Error output should include preamble\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2878,11 +2903,9 @@ func TestInit_invalidSyntaxInvalidBackend(t *testing.T) { testCopyDir(t, testFixturePath("init-syntax-invalid-backend-invalid"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, } @@ -2890,11 +2913,13 @@ func TestInit_invalidSyntaxInvalidBackend(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run([]string{"-no-color"}) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below."; !strings.Contains(errStr, subStr) { t.Errorf("Error output should include preamble\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2911,11 +2936,9 @@ func TestInit_invalidSyntaxBackendAttribute(t *testing.T) { testCopyDir(t, testFixturePath("init-syntax-invalid-backend-attribute-invalid"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, } @@ -2923,11 +2946,13 @@ func TestInit_invalidSyntaxBackendAttribute(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run([]string{"-no-color"}) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() if subStr := "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below."; !strings.Contains(errStr, subStr) { t.Errorf("Error output should include preamble\nwant substr: %s\ngot:\n%s", subStr, errStr) } @@ -2952,21 +2977,21 @@ func TestInit_tests(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider), - Ui: ui, View: view, ProviderSource: providerSource, }, } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } } @@ -2983,24 +3008,24 @@ func TestInit_testsWithProvider(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider), - Ui: ui, View: view, ProviderSource: providerSource, }, } - args := []string{} - if code := c.Run(args); code == 0 { - t.Fatalf("expected failure but got: \n%s", ui.OutputWriter.String()) + args := []string{"-no-color"} + code := c.Run(args) + output := done(t) + if code == 0 { + t.Fatalf("expected failure but got: \n%s", output.Stdout()) } - got := ui.ErrorWriter.String() + got := output.Stderr() want := ` Error: Failed to resolve provider packages @@ -3026,27 +3051,25 @@ func TestInit_testsWithModule(t *testing.T) { }) defer close() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider), - Ui: ui, View: view, ProviderSource: providerSource, }, } - args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) } // Check output - output := ui.OutputWriter.String() - if !strings.Contains(output, "test.main.setup in setup") { - t.Fatalf("doesn't look like we installed the test module': %s", output) + if !strings.Contains(output.Stdout(), "test.main.setup in setup") { + t.Fatalf("doesn't look like we installed the test module': %s", output.Stdout()) } } @@ -3057,20 +3080,20 @@ func TestInit_moduleSource(t *testing.T) { testCopyDir(t, testFixturePath("init-module-variable-source"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) closeInput := testInteractiveInput(t, []string{"./mod"}) defer closeInput() c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - if code := c.Run(nil); code != 0 { - t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } }) @@ -3079,20 +3102,20 @@ func TestInit_moduleSource(t *testing.T) { testCopyDir(t, testFixturePath("init-module-variable-source-multiple"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) closeInput := testInteractiveInput(t, []string{"./mod"}) defer closeInput() c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } - if code := c.Run(nil); code != 0 { - t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } }) @@ -3101,14 +3124,12 @@ func TestInit_moduleSource(t *testing.T) { testCopyDir(t, testFixturePath("init-module-variable-source"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) closeInput := testInteractiveInput(t, []string{}) defer closeInput() c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } @@ -3117,8 +3138,10 @@ func TestInit_moduleSource(t *testing.T) { "-input=false", } - if code := c.Run(args); code != 1 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } }) @@ -3127,19 +3150,19 @@ func TestInit_moduleSource(t *testing.T) { testCopyDir(t, testFixturePath("init-module-variable-source"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } args := []string{"-var", "src=./mod"} - if code := c.Run(args); code != 0 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } }) } @@ -3155,19 +3178,19 @@ func TestInit_moduleVersion(t *testing.T) { testCopyDir(t, testFixturePath("init-module-variable-version"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) c := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, }, } args := []string{"-var", "modver=0.0.1"} - if code := c.Run(args); code != 0 { - t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, ui.ErrorWriter.String(), ui.OutputWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout()) } }) } @@ -3177,11 +3200,9 @@ func TestInit_invalidExtraLabel(t *testing.T) { testCopyDir(t, testFixturePath("init-syntax-invalid-extra-label"), td) t.Chdir(td) - ui := cli.NewMockUi() - view, _ := testView(t) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), - Ui: ui, View: view, } @@ -3189,14 +3210,16 @@ func TestInit_invalidExtraLabel(t *testing.T) { Meta: m, } - if code := c.Run(nil); code == 0 { - t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", ui.OutputWriter, ui.ErrorWriter) + code := c.Run([]string{"-no-color"}) + output := done(t) + if code == 0 { + t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr()) } - errStr := ui.ErrorWriter.String() + errStr := output.Stderr() splitted := strings.Split(errStr, "Error: Unsupported block type") if len(splitted) != 2 { - t.Fatalf("want exactly one unsupported block type errors but got: %d\nstderr:\n%s\n\nstdout:\n%s", len(splitted)-1, errStr, ui.OutputWriter.String()) + t.Fatalf("want exactly one unsupported block type errors but got: %d\nstderr:\n%s\n\nstdout:\n%s", len(splitted)-1, errStr, output.Stdout()) } } @@ -3208,8 +3231,7 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) providerSource, closeCallback := newMockProviderSource(t, map[string][]string{ // mock aws provider "hashicorp/aws": {"5.0", "5.8"}, @@ -3218,7 +3240,6 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -3230,8 +3251,10 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { args := []string{ "-backend=false", // should disable reading encryption key run init successfully } - if code := c.Run(args); code != 0 { - t.Fatalf("init should run successfully with -backend=false: \ngot error : %s\n", ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("init should run successfully with -backend=false: \ngot error : %s\n", output.Stderr()) } }) @@ -3242,8 +3265,7 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { t.Chdir(td) overrides := metaOverridesForProvider(testProvider()) - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) providerSource, closeCallback := newMockProviderSource(t, map[string][]string{ // mock aws provider "hashicorp/aws": {"5.0", "5.8"}, @@ -3252,7 +3274,6 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: overrides, - Ui: ui, View: view, ProviderSource: providerSource, } @@ -3260,13 +3281,14 @@ func TestInit_skipEncryptionBackendFalse(t *testing.T) { c := &InitCommand{ Meta: m, } - var args []string // Check error is generated from trying to read encryption key or fail test - if code := c.Run(args); code == 0 { + code := c.Run(args) + output := done(t) + if code == 0 { t.Fatalf("init should not run successfully\n") - } else if !strings.Contains(ui.ErrorWriter.String(), "key_provider.aws_kms.key failed with error:") { - t.Fatalf("generated error should contain the string \"Error: Unable to fetch encryption key data\"\ninstead got : %s\n", ui.ErrorWriter.String()) + } else if !strings.Contains(output.Stderr(), "key_provider.aws_kms.key failed with error:") { + t.Fatalf("generated error should contain the string \"Error: Unable to fetch encryption key data\"\ninstead got : %s\n", output.Stderr()) } }) } diff --git a/internal/command/meta.go b/internal/command/meta.go index d1e2741915..1ebf7a981e 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -694,6 +694,39 @@ func (m *Meta) process(args []string) []string { return args } +// configureUiFromView is a shim method between now and the moment when +// the remote backend and cloud package use the new View abstraction. +// This method does several things: +// - creates a new [NewBasicUI] if [Meta.Ui] is nil (needed for testing, see below) +// - wraps the existing [Meta.Ui] into a new layer that uses the [views.View] +// to print information and the existing [Meta.Ui] to ask for use input +func (m *Meta) configureUiFromView(options arguments.ViewOptions) { + // We do this so that we retain the ability to technically call + // process multiple times, even if we have no plans to do so + if m.oldUi != nil { + m.Ui = m.oldUi + } + // This is a workaround to be able to get rid of the [Meta.Ui] slow and steady. + // For the moment, this builds the Ui in the same way it's built in the main.go, but we want + // it added here to remove the requirement of having the Ui initialised during tests. + // The highlight here is that the "printing" is done through the [Meta.View] and + // this Ui instance is used only to ask for user input. + // Therefore, tests can initialise only the View and check the output from there. + if m.Ui == nil { + m.Ui = NewBasicUI() + } + + // Backup the current Ui to be used later + m.oldUi = m.Ui + + // Createa new ViewUi that wraps the View for printing and oldUi for user input + m.Ui = &cli.ConcurrentUi{ + Ui: views.NewViewUI(options, m.View, m.oldUi), + } + // compared with Meta.process, this method does not configure the Meta.View, since that is the + // responsibility of the caller of this method. +} + // uiHook returns the UiHook to use with the context. func (m *Meta) uiHook() *views.UiHook { return views.NewUiHook(m.View) diff --git a/internal/command/providers_schema_test.go b/internal/command/providers_schema_test.go index 1e0f6e405c..ef0e98fbd0 100644 --- a/internal/command/providers_schema_test.go +++ b/internal/command/providers_schema_test.go @@ -61,11 +61,11 @@ func TestProvidersSchema_output(t *testing.T) { defer close() p := providersSchemaFixtureProvider() - ui := new(cli.MockUi) + view, done := testView(t) m := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(p), - Ui: ui, + View: view, ProviderSource: providerSource, } @@ -73,16 +73,23 @@ func TestProvidersSchema_output(t *testing.T) { ic := &InitCommand{ Meta: m, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + code := ic.Run([]string{}) + output := done(t) + if code != 0 { + t.Fatalf("init failed\n%s", output.Stderr()) } - // flush the init output from the mock ui - ui.OutputWriter.Reset() - // `tofu provider schemas` command - pc := &ProvidersSchemaCommand{Meta: m} - if code := pc.Run([]string{"-json"}); code != 0 { + // TODO meta-refactor-views: we need the ui here because the provider schema command is not yet migrated to views + // Once the command is migrated, remove this part and use the testView + ui := new(cli.MockUi) + m.Ui = ui + m.View = nil + pc := &ProvidersSchemaCommand{ + Meta: m, + } + code = pc.Run([]string{"-json"}) + if code != 0 { t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String()) } var got, want providerSchemas diff --git a/internal/command/providers_test.go b/internal/command/providers_test.go index 4ed290f0a9..8dfe696bfe 100644 --- a/internal/command/providers_test.go +++ b/internal/command/providers_test.go @@ -73,24 +73,26 @@ func TestProviders_modules(t *testing.T) { t.Chdir(td) // first run init with mock provider sources to install the module - initUi := new(cli.MockUi) providerSource, close := newMockProviderSource(t, map[string][]string{ "foo": {"1.0.0"}, "bar": {"2.0.0"}, "baz": {"1.2.2"}, }) defer close() - m := Meta{ + view, done := testView(t) + initMeta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(testProvider()), - Ui: initUi, + View: view, ProviderSource: providerSource, } ic := &InitCommand{ - Meta: m, + Meta: initMeta, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", initUi.ErrorWriter) + code := ic.Run([]string{}) + output := done(t) + if code != 0 { + t.Fatalf("init failed\n%s", output.Stderr()) } // Providers command @@ -110,14 +112,14 @@ func TestProviders_modules(t *testing.T) { wantOutput := []string{ "provider[registry.opentofu.org/hashicorp/foo] 1.0.0", // from required_providers "provider[registry.opentofu.org/hashicorp/bar] 2.0.0", // from provider config - "── module.kiddo", // tree node for child module + "── module.kiddo", // tree node for child module "provider[registry.opentofu.org/hashicorp/baz]", // implied by a resource in the child module } - output := ui.OutputWriter.String() + stdout := ui.OutputWriter.String() for _, want := range wantOutput { - if !strings.Contains(output, want) { - t.Errorf("output missing %s:\n%s", want, output) + if !strings.Contains(stdout, want) { + t.Errorf("output missing %s:\n%s", want, stdout) } } } diff --git a/internal/command/show_test.go b/internal/command/show_test.go index dc7155bc73..18225fd326 100644 --- a/internal/command/show_test.go +++ b/internal/command/show_test.go @@ -15,7 +15,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/mitchellh/cli" "github.com/opentofu/opentofu/internal/command/workdir" "github.com/zclconf/go-cty/cty" @@ -586,21 +585,23 @@ func TestShow_json_output(t *testing.T) { p := showFixtureProvider() // init - ui := new(cli.MockUi) + view, done := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(p), - Ui: ui, + View: view, ProviderSource: providerSource, }, } - if code := ic.Run([]string{}); code != 0 { + code := ic.Run([]string{}) + output := done(t) + if code != 0 { if expectError { // this should error, but not panic. return } - t.Fatalf("init failed\n%s", ui.ErrorWriter) + t.Fatalf("init failed\n%s", output.Stderr()) } // read expected output @@ -634,7 +635,7 @@ func TestShow_json_output(t *testing.T) { "-out=tofu.plan", } - code := pc.Run(args) + code = pc.Run(args) planOutput := planDone(t) var wantedCode int @@ -701,17 +702,19 @@ func TestShow_json_output_sensitive(t *testing.T) { p := showFixtureSensitiveProvider() // init - ui := new(cli.MockUi) + initView, initDone := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(p), - Ui: ui, + View: initView, ProviderSource: providerSource, }, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + code := ic.Run([]string{}) + output := initDone(t) + if code != 0 { + t.Fatalf("init failed\n%s", output.Stderr()) } // plan @@ -728,7 +731,7 @@ func TestShow_json_output_sensitive(t *testing.T) { args := []string{ "-out=tofu.plan", } - code := pc.Run(args) + code = pc.Run(args) planOutput := planDone(t) if code != 0 { @@ -801,17 +804,19 @@ func TestShow_json_output_conditions_refresh_only(t *testing.T) { p := showFixtureSensitiveProvider() // init - ui := new(cli.MockUi) + initView, initDone := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(p), - Ui: ui, ProviderSource: providerSource, + View: initView, }, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + initCode := ic.Run([]string{}) + output := initDone(t) + if initCode != 0 { + t.Fatalf("init failed\n%s", output.Stderr()) } // plan @@ -917,17 +922,19 @@ func TestShow_json_output_state(t *testing.T) { p := showFixtureProvider() // init - ui := new(cli.MockUi) + initView, initDone := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(p), - Ui: ui, + View: initView, ProviderSource: providerSource, }, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + initCode := ic.Run([]string{}) + output := initDone(t) + if initCode != 0 { + t.Fatalf("init failed\n%s", output.Stderr()) } // show @@ -1365,17 +1372,19 @@ func TestShow_config(t *testing.T) { defer close() // Initialize the module - ui := new(cli.MockUi) + initView, initDone := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(showFixtureProvider()), - Ui: ui, + View: initView, ProviderSource: providerSource, }, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + initCode := ic.Run([]string{}) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed\n%s", initOutput.Stderr()) } view, done := testView(t) @@ -1470,17 +1479,19 @@ func TestShow_config_withModule(t *testing.T) { defer close() // Initialize the module - ui := new(cli.MockUi) + initView, initDone := testView(t) ic := &InitCommand{ Meta: Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(showFixtureProvider()), - Ui: ui, + View: initView, ProviderSource: providerSource, }, } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) + initCode := ic.Run([]string{}) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed\n%s", initOutput.Stderr()) } view, done := testView(t) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 498aa80d6e..db988c5a46 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -766,12 +766,10 @@ can remove the provider configuration again. streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -781,23 +779,27 @@ can remove the provider configuration again. Meta: meta, } - if code := init.Run(nil); code != 0 { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + initCode := init.Run(nil) + initOutput := done(t) + if initCode != 0 { + t.Fatalf("expected status code 0 but got %d: %s", initCode, initOutput.Stderr()) } + streams, done = terminal.StreamsForTesting(t) + meta.View = views.NewView(streams) c := &TestCommand{ Meta: meta, } code := c.Run([]string{"-no-color"}) - output := done(t) + testOutput := done(t) if code != 1 { t.Errorf("expected status code 1 but got %d", code) } - actualOut, expectedOut := output.Stdout(), tc.expectedOut - actualErr, expectedErr := output.Stderr(), tc.expectedErr + actualOut, expectedOut := testOutput.Stdout(), tc.expectedOut + actualErr, expectedErr := testOutput.Stderr(), tc.expectedErr if diff := cmp.Diff(actualOut, expectedOut); len(diff) > 0 { t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expectedOut, actualOut, diff) @@ -915,11 +917,9 @@ func TestTest_Modules(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -929,26 +929,30 @@ func TestTest_Modules(t *testing.T) { Meta: meta, } - if code := init.Run(nil); code != 0 { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + initCode := init.Run(nil) + initOutput := done(t) + if initCode != 0 { + t.Fatalf("expected status code 0 but got %d: %s", initCode, initOutput.Stderr()) } + streams, done = terminal.StreamsForTesting(t) + meta.View = views.NewView(streams) command := &TestCommand{ Meta: meta, } code := command.Run([]string{"-no-color"}) - output := done(t) + testOutput := done(t) printedOutput := false if code != tc.code { printedOutput = true - t.Errorf("expected status code %d but got %d: %s", tc.code, code, output.All()) + t.Errorf("expected status code %d but got %d: %s", tc.code, code, testOutput.All()) } // If we're not expecting a failure, we can compare the output. if code != 1 { - actual := output.All() + actual := testOutput.All() if diff := cmp.Diff(actual, tc.expected); len(diff) > 0 { t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", tc.expected, actual, diff) } @@ -962,7 +966,7 @@ func TestTest_Modules(t *testing.T) { if provider.ResourceCount() > 0 { if !printedOutput { - t.Errorf("should have deleted all resources on completion but left %s\n\n%s", provider.ResourceString(), output.All()) + t.Errorf("should have deleted all resources on completion but left %s\n\n%s", provider.ResourceString(), testOutput.All()) } else { t.Errorf("should have deleted all resources on completion but left %s", provider.ResourceString()) } @@ -970,7 +974,7 @@ func TestTest_Modules(t *testing.T) { if provider.DataSourceCount() > 0 { if !printedOutput { - t.Errorf("should have deleted all data sources on completion but left %s\n\n%s", provider.DataSourceString(), output.All()) + t.Errorf("should have deleted all data sources on completion but left %s\n\n%s", provider.DataSourceString(), testOutput.All()) } else { t.Errorf("should have deleted all data sources on completion but left %s", provider.DataSourceString()) } @@ -993,12 +997,10 @@ func TestTest_StatePropagation(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -1008,16 +1010,21 @@ func TestTest_StatePropagation(t *testing.T) { Meta: meta, } - if code := init.Run(nil); code != 0 { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + initCode := init.Run(nil) + initOutput := done(t) + if initCode != 0 { + t.Fatalf("expected status code 0 but got %d: %s", initCode, initOutput.Stderr()) } + streams, done = terminal.StreamsForTesting(t) + meta.View = views.NewView(streams) + c := &TestCommand{ Meta: meta, } code := c.Run([]string{"-verbose", "-no-color"}) - output := done(t) + testOutput := done(t) if code != 0 { t.Errorf("expected status code 0 but got %d", code) @@ -1085,7 +1092,7 @@ Plan: 0 to add, 1 to change, 0 to destroy. Success! 5 passed, 0 failed. ` - actual := output.All() + actual := testOutput.All() if diff := cmp.Diff(actual, expected); len(diff) > 0 { t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expected, actual, diff) @@ -1291,11 +1298,9 @@ Success! 1 passed, 0 failed. streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -1305,22 +1310,26 @@ Success! 1 passed, 0 failed. Meta: meta, } - if code := init.Run(nil); code != 0 { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + initCode := init.Run(nil) + initOutput := done(t) + if initCode != 0 { + t.Fatalf("expected status code 0 but got %d: %s", initCode, initOutput.Stderr()) } + streams, done = terminal.StreamsForTesting(t) + meta.View = views.NewView(streams) command := &TestCommand{ Meta: meta, } code := command.Run([]string{"-verbose", "-no-color"}) - output := done(t) + testOutput := done(t) if code != tc.code { - t.Errorf("expected status code %d but got %d: %s", tc.code, code, output.All()) + t.Errorf("expected status code %d but got %d: %s", tc.code, code, testOutput.All()) } - actual := output.All() + actual := testOutput.All() if diff := cmp.Diff(actual, tc.expected); len(diff) > 0 { t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", tc.expected, actual, diff) @@ -1378,11 +1387,9 @@ func TestTest_InvalidLocalVariables(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -1392,32 +1399,37 @@ func TestTest_InvalidLocalVariables(t *testing.T) { Meta: meta, } - if code := init.Run(nil); code != 0 { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + initCode := init.Run(nil) + initOutput := done(t) + if initCode != 0 { + t.Fatalf("expected status code 0 but got %d: %s", initCode, initOutput.Stderr()) } + streams, done = terminal.StreamsForTesting(t) + meta.View = views.NewView(streams) + command := &TestCommand{ Meta: meta, } code := command.Run([]string{"-verbose", "-no-color"}) - output := done(t) + testOutput := done(t) if code != tc.code { - t.Errorf("expected status code %d but got %d: %s", tc.code, code, output.All()) + t.Errorf("expected status code %d but got %d: %s", tc.code, code, testOutput.All()) } - actual := output.All() + actual := testOutput.All() for _, containsString := range tc.contains { if !strings.Contains(actual, containsString) { - t.Errorf("expected '%s' in output but didn't find it: \n%s", containsString, output.All()) + t.Errorf("expected '%s' in output but didn't find it: \n%s", containsString, testOutput.All()) } } for _, notContainsString := range tc.notContains { if strings.Contains(actual, notContainsString) { - t.Errorf("expected not to find '%s' in output: \n%s", notContainsString, output.All()) + t.Errorf("expected not to find '%s' in output: \n%s", notContainsString, testOutput.All()) } } }) @@ -1462,13 +1474,11 @@ digits, underscores, and dashes. }) defer close() - streams, _ := terminal.StreamsForTesting(t) + streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) - ui := new(cli.MockUi) meta := Meta{ WorkingDir: workdir.NewDir("."), testingOverrides: metaOverridesForProvider(provider.Provider), - Ui: ui, View: view, Streams: streams, ProviderSource: providerSource, @@ -1477,9 +1487,10 @@ digits, underscores, and dashes. init := &InitCommand{ Meta: meta, } - - if code := init.Run(nil); code != tc.code { - t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + code := init.Run(nil) + initOutput := done(t) + if code != tc.code { + t.Fatalf("expected status code 0 but got %d: %s", code, initOutput.Stderr()) } }) } diff --git a/internal/command/views/hook_module_install.go b/internal/command/views/hook_module_install.go new file mode 100644 index 0000000000..ad1d1311b3 --- /dev/null +++ b/internal/command/views/hook_module_install.go @@ -0,0 +1,82 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "fmt" + + "github.com/hashicorp/go-version" + "github.com/opentofu/opentofu/internal/initwd" +) + +// moduleInstallationHookHuman is the implementation of [initwd.ModuleInstallHooks] that prints the modules +// installation progress information in human readable format. +type moduleInstallationHookHuman struct { + v *View + showLocalPaths bool +} + +var _ initwd.ModuleInstallHooks = moduleInstallationHookHuman{} + +func (h moduleInstallationHookHuman) Download(modulePath, packageAddr string, v *version.Version) { + if v != nil { + _, _ = h.v.streams.Println(fmt.Sprintf("Downloading %s %s for %s...", packageAddr, v, modulePath)) + } else { + _, _ = h.v.streams.Println(fmt.Sprintf("Downloading %s for %s...", packageAddr, modulePath)) + } +} + +func (h moduleInstallationHookHuman) Install(modulePath string, v *version.Version, localDir string) { + if h.showLocalPaths { + _, _ = h.v.streams.Println(fmt.Sprintf("- %s in %s", modulePath, localDir)) + } else { + _, _ = h.v.streams.Println(fmt.Sprintf("- %s", modulePath)) + } +} + +// moduleInstallationHookJSON is the implementation of [initwd.ModuleInstallHooks] that prints the modules +// installation progress information in JSON format. +type moduleInstallationHookJSON struct { + v *JSONView + showLocalPaths bool +} + +var _ initwd.ModuleInstallHooks = moduleInstallationHookJSON{} + +func (h moduleInstallationHookJSON) Download(modulePath, packageAddr string, v *version.Version) { + if v != nil { + h.v.Info(fmt.Sprintf("Downloading %s %s for %s...", packageAddr, v, modulePath)) + } else { + h.v.Info(fmt.Sprintf("Downloading %s for %s...", packageAddr, modulePath)) + } +} + +func (h moduleInstallationHookJSON) Install(modulePath string, _ *version.Version, localDir string) { + if h.showLocalPaths { + h.v.Info(fmt.Sprintf("installing %s in %s", modulePath, localDir)) + } else { + h.v.Info(fmt.Sprintf("installing %s", modulePath)) + } +} + +// moduleInstallationHookMulti is the implementation of [initwd.ModuleInstallHooks] that wraps multiple +// implementation of [initwd.ModuleInstallHooks] and acts as a proxy for all of those. +// This is used for the `-json-into` flag. +type moduleInstallationHookMulti []initwd.ModuleInstallHooks + +var _ initwd.ModuleInstallHooks = moduleInstallationHookMulti(nil) + +func (m moduleInstallationHookMulti) Download(modulePath, packageAddr string, v *version.Version) { + for _, h := range m { + h.Download(modulePath, packageAddr, v) + } +} + +func (m moduleInstallationHookMulti) Install(modulePath string, v *version.Version, localDir string) { + for _, h := range m { + h.Install(modulePath, v, localDir) + } +} diff --git a/internal/command/views/hook_module_install_test.go b/internal/command/views/hook_module_install_test.go new file mode 100644 index 0000000000..cf7426d726 --- /dev/null +++ b/internal/command/views/hook_module_install_test.go @@ -0,0 +1,147 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" + "github.com/opentofu/opentofu/internal/initwd" +) + +func TestModuleInstallationHooks(t *testing.T) { + tests := map[string]struct { + viewCall func(hook initwd.ModuleInstallHooks) + showLocalPaths bool + wantJson []map[string]any + wantStdout string + wantStderr string + }{ + "download_with_version": { + viewCall: func(hook initwd.ModuleInstallHooks) { + hook.Download("root.networking", "git::https://example.com/module.git", version.Must(version.NewVersion("2.5.3"))) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Downloading git::https://example.com/module.git 2.5.3 for root.networking...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("Downloading git::https://example.com/module.git 2.5.3 for root.networking..."), + }, + "download_without_version": { + viewCall: func(hook initwd.ModuleInstallHooks) { + hook.Download("root.storage", "git::https://example.com/storage.git", nil) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Downloading git::https://example.com/storage.git for root.storage...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("Downloading git::https://example.com/storage.git for root.storage..."), + }, + "install_without_local_path": { + viewCall: func(hook initwd.ModuleInstallHooks) { + hook.Install("root.networking", version.Must(version.NewVersion("2.5.3")), "/path/to/.terraform/modules/networking") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "installing root.networking", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- root.networking"), + }, + "install_with_local_path": { + viewCall: func(hook initwd.ModuleInstallHooks) { + hook.Install("root.networking", version.Must(version.NewVersion("2.5.3")), "/path/to/.terraform/modules/networking") + }, + showLocalPaths: true, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "installing root.networking in /path/to/.terraform/modules/networking", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- root.networking in /path/to/.terraform/modules/networking"), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + testModuleInstallationHookHuman(t, tc.viewCall, tc.showLocalPaths, tc.wantStdout, tc.wantStderr) + testModuleInstallationHookJson(t, tc.viewCall, tc.showLocalPaths, tc.wantJson) + testModuleInstallationHookMulti(t, tc.viewCall, tc.showLocalPaths, tc.wantStdout, tc.wantStderr, tc.wantJson) + }) + } +} + +func testModuleInstallationHookHuman(t *testing.T, call func(init initwd.ModuleInstallHooks), showLocalPaths bool, wantStdout, wantStderr string) { + view, done := testView(t) + moduleInstallationViewCall := moduleInstallationHookHuman{v: view, showLocalPaths: showLocalPaths} + call(moduleInstallationViewCall) + output := done(t) + if diff := cmp.Diff(wantStderr, output.Stderr()); diff != "" { + t.Errorf("invalid stderr (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(wantStdout, output.Stdout()); diff != "" { + t.Errorf("invalid stdout (-want, +got):\n%s", diff) + } +} + +func testModuleInstallationHookJson(t *testing.T, call func(init initwd.ModuleInstallHooks), showLocalPaths bool, want []map[string]interface{}) { + // New type just to assert the fields that we are interested in + view, done := testView(t) + moduleInstallationViewCall := moduleInstallationHookJSON{v: NewJSONView(view, nil), showLocalPaths: showLocalPaths} + call(moduleInstallationViewCall) + output := done(t) + if output.Stderr() != "" { + t.Errorf("expected no stderr but got:\n%s", output.Stderr()) + } + + testJSONViewOutputEquals(t, output.Stdout(), want) +} + +func testModuleInstallationHookMulti(t *testing.T, call func(init initwd.ModuleInstallHooks), showLocalPaths bool, wantStdout string, wantStderr string, want []map[string]interface{}) { + jsonInto, err := os.CreateTemp(t.TempDir(), "json-into-*") + if err != nil { + t.Fatalf("failed to create the file to write json content into: %s", err) + } + view, done := testView(t) + moduleInstallationViewCall := moduleInstallationHookMulti{ + moduleInstallationHookHuman{v: view, showLocalPaths: showLocalPaths}, + moduleInstallationHookJSON{v: NewJSONView(view, jsonInto), showLocalPaths: showLocalPaths}, + } + call(moduleInstallationViewCall) + { + if err := jsonInto.Close(); err != nil { + t.Fatalf("failed to close the jsonInto file: %s", err) + } + // check the fileInto content + fileContent, err := os.ReadFile(jsonInto.Name()) + if err != nil { + t.Fatalf("failed to read the file content with the json output: %s", err) + } + testJSONViewOutputEquals(t, string(fileContent), want) + } + { + // check the human output + output := done(t) + if diff := cmp.Diff(wantStderr, output.Stderr()); diff != "" { + t.Errorf("invalid stderr (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(wantStdout, output.Stdout()); diff != "" { + t.Errorf("invalid stdout (-want, +got):\n%s", diff) + } + } +} diff --git a/internal/command/views/init.go b/internal/command/views/init.go new file mode 100644 index 0000000000..7af3abf97b --- /dev/null +++ b/internal/command/views/init.go @@ -0,0 +1,592 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "fmt" + "strings" + + "github.com/opentofu/opentofu/internal/command/arguments" + "github.com/opentofu/opentofu/internal/initwd" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +type Init interface { + CopyFromModule(src string) + InitialisedFromEmptyDir() + + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt() + + ConfigError() + OutputNewline() + InitSuccess(cloud bool) + InitSuccessCLI(cloud bool) + + InitializingModules(upgrade bool) + + InitializingCloudBackend() + InitializingBackend() + BackendTypeAlias(backendType, canonType string) + + InitializingProviderPlugins() + ProviderAlreadyInstalled(provider string, version string, inCache bool) + BuiltInProviderAvailable(provider string) + ReusingLockFileVersion(provider string) + FindingProviderVersions(provider string, constraints string) + FindingLatestProviderVersion(provider string) + UsingProviderFromCache(provider string, version string) + InstallingProvider(provider string, version string, toCache bool) + ProviderInstalled(provider string, version string, authResult string, keyID string) + ProviderInstalledSkippedSignature(provider string, version string) + WaitingForCacheLock(cacheDir string) + ProvidersSignedInfo() + ProviderUpgradeLockfileConflict() + ProviderInstallationInterrupted() + LockFileCreated() + LockFileChanged() + Hooks(showLocalDir bool) initwd.ModuleInstallHooks +} + +// NewInit returns an initialized Init implementation for the given ViewType. +func NewInit(args arguments.ViewOptions, view *View) Init { + var init Init + switch args.ViewType { + case arguments.ViewJSON: + init = &InitJSON{view: NewJSONView(view, nil)} + case arguments.ViewHuman: + init = &InitHuman{view: view} + default: + panic(fmt.Sprintf("unknown view type %v", args.ViewType)) + } + + if args.JSONInto != nil { + init = &InitMulti{init, &InitJSON{view: NewJSONView(view, args.JSONInto)}} + } + return init +} + +type InitMulti []Init + +var _ Init = (InitMulti)(nil) + +func (m InitMulti) Diagnostics(diags tfdiags.Diagnostics) { + for _, o := range m { + o.Diagnostics(diags) + } +} + +func (m InitMulti) HelpPrompt() { + for _, o := range m { + o.HelpPrompt() + } +} + +func (m InitMulti) CopyFromModule(src string) { + for _, o := range m { + o.CopyFromModule(src) + } +} + +func (m InitMulti) InitialisedFromEmptyDir() { + for _, o := range m { + o.InitialisedFromEmptyDir() + } +} + +func (m InitMulti) ConfigError() { + for _, o := range m { + o.ConfigError() + } +} + +func (m InitMulti) OutputNewline() { + for _, o := range m { + o.OutputNewline() + } +} + +func (m InitMulti) InitSuccess(cloud bool) { + for _, o := range m { + o.InitSuccess(cloud) + } +} + +func (m InitMulti) InitSuccessCLI(cloud bool) { + for _, o := range m { + o.InitSuccessCLI(cloud) + } +} + +func (m InitMulti) InitializingModules(upgrade bool) { + for _, o := range m { + o.InitializingModules(upgrade) + } +} + +func (m InitMulti) InitializingCloudBackend() { + for _, o := range m { + o.InitializingCloudBackend() + } +} + +func (m InitMulti) InitializingBackend() { + for _, o := range m { + o.InitializingBackend() + } +} + +func (m InitMulti) BackendTypeAlias(backendType, canonType string) { + for _, o := range m { + o.BackendTypeAlias(backendType, canonType) + } +} + +func (m InitMulti) InitializingProviderPlugins() { + for _, o := range m { + o.InitializingProviderPlugins() + } +} + +func (m InitMulti) ProviderAlreadyInstalled(provider string, version string, inCache bool) { + for _, o := range m { + o.ProviderAlreadyInstalled(provider, version, inCache) + } +} + +func (m InitMulti) BuiltInProviderAvailable(provider string) { + for _, o := range m { + o.BuiltInProviderAvailable(provider) + } +} + +func (m InitMulti) ReusingLockFileVersion(provider string) { + for _, o := range m { + o.ReusingLockFileVersion(provider) + } +} + +func (m InitMulti) FindingProviderVersions(provider string, constraints string) { + for _, o := range m { + o.FindingProviderVersions(provider, constraints) + } +} + +func (m InitMulti) FindingLatestProviderVersion(provider string) { + for _, o := range m { + o.FindingLatestProviderVersion(provider) + } +} + +func (m InitMulti) UsingProviderFromCache(provider string, version string) { + for _, o := range m { + o.UsingProviderFromCache(provider, version) + } +} + +func (m InitMulti) InstallingProvider(provider string, version string, toCache bool) { + for _, o := range m { + o.InstallingProvider(provider, version, toCache) + } +} + +func (m InitMulti) ProviderInstalled(provider string, version string, authResult string, keyID string) { + for _, o := range m { + o.ProviderInstalled(provider, version, authResult, keyID) + } +} + +func (m InitMulti) ProviderInstalledSkippedSignature(provider string, version string) { + for _, o := range m { + o.ProviderInstalledSkippedSignature(provider, version) + } +} + +func (m InitMulti) WaitingForCacheLock(cacheDir string) { + for _, o := range m { + o.WaitingForCacheLock(cacheDir) + } +} + +func (m InitMulti) ProvidersSignedInfo() { + for _, o := range m { + o.ProvidersSignedInfo() + } +} + +func (m InitMulti) ProviderUpgradeLockfileConflict() { + for _, o := range m { + o.ProviderUpgradeLockfileConflict() + } +} + +func (m InitMulti) ProviderInstallationInterrupted() { + for _, o := range m { + o.ProviderInstallationInterrupted() + } +} + +func (m InitMulti) LockFileCreated() { + for _, o := range m { + o.LockFileCreated() + } +} + +func (m InitMulti) LockFileChanged() { + for _, o := range m { + o.LockFileChanged() + } +} + +func (m InitMulti) Hooks(showLocalPath bool) initwd.ModuleInstallHooks { + hooks := make([]initwd.ModuleInstallHooks, len(m)) + for i, o := range m { + hooks[i] = o.Hooks(showLocalPath) + } + return moduleInstallationHookMulti(hooks) +} + +type InitHuman struct { + view *View +} + +var _ Init = (*InitHuman)(nil) + +func (v *InitHuman) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *InitHuman) HelpPrompt() { + v.view.HelpPrompt("init") +} + +func (v *InitHuman) CopyFromModule(src string) { + msg := v.view.colorize.Color(fmt.Sprintf("[reset][bold]Copying configuration[reset] from %q...", src)) + _, _ = v.view.streams.Println(msg) +} + +func (v *InitHuman) InitialisedFromEmptyDir() { + const outputInitEmpty = ` +[reset][bold]OpenTofu initialized in an empty directory![reset] + +The directory has no OpenTofu configuration files. You may begin working +with OpenTofu immediately by creating OpenTofu configuration files.` + _, _ = v.view.streams.Println(strings.TrimSpace(v.view.colorize.Color(outputInitEmpty))) +} + +func (v *InitHuman) ConfigError() { + const errInitConfigError = ` +[reset]OpenTofu encountered problems during initialization, including problems +with the configuration, described below. + +The OpenTofu configuration must be valid before initialization so that +OpenTofu can determine which modules and providers need to be installed.` + + _, _ = v.view.streams.Eprintln(v.view.colorize.Color(errInitConfigError)) +} + +func (v *InitHuman) OutputNewline() { + _, _ = v.view.streams.Println("") +} + +func (v *InitHuman) InitSuccess(cloud bool) { + if cloud { + const outputInitSuccessCloud = `[reset][bold][green]Cloud backend has been successfully initialized![reset][green]` + _, _ = v.view.streams.Println(v.view.colorize.Color(outputInitSuccessCloud)) + } else { + const outputInitSuccess = `[reset][bold][green]OpenTofu has been successfully initialized![reset][green]` + _, _ = v.view.streams.Println(v.view.colorize.Color(outputInitSuccess)) + } +} + +func (v *InitHuman) InitSuccessCLI(cloud bool) { + if cloud { + const outputInitSuccessCLICloud = `[reset][green] +You may now begin working with cloud backend. Try running "tofu plan" to +see any changes that are required for your infrastructure. + +If you ever set or change modules or OpenTofu Settings, run "tofu init" +again to reinitialize your working directory.` + _, _ = v.view.streams.Println(v.view.colorize.Color(outputInitSuccessCLICloud)) + } else { + const outputInitSuccessCLI = `[reset][green] +You may now begin working with OpenTofu. Try running "tofu plan" to see +any changes that are required for your infrastructure. All OpenTofu commands +should now work. + +If you ever set or change modules or backend configuration for OpenTofu, +rerun this command to reinitialize your working directory. If you forget, other +commands will detect it and remind you to do so if necessary.` + _, _ = v.view.streams.Println(v.view.colorize.Color(outputInitSuccessCLI)) + } +} + +func (v *InitHuman) InitializingModules(upgrade bool) { + if upgrade { + _, _ = v.view.streams.Println(v.view.colorize.Color("[reset][bold]Upgrading modules...")) + } else { + _, _ = v.view.streams.Println(v.view.colorize.Color("[reset][bold]Initializing modules...")) + } +} + +func (v *InitHuman) InitializingCloudBackend() { + _, _ = v.view.streams.Println(v.view.colorize.Color("\n[reset][bold]Initializing cloud backend...")) +} + +func (v *InitHuman) InitializingBackend() { + _, _ = v.view.streams.Println(v.view.colorize.Color("\n[reset][bold]Initializing the backend...")) +} + +func (v *InitHuman) BackendTypeAlias(backendType, canonType string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- %q is an alias for backend type %q", backendType, canonType)) +} + +func (v *InitHuman) InitializingProviderPlugins() { + _, _ = v.view.streams.Println(v.view.colorize.Color("\n[reset][bold]Initializing provider plugins...")) +} + +func (v *InitHuman) ProviderAlreadyInstalled(provider string, version string, inCache bool) { + if inCache { + _, _ = v.view.streams.Println(fmt.Sprintf("- Detected previously-installed %s v%s in the shared cache directory", provider, version)) + } else { + _, _ = v.view.streams.Println(fmt.Sprintf("- Using previously-installed %s v%s", provider, version)) + } +} + +func (v *InitHuman) BuiltInProviderAvailable(provider string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- %s is built in to OpenTofu", provider)) +} + +func (v *InitHuman) ReusingLockFileVersion(provider string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Reusing previous version of %s from the dependency lock file", provider)) +} + +func (v *InitHuman) FindingProviderVersions(provider string, constraints string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Finding %s versions matching %q...", provider, constraints)) +} + +func (v *InitHuman) FindingLatestProviderVersion(provider string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Finding latest version of %s...", provider)) +} + +func (v *InitHuman) UsingProviderFromCache(provider string, version string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Using %s v%s from the shared cache directory", provider, version)) +} + +func (v *InitHuman) InstallingProvider(provider string, version string, toCache bool) { + if toCache { + _, _ = v.view.streams.Println(fmt.Sprintf("- Installing %s v%s to the shared cache directory...", provider, version)) + } else { + _, _ = v.view.streams.Println(fmt.Sprintf("- Installing %s v%s...", provider, version)) + } +} + +func (v *InitHuman) ProviderInstalled(provider string, version string, authResult string, keyID string) { + if keyID != "" { + keyID = v.view.colorize.Color(fmt.Sprintf(", key ID [reset][bold]%s[reset]", keyID)) + } + _, _ = v.view.streams.Println(fmt.Sprintf("- Installed %s v%s (%s%s)", provider, version, authResult, keyID)) +} + +func (v *InitHuman) ProviderInstalledSkippedSignature(provider string, version string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Installed %s v%s. Signature validation was skipped due to the registry not containing GPG keys for this provider", provider, version)) +} + +func (v *InitHuman) WaitingForCacheLock(cacheDir string) { + _, _ = v.view.streams.Println(fmt.Sprintf("- Waiting for lock on cache directory %s", cacheDir)) +} + +func (v *InitHuman) ProvidersSignedInfo() { + _, _ = v.view.streams.Println(fmt.Sprintf("\nProviders are signed by their developers.\n" + + "If you'd like to know more about provider signing, you can read about it here:\n" + + "https://opentofu.org/docs/cli/plugins/signing/")) +} + +func (v *InitHuman) ProviderUpgradeLockfileConflict() { + _, _ = v.view.streams.Eprintln("The -upgrade flag conflicts with -lockfile=readonly.") +} + +func (v *InitHuman) ProviderInstallationInterrupted() { + _, _ = v.view.streams.Eprintln("Provider installation was canceled by an interrupt signal.") +} + +func (v *InitHuman) LockFileCreated() { + _, _ = v.view.streams.Println(v.view.colorize.Color(` +OpenTofu has created a lock file [bold].terraform.lock.hcl[reset] to record the provider +selections it made above. Include this file in your version control repository +so that OpenTofu can guarantee to make the same selections by default when +you run "tofu init" in the future.`)) +} + +func (v *InitHuman) LockFileChanged() { + _, _ = v.view.streams.Println(v.view.colorize.Color(` +OpenTofu has made some changes to the provider dependency selections recorded +in the .terraform.lock.hcl file. Review those changes and commit them to your +version control system if they represent changes you intended to make.`)) +} + +func (v *InitHuman) Hooks(showLocalPath bool) initwd.ModuleInstallHooks { + return &moduleInstallationHookHuman{ + v: v.view, + showLocalPaths: showLocalPath, + } +} + +type InitJSON struct { + view *JSONView +} + +var _ Init = (*InitJSON)(nil) + +func (v *InitJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +func (v *InitJSON) HelpPrompt() {} + +func (v *InitJSON) CopyFromModule(src string) { + v.view.Info(fmt.Sprintf("Copying configuration from %q...", src)) +} + +func (v *InitJSON) InitialisedFromEmptyDir() { + const outputInitEmpty = `OpenTofu initialized in an empty directory! The directory has no OpenTofu configuration files. You may begin working with OpenTofu immediately by creating OpenTofu configuration files.` + v.view.Info(outputInitEmpty) +} + +func (v *InitJSON) ConfigError() { + const errInitConfigError = `OpenTofu encountered problems during initialization, including problems with the configuration, described below. The OpenTofu configuration must be valid before initialization so that OpenTofu can determine which modules and providers need to be installed.` + v.view.Error(errInitConfigError) +} + +func (v *InitJSON) OutputNewline() { +} + +func (v *InitJSON) InitSuccess(cloud bool) { + if cloud { + v.view.Info(`Cloud backend has been successfully initialized!`) + } else { + v.view.Info(`OpenTofu has been successfully initialized!`) + } +} + +func (v *InitJSON) InitSuccessCLI(cloud bool) { + if cloud { + const outputInitSuccessCLICloud = `You may now begin working with cloud backend. Try running "tofu plan" to see any changes that are required for your infrastructure. If you ever set or change modules or OpenTofu Settings, run "tofu init" again to reinitialize your working directory.` + v.view.Info(outputInitSuccessCLICloud) + } else { + const outputInitSuccessCLI = `You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary.` + v.view.Info(outputInitSuccessCLI) + } +} + +func (v *InitJSON) InitializingModules(upgrade bool) { + if upgrade { + v.view.Info("Upgrading modules...") + } else { + v.view.Info("Initializing modules...") + } +} + +func (v *InitJSON) InitializingCloudBackend() { + v.view.Info("Initializing cloud backend...") +} + +func (v *InitJSON) InitializingBackend() { + v.view.Info("Initializing the backend...") +} + +func (v *InitJSON) BackendTypeAlias(backendType, canonType string) { + v.view.Info(fmt.Sprintf("%q is an alias for backend type %q", backendType, canonType)) +} + +func (v *InitJSON) InitializingProviderPlugins() { + v.view.Info("Initializing provider plugins...") +} + +func (v *InitJSON) ProviderAlreadyInstalled(provider string, version string, inCache bool) { + if inCache { + v.view.Info(fmt.Sprintf("Detected previously-installed %s v%s in the shared cache directory", provider, version)) + } else { + v.view.Info(fmt.Sprintf("Using previously-installed %s v%s", provider, version)) + } +} + +func (v *InitJSON) BuiltInProviderAvailable(provider string) { + v.view.Info(fmt.Sprintf("%s is built in to OpenTofu", provider)) +} + +func (v *InitJSON) ReusingLockFileVersion(provider string) { + v.view.Info(fmt.Sprintf("Reusing previous version of %s from the dependency lock file", provider)) +} + +func (v *InitJSON) FindingProviderVersions(provider string, constraints string) { + v.view.Info(fmt.Sprintf("Finding %s versions matching %q...", provider, constraints)) +} + +func (v *InitJSON) FindingLatestProviderVersion(provider string) { + v.view.Info(fmt.Sprintf("Finding latest version of %s...", provider)) +} + +func (v *InitJSON) UsingProviderFromCache(provider string, version string) { + v.view.Info(fmt.Sprintf("Using %s v%s from the shared cache directory", provider, version)) +} + +func (v *InitJSON) InstallingProvider(provider string, version string, toCache bool) { + if toCache { + v.view.Info(fmt.Sprintf("Installing %s v%s to the shared cache directory...", provider, version)) + } else { + v.view.Info(fmt.Sprintf("Installing %s v%s...", provider, version)) + } +} + +func (v *InitJSON) ProviderInstalled(provider string, version string, authResult string, keyID string) { + if keyID != "" { + keyID = fmt.Sprintf(", key ID %s", keyID) + } + v.view.Info(fmt.Sprintf("Installed %s v%s (%s%s)", provider, version, authResult, keyID)) +} + +func (v *InitJSON) ProviderInstalledSkippedSignature(provider string, version string) { + v.view.Warn(fmt.Sprintf("Installed %s v%s. Signature validation was skipped due to the registry not containing GPG keys for this provider", provider, version)) +} + +func (v *InitJSON) WaitingForCacheLock(cacheDir string) { + v.view.Info(fmt.Sprintf("Waiting for lock on cache directory %s", cacheDir)) +} + +func (v *InitJSON) ProvidersSignedInfo() { + v.view.Info("Providers are signed by their developers. " + + "If you'd like to know more about provider signing, you can read about it here: " + + "https://opentofu.org/docs/cli/plugins/signing/") +} + +func (v *InitJSON) ProviderUpgradeLockfileConflict() { + v.view.Error("The -upgrade flag conflicts with -lockfile=readonly.") +} + +func (v *InitJSON) ProviderInstallationInterrupted() { + v.view.Error("Provider installation was canceled by an interrupt signal.") +} + +func (v *InitJSON) LockFileCreated() { + v.view.Info("OpenTofu has created a lock file .terraform.lock.hcl to record the provider " + + "selections it made above. Include this file in your version control repository " + + "so that OpenTofu can guarantee to make the same selections by default when " + + "you run \"tofu init\" in the future.") +} + +func (v *InitJSON) LockFileChanged() { + v.view.Info("OpenTofu has made some changes to the provider dependency selections recorded " + + "in the .terraform.lock.hcl file. Review those changes and commit them to your " + + "version control system if they represent changes you intended to make.") +} + +func (v *InitJSON) Hooks(showLocalPath bool) initwd.ModuleInstallHooks { + return &moduleInstallationHookJSON{ + v: v.view, + showLocalPaths: showLocalPath, + } +} diff --git a/internal/command/views/init_test.go b/internal/command/views/init_test.go new file mode 100644 index 0000000000..1fcd4359a9 --- /dev/null +++ b/internal/command/views/init_test.go @@ -0,0 +1,678 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "fmt" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/opentofu/opentofu/internal/command/arguments" + "github.com/opentofu/opentofu/internal/terminal" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +func TestInitViews(t *testing.T) { + tests := map[string]struct { + viewCall func(init Init) + wantJson []map[string]any + wantStdout string + wantStderr string + }{ + "copyFromModule": { + viewCall: func(init Init) { + init.CopyFromModule("my source") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Copying configuration from \"my source\"...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline(`Copying configuration from "my source"...`), + }, + "fromEmptyDir": { + viewCall: func(init Init) { + init.InitialisedFromEmptyDir() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "OpenTofu initialized in an empty directory! The directory has no OpenTofu configuration files. You may begin working with OpenTofu immediately by creating OpenTofu configuration files.", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("OpenTofu initialized in an empty directory!\n\nThe directory has no OpenTofu configuration files. You may begin working\nwith OpenTofu immediately by creating OpenTofu configuration files."), + }, + "outputNewline": { + viewCall: func(init Init) { + init.OutputNewline() + }, + wantStdout: withNewline(""), + wantStderr: "", + wantJson: []map[string]any{{}}, + }, + "initSuccess_cloud": { + viewCall: func(init Init) { + init.InitSuccess(true) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Cloud backend has been successfully initialized!", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("Cloud backend has been successfully initialized!"), + }, + "initSuccess_non-cloud": { + viewCall: func(init Init) { + init.InitSuccess(false) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "OpenTofu has been successfully initialized!", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("OpenTofu has been successfully initialized!"), + }, + "initSuccessCLI_cloud": { + viewCall: func(init Init) { + init.InitSuccessCLI(true) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "You may now begin working with cloud backend. Try running \"tofu plan\" to see any changes that are required for your infrastructure. If you ever set or change modules or OpenTofu Settings, run \"tofu init\" again to reinitialize your working directory.", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nYou may now begin working with cloud backend. Try running \"tofu plan\" to\nsee any changes that are required for your infrastructure.\n\nIf you ever set or change modules or OpenTofu Settings, run \"tofu init\"\nagain to reinitialize your working directory."), + }, + "initSuccessCLI_non-cloud": { + viewCall: func(init Init) { + init.InitSuccessCLI(false) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "You may now begin working with OpenTofu. Try running \"tofu plan\" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary.", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nYou may now begin working with OpenTofu. Try running \"tofu plan\" to see\nany changes that are required for your infrastructure. All OpenTofu commands\nshould now work.\n\nIf you ever set or change modules or backend configuration for OpenTofu,\nrerun this command to reinitialize your working directory. If you forget, other\ncommands will detect it and remind you to do so if necessary."), + }, + "initializingModules_upgrade": { + viewCall: func(init Init) { + init.InitializingModules(true) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Upgrading modules...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("Upgrading modules..."), + }, + "initializingModules_init": { + viewCall: func(init Init) { + init.InitializingModules(false) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Initializing modules...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("Initializing modules..."), + }, + "initializingCloudBackend": { + viewCall: func(init Init) { + init.InitializingCloudBackend() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Initializing cloud backend...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nInitializing cloud backend..."), + }, + "initializingBackend": { + viewCall: func(init Init) { + init.InitializingBackend() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Initializing the backend...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nInitializing the backend..."), + }, + "backendTypeAlias": { + viewCall: func(init Init) { + init.BackendTypeAlias("s3", "aws_s3") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "\"s3\" is an alias for backend type \"aws_s3\"", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- \"s3\" is an alias for backend type \"aws_s3\""), + }, + "initializingProviderPlugins": { + viewCall: func(init Init) { + init.InitializingProviderPlugins() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Initializing provider plugins...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nInitializing provider plugins..."), + }, + "providerAlreadyInstalled_notInCache": { + viewCall: func(init Init) { + init.ProviderAlreadyInstalled("hashicorp/aws", "5.0.0", false) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Using previously-installed hashicorp/aws v5.0.0", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Using previously-installed hashicorp/aws v5.0.0"), + }, + "providerAlreadyInstalled_inCache": { + viewCall: func(init Init) { + init.ProviderAlreadyInstalled("hashicorp/aws", "5.0.0", true) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Detected previously-installed hashicorp/aws v5.0.0 in the shared cache directory", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Detected previously-installed hashicorp/aws v5.0.0 in the shared cache directory"), + }, + "builtInProviderAvailable": { + viewCall: func(init Init) { + init.BuiltInProviderAvailable("terraform") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "terraform is built in to OpenTofu", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- terraform is built in to OpenTofu"), + }, + "reusingLockFileVersion": { + viewCall: func(init Init) { + init.ReusingLockFileVersion("hashicorp/random") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Reusing previous version of hashicorp/random from the dependency lock file", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Reusing previous version of hashicorp/random from the dependency lock file"), + }, + "findingProviderVersions": { + viewCall: func(init Init) { + init.FindingProviderVersions("hashicorp/aws", "~> 5.0") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Finding hashicorp/aws versions matching \"~> 5.0\"...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Finding hashicorp/aws versions matching \"~> 5.0\"..."), + }, + "findingLatestProviderVersion": { + viewCall: func(init Init) { + init.FindingLatestProviderVersion("hashicorp/null") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Finding latest version of hashicorp/null...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Finding latest version of hashicorp/null..."), + }, + "usingProviderFromCache": { + viewCall: func(init Init) { + init.UsingProviderFromCache("hashicorp/aws", "5.0.0") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Using hashicorp/aws v5.0.0 from the shared cache directory", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Using hashicorp/aws v5.0.0 from the shared cache directory"), + }, + "installingProvider_notToCache": { + viewCall: func(init Init) { + init.InstallingProvider("hashicorp/aws", "5.0.0", false) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Installing hashicorp/aws v5.0.0...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Installing hashicorp/aws v5.0.0..."), + }, + "installingProvider_toCache": { + viewCall: func(init Init) { + init.InstallingProvider("hashicorp/aws", "5.0.0", true) + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Installing hashicorp/aws v5.0.0 to the shared cache directory...", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Installing hashicorp/aws v5.0.0 to the shared cache directory..."), + }, + "providerInstalled_noKeyID": { + viewCall: func(init Init) { + init.ProviderInstalled("hashicorp/aws", "5.0.0", "signed by HashiCorp", "") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Installed hashicorp/aws v5.0.0 (signed by HashiCorp)", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Installed hashicorp/aws v5.0.0 (signed by HashiCorp)"), + }, + "providerInstalled_withKeyID": { + viewCall: func(init Init) { + init.ProviderInstalled("hashicorp/aws", "5.0.0", "signed", "34365D9472D7468F") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Installed hashicorp/aws v5.0.0 (signed, key ID 34365D9472D7468F)", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Installed hashicorp/aws v5.0.0 (signed, key ID 34365D9472D7468F)"), + }, + "waitingForCacheLock": { + viewCall: func(init Init) { + init.WaitingForCacheLock("/tmp/plugin-cache") + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Waiting for lock on cache directory /tmp/plugin-cache", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Waiting for lock on cache directory /tmp/plugin-cache"), + }, + "providersSignedInfo": { + viewCall: func(init Init) { + init.ProvidersSignedInfo() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "Providers are signed by their developers. If you'd like to know more about provider signing, you can read about it here: https://opentofu.org/docs/cli/plugins/signing/", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nProviders are signed by their developers.\nIf you'd like to know more about provider signing, you can read about it here:\nhttps://opentofu.org/docs/cli/plugins/signing/"), + }, + "lockFileCreated": { + viewCall: func(init Init) { + init.LockFileCreated() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "OpenTofu has created a lock file .terraform.lock.hcl to record the provider selections it made above. Include this file in your version control repository so that OpenTofu can guarantee to make the same selections by default when you run \"tofu init\" in the future.", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nOpenTofu has created a lock file .terraform.lock.hcl to record the provider\nselections it made above. Include this file in your version control repository\nso that OpenTofu can guarantee to make the same selections by default when\nyou run \"tofu init\" in the future."), + }, + "lockFileChanged": { + viewCall: func(init Init) { + init.LockFileChanged() + }, + wantJson: []map[string]any{ + { + "@level": "info", + "@message": "OpenTofu has made some changes to the provider dependency selections recorded in the .terraform.lock.hcl file. Review those changes and commit them to your version control system if they represent changes you intended to make.", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("\nOpenTofu has made some changes to the provider dependency selections recorded\nin the .terraform.lock.hcl file. Review those changes and commit them to your\nversion control system if they represent changes you intended to make."), + }, + // to stderr + "configError": { + viewCall: func(init Init) { + init.ConfigError() + }, + wantJson: []map[string]any{ + { + "@level": "error", + "@message": "OpenTofu encountered problems during initialization, including problems with the configuration, described below. The OpenTofu configuration must be valid before initialization so that OpenTofu can determine which modules and providers need to be installed.", + "@module": "tofu.ui", + }, + }, + wantStdout: "", + wantStderr: withNewline("\nOpenTofu encountered problems during initialization, including problems\nwith the configuration, described below.\n\nThe OpenTofu configuration must be valid before initialization so that\nOpenTofu can determine which modules and providers need to be installed."), + }, + "providerInstalledSkippedSignature": { + viewCall: func(init Init) { + init.ProviderInstalledSkippedSignature("hashicorp/random", "3.0.0") + }, + wantJson: []map[string]any{ + { + "@level": "warn", + "@message": "Installed hashicorp/random v3.0.0. Signature validation was skipped due to the registry not containing GPG keys for this provider", + "@module": "tofu.ui", + }, + }, + wantStdout: withNewline("- Installed hashicorp/random v3.0.0. Signature validation was skipped due to the registry not containing GPG keys for this provider"), + wantStderr: "", + }, + "providerUpgradeLockfileConflict": { + viewCall: func(init Init) { + init.ProviderUpgradeLockfileConflict() + }, + wantJson: []map[string]any{ + { + "@level": "error", + "@message": "The -upgrade flag conflicts with -lockfile=readonly.", + "@module": "tofu.ui", + }, + }, + wantStdout: "", + wantStderr: withNewline("The -upgrade flag conflicts with -lockfile=readonly."), + }, + "providerInstallationInterrupted": { + viewCall: func(init Init) { + init.ProviderInstallationInterrupted() + }, + wantJson: []map[string]any{ + { + "@level": "error", + "@message": "Provider installation was canceled by an interrupt signal.", + "@module": "tofu.ui", + }, + }, + wantStdout: "", + wantStderr: withNewline("Provider installation was canceled by an interrupt signal."), + }, + // Diagnostics + "warning": { + viewCall: func(init Init) { + diags := tfdiags.Diagnostics{ + tfdiags.Sourceless(tfdiags.Warning, "A warning occurred", "foo bar"), + } + init.Diagnostics(diags) + }, + wantStdout: withNewline("\nWarning: A warning occurred\n\nfoo bar"), + wantStderr: "", + wantJson: []map[string]any{ + { + "@level": "warn", + "@message": "Warning: A warning occurred", + "@module": "tofu.ui", + "diagnostic": map[string]any{ + "detail": "foo bar", + "severity": "warning", + "summary": "A warning occurred", + }, + "type": "diagnostic", + }, + }, + }, + "error": { + viewCall: func(init Init) { + diags := tfdiags.Diagnostics{ + tfdiags.Sourceless(tfdiags.Error, "An error occurred", "foo bar"), + } + init.Diagnostics(diags) + }, + wantStdout: "", + wantStderr: withNewline("\nError: An error occurred\n\nfoo bar"), + wantJson: []map[string]any{ + { + "@level": "error", + "@message": "Error: An error occurred", + "@module": "tofu.ui", + "diagnostic": map[string]any{ + "detail": "foo bar", + "severity": "error", + "summary": "An error occurred", + }, + "type": "diagnostic", + }, + }, + }, + "multiple_diagnostics": { + viewCall: func(init Init) { + diags := tfdiags.Diagnostics{ + tfdiags.Sourceless(tfdiags.Warning, "A warning", "foo bar warning"), + tfdiags.Sourceless(tfdiags.Error, "An error", "foo bar error"), + } + init.Diagnostics(diags) + }, + wantStdout: withNewline("\nWarning: A warning\n\nfoo bar warning"), + wantStderr: withNewline("\nError: An error\n\nfoo bar error"), + wantJson: []map[string]any{ + { + "@level": "warn", + "@message": "Warning: A warning", + "@module": "tofu.ui", + "diagnostic": map[string]any{ + "detail": "foo bar warning", + "severity": "warning", + "summary": "A warning", + }, + "type": "diagnostic", + }, + { + "@level": "error", + "@message": "Error: An error", + "@module": "tofu.ui", + "diagnostic": map[string]any{ + "detail": "foo bar error", + "severity": "error", + "summary": "An error", + }, + "type": "diagnostic", + }, + }, + }, + // Miscs + "help prompt": { + viewCall: func(init Init) { + init.HelpPrompt() + }, + wantStdout: "", + wantStderr: withNewline("\nFor more help on using this command, run:\n tofu init -help"), + wantJson: []map[string]any{{}}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + testInitHuman(t, tc.viewCall, tc.wantStdout, tc.wantStderr) + testInitJson(t, tc.viewCall, tc.wantJson) + testInitMulti(t, tc.viewCall, tc.wantStdout, tc.wantStderr, tc.wantJson) + }) + } +} + +func TestInitViews_Hooks(t *testing.T) { + t.Run("hooks_human_withLocalPath", func(t *testing.T) { + view, _ := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewHuman}, view) + hooks := initView.Hooks(true) + + if hooks == nil { + t.Fatal("expected hooks to be non-nil") + } + + // Verify it's the right type + _, ok := hooks.(*moduleInstallationHookHuman) + if !ok { + t.Errorf("expected *moduleInstallationHookHuman, got %T", hooks) + } + }) + + t.Run("hooks_human_withoutLocalPath", func(t *testing.T) { + view, _ := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewHuman}, view) + hooks := initView.Hooks(false) + + if hooks == nil { + t.Fatal("expected hooks to be non-nil") + } + + _, ok := hooks.(*moduleInstallationHookHuman) + if !ok { + t.Errorf("expected *moduleInstallationHookHuman, got %T", hooks) + } + }) + + t.Run("hooks_json", func(t *testing.T) { + view, _ := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewJSON}, view) + hooks := initView.Hooks(true) + + if hooks == nil { + t.Fatal("expected hooks to be non-nil") + } + + _, ok := hooks.(*moduleInstallationHookJSON) + if !ok { + t.Errorf("expected *moduleInstallationHookJSON, got %T", hooks) + } + }) + + t.Run("hooks_multi", func(t *testing.T) { + jsonInto, err := os.CreateTemp(t.TempDir(), "json-into-*") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + defer jsonInto.Close() + + view, _ := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewHuman, JSONInto: jsonInto}, view) + hooks := initView.Hooks(true) + + if hooks == nil { + t.Fatal("expected hooks to be non-nil") + } + + // Should be multi hook + _, ok := hooks.(moduleInstallationHookMulti) + if !ok { + t.Errorf("expected moduleInstallationHookMulti, got %T", hooks) + } + }) +} + +func testInitHuman(t *testing.T, call func(init Init), wantStdout, wantStderr string) { + view, done := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewHuman}, view) + call(initView) + output := done(t) + if diff := cmp.Diff(wantStderr, output.Stderr()); diff != "" { + t.Errorf("invalid stderr (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(wantStdout, output.Stdout()); diff != "" { + t.Errorf("invalid stdout (-want, +got):\n%s", diff) + } +} + +func testInitJson(t *testing.T, call func(init Init), want []map[string]interface{}) { + // New type just to assert the fields that we are interested in + view, done := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewJSON}, view) + call(initView) + output := done(t) + if output.Stderr() != "" { + t.Errorf("expected no stderr but got:\n%s", output.Stderr()) + } + + testJSONViewOutputEquals(t, output.Stdout(), want) +} + +func testInitMulti(t *testing.T, call func(init Init), wantStdout string, wantStderr string, want []map[string]interface{}) { + jsonInto, err := os.CreateTemp(t.TempDir(), "json-into-*") + if err != nil { + t.Fatalf("failed to create the file to write json content into: %s", err) + } + view, done := testView(t) + initView := NewInit(arguments.ViewOptions{ViewType: arguments.ViewHuman, JSONInto: jsonInto}, view) + call(initView) + { + if err := jsonInto.Close(); err != nil { + t.Fatalf("failed to close the jsonInto file: %s", err) + } + // check the fileInto content + fileContent, err := os.ReadFile(jsonInto.Name()) + if err != nil { + t.Fatalf("failed to read the file content with the json output: %s", err) + } + testJSONViewOutputEquals(t, string(fileContent), want) + } + { + // check the human output + output := done(t) + if diff := cmp.Diff(wantStderr, output.Stderr()); diff != "" { + t.Errorf("invalid stderr (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(wantStdout, output.Stdout()); diff != "" { + t.Errorf("invalid stdout (-want, +got):\n%s", diff) + } + } +} + +func testView(t *testing.T) (*View, func(*testing.T) *terminal.TestOutput) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + return view, done +} + +func withNewline(in string) string { + return fmt.Sprintf("%s\n", in) +} diff --git a/internal/command/views/json_view_test.go b/internal/command/views/json_view_test.go index 0363ec6e71..5428047373 100644 --- a/internal/command/views/json_view_test.go +++ b/internal/command/views/json_view_test.go @@ -458,7 +458,12 @@ func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string break } wantStruct := want[i] - + // When the json content generated is empty, there will be an empty one liner that can be matched + // by a "want" slice with one empty element + if len(gotLines[i]) == 0 && len(wantStruct) == 0 { + t.Logf("json output empty and that matches the requirements") + continue + } if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil { t.Fatal(err) } diff --git a/internal/command/views/view.go b/internal/command/views/view.go index 2e6d6c4236..14f0559d6f 100644 --- a/internal/command/views/view.go +++ b/internal/command/views/view.go @@ -6,6 +6,8 @@ package views import ( + "maps" + "github.com/hashicorp/hcl/v2" "github.com/mitchellh/colorstring" "github.com/opentofu/opentofu/internal/command/arguments" @@ -43,6 +45,10 @@ type View struct { // showSensitive is used to display the value of variables marked as sensitive. showSensitive bool + // Because some commands used before the UI to print diagnostics, those were printed using an [*ln] function, so + // we want to be able to configure this for some of the commands to be able to keep the behavior consistent. + diagsPrinter func(severity tfdiags.Severity, msg string) + // This unfortunate wart is required to enable rendering of diagnostics which // have associated source code in the configuration. This function pointer // will be dereferenced as late as possible when rendering diagnostics in @@ -61,6 +67,13 @@ func NewView(streams *terminal.Streams) *View { Reset: true, }, configSources: func() map[string]*hcl.File { return nil }, + diagsPrinter: func(severity tfdiags.Severity, msg string) { + if severity == tfdiags.Error { + _, _ = streams.Eprint(msg) + } else { + _, _ = streams.Print(msg) + } + }, } } @@ -82,7 +95,11 @@ func (v *View) RunningInAutomation() bool { // Configure applies the global view configuration flags. func (v *View) Configure(view *arguments.View) { + colors := maps.Clone(colorstring.DefaultColors) + colors["purple"] = "38;5;57" // Add also purple to the colorise colors set + v.colorize.Disable = view.NoColor + v.colorize.Colors = colors v.compactWarnings = view.CompactWarnings v.consolidateWarnings = view.ConsolidateWarnings v.consolidateErrors = view.ConsolidateErrors @@ -90,6 +107,16 @@ func (v *View) Configure(view *arguments.View) { v.ModuleDeprecationWarnLvl = view.ModuleDeprecationWarnLvl } +func (v *View) DiagsWithNewline() { + v.diagsPrinter = func(severity tfdiags.Severity, msg string) { + if severity == tfdiags.Error { + _, _ = v.streams.Eprintln(msg) + } else { + _, _ = v.streams.Println(msg) + } + } +} + // SetConfigSources overrides the default no-op callback with a new function // pointer, and should be called when the config loader is initialized. func (v *View) SetConfigSources(cb func() map[string]*hcl.File) { @@ -159,6 +186,12 @@ func (v *View) Diagnostics(diags tfdiags.Diagnostics) { msg = format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns()) } + // TODO meta-refactor: once we are done with migrating all the commands to views, we should get rid + // of the check and just allow the diagsPrinter to be called directly. + if v.diagsPrinter != nil { + v.diagsPrinter(diag.Severity(), msg) + continue + } if diag.Severity() == tfdiags.Error { v.streams.Eprint(msg) } else { diff --git a/internal/command/views/view_ui.go b/internal/command/views/view_ui.go new file mode 100644 index 0000000000..1af1034377 --- /dev/null +++ b/internal/command/views/view_ui.go @@ -0,0 +1,183 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "fmt" + "regexp" + + "github.com/mitchellh/cli" + "github.com/opentofu/opentofu/internal/command/arguments" +) + +var ErrorInputDisabled = fmt.Errorf("in this view cannot ask user input") + +var _ cli.Ui = (*ViewUiHuman)(nil) +var _ cli.Ui = (*ViewUiJSON)(nil) +var _ cli.Ui = (*ViewUiMulti)(nil) + +func NewViewUI(args arguments.ViewOptions, view *View, oldUi cli.Ui) cli.Ui { + var ret cli.Ui + switch args.ViewType { + case arguments.ViewJSON: + ret = &ViewUiJSON{ + view: NewJSONView(view, nil), + } + case arguments.ViewHuman: + ret = &ViewUiHuman{ + errorColor: "[red]", + warnColor: "[yellow]", + ui: oldUi, + view: view, + } + default: + panic(fmt.Sprintf("unknown view type %v", args.ViewType)) + } + + if args.JSONInto != nil { + ret = &ViewUiMulti{ret, &ViewUiJSON{view: NewJSONView(view, args.JSONInto)}} + } + return ret +} + +// ViewUiHuman is a Ui implementation that colors its output according +// to the given color schemes for the given type of output. +type ViewUiHuman struct { + ui cli.Ui + view *View + errorColor string + warnColor string + outputColor string + infoColor string +} + +func (u *ViewUiHuman) Ask(query string) (string, error) { + return u.ui.Ask(u.colorize(query, u.outputColor)) +} + +func (u *ViewUiHuman) AskSecret(query string) (string, error) { + return u.ui.AskSecret(u.colorize(query, u.outputColor)) +} + +func (u *ViewUiHuman) Output(message string) { + _, _ = u.view.streams.Println(u.colorize(message, u.outputColor)) +} + +func (u *ViewUiHuman) Info(message string) { + _, _ = u.view.streams.Println(u.colorize(message, u.infoColor)) +} + +func (u *ViewUiHuman) Error(message string) { + _, _ = u.view.streams.Eprintln(u.colorize(message, u.errorColor)) +} + +func (u *ViewUiHuman) Warn(message string) { + // Warning messages are meant to go to stdout as pointed out here: https://github.com/opentofu/opentofu/commit/0c3bb316ea56aacf5108883d1a269a53744fdd43 + _, _ = u.view.streams.Println(u.colorize(message, u.warnColor)) +} + +func (u *ViewUiHuman) colorize(message string, color string) string { + if color == "" { + return message + } + + return u.view.colorize.Color(fmt.Sprintf("%s%s[reset]", color, message)) +} + +// ViewUiJSON is a Ui implementation that colors its output according +// to the given color schemes for the given type of output. +type ViewUiJSON struct { + view *JSONView +} + +func (u *ViewUiJSON) Ask(_ string) (string, error) { + return "", ErrorInputDisabled +} + +func (u *ViewUiJSON) AskSecret(_ string) (string, error) { + return "", ErrorInputDisabled +} + +func (u *ViewUiJSON) Output(message string) { + u.view.Info(stripColor(message)) +} + +func (u *ViewUiJSON) Info(message string) { + u.view.Info(stripColor(message)) +} + +func (u *ViewUiJSON) Error(message string) { + u.view.Error(stripColor(message)) +} + +func (u *ViewUiJSON) Warn(message string) { + u.view.Warn(stripColor(message)) +} + +// ViewUiMulti is a Ui implementation that colors its output according +// to the given color schemes for the given type of output. +type ViewUiMulti []cli.Ui + +func (u ViewUiMulti) Ask(query string) (string, error) { + var err error + + for _, ui := range u { + out, innerErr := ui.Ask(query) + if innerErr == nil { + return out, innerErr // Return first response + } + err = innerErr // Othwerise, store the error to be returned later in case it's needed + } + return "", err +} + +func (u ViewUiMulti) AskSecret(query string) (string, error) { + var err error + + for _, ui := range u { + out, innerErr := ui.AskSecret(query) + if innerErr == nil { + return out, innerErr // Return first response + } + err = innerErr // Othwerise, store the error to be returned later in case it's needed + } + return "", err +} + +func (u ViewUiMulti) Output(message string) { + for _, ui := range u { + ui.Output(message) + } +} + +func (u ViewUiMulti) Info(message string) { + for _, ui := range u { + ui.Info(message) + } +} + +func (u ViewUiMulti) Error(message string) { + for _, ui := range u { + ui.Error(message) + } +} + +func (u ViewUiMulti) Warn(message string) { + for _, ui := range u { + ui.Warn(message) + } +} + +var matchColorRe = regexp.MustCompile("\033\\[[\\d;]*m") + +func stripColor(s string) string { + // This is a workaround for supporting json-into in legacy UI code paths. Hopefully this will all be ripped out once rfc/20251105-use-cobra-instead-of-mitchellh.md + // and related work is completed. + // + // NOTE: The regexp above is specifically tailored to the mitchellh colorstring.go implementation and will NOT work with the *full* set + // of possible colorization chars. + return matchColorRe.ReplaceAllString(s, "") +} diff --git a/internal/command/views/view_ui_test.go b/internal/command/views/view_ui_test.go new file mode 100644 index 0000000000..499eb98ebf --- /dev/null +++ b/internal/command/views/view_ui_test.go @@ -0,0 +1,149 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package views + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/mitchellh/cli" + "github.com/opentofu/opentofu/internal/command/arguments" + "github.com/opentofu/opentofu/internal/terminal" +) + +func TestViewUiHuman_OutputStreams(t *testing.T) { + testCases := []struct { + name string + fn func(ui cli.Ui) + expectStdout string + expectStderr string + }{ + { + name: "Output goes to stdout", + fn: func(ui cli.Ui) { + ui.Output("test output message") + }, + expectStdout: withNewline("test output message"), + expectStderr: "", + }, + { + name: "Info goes to stdout", + fn: func(ui cli.Ui) { + ui.Info("test info message") + }, + expectStdout: withNewline("test info message"), + expectStderr: "", + }, + { + name: "Warn goes to stdout", + fn: func(ui cli.Ui) { + ui.Warn("test warning message") + }, + expectStdout: withNewline("test warning message"), + expectStderr: "", + }, + { + name: "Error goes to stderr", + fn: func(ui cli.Ui) { + ui.Error("test error message") + }, + expectStdout: "", + expectStderr: withNewline("test error message"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + + ui := NewViewUI(arguments.ViewOptions{ViewType: arguments.ViewHuman}, view, nil) // testing output only, no need for Ui + + tc.fn(ui) + output := done(t) + if diff := cmp.Diff(tc.expectStderr, output.Stderr()); diff != "" { + t.Errorf("invalid stderr (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(tc.expectStdout, output.Stdout()); diff != "" { + t.Errorf("invalid stdout (-want, +got):\n%s", diff) + } + }) + } +} + +func TestViewUiJSON_OutputStreams(t *testing.T) { + testCases := []struct { + name string + fn func(ui cli.Ui) + expectStdout []map[string]any + }{ + { + name: "Output goes to stdout", + fn: func(ui cli.Ui) { + ui.Output("test output") + }, + expectStdout: []map[string]any{ + { + "@level": "info", + "@message": "test output", + "@module": "tofu.ui", + }, + }, + }, + { + name: "Info goes to stdout", + fn: func(ui cli.Ui) { + ui.Info("test info") + }, + expectStdout: []map[string]any{ + { + "@level": "info", + "@message": "test info", + "@module": "tofu.ui", + }, + }, + }, + { + name: "Warn goes to stdout", + fn: func(ui cli.Ui) { + ui.Warn("test warning") + }, + expectStdout: []map[string]any{ + { + "@level": "warn", + "@message": "test warning", + "@module": "tofu.ui", + }, + }, + }, + { + name: "Error goes to stderr (via JSON view)", + fn: func(ui cli.Ui) { + ui.Error("test error") + }, + expectStdout: []map[string]any{ + { + "@level": "error", + "@message": "test error", + "@module": "tofu.ui", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + + ui := NewViewUI(arguments.ViewOptions{ViewType: arguments.ViewJSON}, view, nil) + + tc.fn(ui) + output := done(t) + testJSONViewOutputEquals(t, output.Stdout(), tc.expectStdout) + }) + } +}