diff --git a/internal/command/command_test.go b/internal/command/command_test.go index db29b4ae1c30..fb8b0cc2792a 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -1293,6 +1293,8 @@ func checkGoldenReferenceHumanOutput(t *testing.T, output *terminal.TestOutput, // checkParameterizedGoldenReferenceHumanOutput compares a test fixture's log output with the given test output. // The log is expected to be in a file called "output.log" or "output-parameterized.log" located under the specified fixture path. // +// If any parameters are provided, this function expects to find and use a "output-parameterized.jsonlog" file. +// // The log can contain format specifiers that will be replaced with the given params, and these are only intended // for use when output references values like current platform or Terraform version. func checkParameterizedGoldenReferenceHumanOutput(t *testing.T, output *terminal.TestOutput, fixturePathName string, params ...interface{}) { @@ -1343,8 +1345,30 @@ func checkParameterizedGoldenReferenceHumanOutput(t *testing.T, output *terminal func checkGoldenReference(t *testing.T, output *terminal.TestOutput, fixturePathName string) { t.Helper() + // No params + checkParameterizedGoldenReference(t, output, fixturePathName) +} + +// checkParameterizedGoldenReference compares a test fixture's log output with the given test JSON output. +// The log is expected to be in a file called "output.jsonlog" or "output-parameterized.jsonlog" located under the specified fixture path. +// +// If any parameters are provided, this function expects to find and use a "output-parameterized.jsonlog" file. +// +// The log can contain format specifiers that will be replaced with the given params, and these are only intended +// for use when output references values like current platform or Terraform version. +func checkParameterizedGoldenReference(t *testing.T, output *terminal.TestOutput, fixturePathName string, params ...interface{}) { + t.Helper() + + var expectedFilePath string + + if len(params) > 0 { + expectedFilePath = path.Join(testFixturePath(fixturePathName), "output-parameterized.jsonlog") + } else { + expectedFilePath = path.Join(testFixturePath(fixturePathName), "output.jsonlog") + } + // Load the golden reference fixture - wantFile, err := os.Open(path.Join(testFixturePath(fixturePathName), "output.jsonlog")) + wantFile, err := os.Open(expectedFilePath) if err != nil { t.Fatalf("failed to open output file: %s", err) } @@ -1353,7 +1377,8 @@ func checkGoldenReference(t *testing.T, output *terminal.TestOutput, fixturePath if err != nil { t.Fatalf("failed to read output file: %s", err) } - want := string(wantBytes) + wantTemplate := string(wantBytes) + want := fmt.Sprintf(wantTemplate, params...) checkGoldenReferenceStr(t, output, want) } diff --git a/internal/command/init.go b/internal/command/init.go index 10f30b599418..15048d257a6a 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -477,7 +477,7 @@ func (c *InitCommand) getProvidersFromPSSConfig(ctx context.Context, rootModEarl // which will be surfaced as diagnostic during installation if !pAddr.IsZero() { cons := reqs[pAddr] - view.LogInitializingStateStoreProviderPlugin(pAddr, cons, rootModEarly.StateStore.Type) + view.LogInitializingStateStoreProviderStart(pAddr, cons, rootModEarly.StateStore.Type) } }, ProviderAlreadyInstalled: providerAlreadyInstalledCallback(view), diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 00bc481382d5..a41e1816ba78 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -3110,7 +3110,7 @@ func (m *Meta) determineIfProviderTrusted(provider addrs.Provider, providerLocat // confirmProviderIsTrusted takes the action determined by `determineIfProviderTrusted` and either prompts the user for approval, or returns an error if something has gone wrong with pre-supplied locks when Terraform was run in automation. // // NOTE: the command parameter is used to determine which command is being run, so that we can provide more specific guidance to the user. Do not use that parameter for any other purpose! -func (m *Meta) confirmProviderIsTrusted(trust ProviderTrust, provider addrs.Provider, stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult, stateStoreProviderLock, locksBeforeInstall *depsfile.Locks, flagLockfilePath string, command cli.Command, view views.ProviderInstallationLogger) tfdiags.Diagnostics { +func (m *Meta) confirmProviderIsTrusted(trust ProviderTrust, provider addrs.Provider, stateStoreProviderAuthResult *getproviders.PackageAuthenticationResult, stateStoreProviderLock, locksBeforeInstall *depsfile.Locks, flagLockfilePath string, command cli.Command, view views.StateStoreProviderTrustLogger) tfdiags.Diagnostics { var diags tfdiags.Diagnostics switch trust { @@ -3119,7 +3119,7 @@ func (m *Meta) confirmProviderIsTrusted(trust ProviderTrust, provider addrs.Prov if flagLockfilePath != "" { // If the user supplied a lock file path via CLI flag, we should notify them that it was used. - view.Output(views.StateStoreProviderAutomationApprovedMessage) + view.LogAutomaticApproval() view.Spacer() } case RequiresApproval: @@ -3127,12 +3127,12 @@ func (m *Meta) confirmProviderIsTrusted(trust ProviderTrust, provider addrs.Prov // Prompt the user about trusting the provider used for state storage. diags = diags.Append(m.promptStateStorageProviderApproval(provider, stateStoreProviderLock, stateStoreProviderAuthResult)) if diags.HasErrors() { - view.Output(views.StateStoreProviderInteractiveRejectedMessage) + view.LogInteractiveRejection() view.Spacer() return diags } - view.Output(views.StateStoreProviderInteractiveApprovedMessage) + view.LogInteractiveApproval() view.Spacer() } else { // Confirm that a lock was used to control download. diff --git a/internal/command/state_migrate.go b/internal/command/state_migrate.go index f62bd052b1a8..0b919ccde40b 100644 --- a/internal/command/state_migrate.go +++ b/internal/command/state_migrate.go @@ -537,7 +537,7 @@ func (c *StateMigrateCommand) getSingleProvider(ctx context.Context, stateStore // which will be surfaced as diagnostic during installation if !pAddr.IsZero() { cons := reqs[pAddr] - view.LogInitializingStateStoreProviderPlugin(pAddr, cons, stateStore.Type) + view.LogInitializingStateStoreProviderStart(pAddr, cons, stateStore.Type) } }, ProviderAlreadyInstalled: providerAlreadyInstalledCallback(view), diff --git a/internal/command/state_migrate_test.go b/internal/command/state_migrate_test.go index 8c37d8ca408d..9a60b2f5497c 100644 --- a/internal/command/state_migrate_test.go +++ b/internal/command/state_migrate_test.go @@ -24,150 +24,224 @@ import ( ) func TestStateMigrate_fromBackendToBackend(t *testing.T) { - fixture := "state-migrate-backend-to-backend" - wd := tempWorkingDirFixture(t, fixture) - t.Chdir(wd.RootModuleDir()) + t.Run("full assertions, including asserting human view output", func(t *testing.T) { + fixture := "state-migrate-backend-to-backend" + wd := tempWorkingDirFixture(t, fixture) + t.Chdir(wd.RootModuleDir()) - ui := testUiWrapped(t) - view, done := testView(t) - c := &StateMigrateCommand{ - Meta: Meta{ - Ui: ui, - View: view, - WorkingDir: wd, - AllowExperimentalFeatures: true, - }, - } + ui := testUiWrapped(t) + view, done := testView(t) + c := &StateMigrateCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: wd, + AllowExperimentalFeatures: true, + }, + } - _ = testInputMap(t, map[string]string{ - "backend-migrate-copy-to-empty": "yes", - }) + _ = testInputMap(t, map[string]string{ + "backend-migrate-copy-to-empty": "yes", + }) - args := []string{"-no-color"} - code := c.Run(args) - out := done(t) - if code != 0 { - t.Fatalf("expected exit code 1, got %d\nstderr: %q", code, out.Stderr()) - } + args := []string{"-no-color"} + code := c.Run(args) + out := done(t) + if code != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %q", code, out.Stderr()) + } - // Assert expected human output is made - checkGoldenReferenceHumanOutput(t, out, fixture) + // Assert expected human output is made + checkGoldenReferenceHumanOutput(t, out, fixture) - // Assert the migrated state contains expected content - f, err := os.Open("destination-backend.tfstate") - if err != nil { - t.Fatalf("failed to read migrated state: %s", err) - } - t.Cleanup(func() { - err := f.Close() + // Assert the migrated state contains expected content + f, err := os.Open("destination-backend.tfstate") + if err != nil { + t.Fatalf("failed to read migrated state: %s", err) + } + t.Cleanup(func() { + err := f.Close() + if err != nil { + t.Fatal(err) + } + }) + s, err := statefile.Read(f) if err != nil { t.Fatal(err) } + _, ok := s.State.RootOutputValues["test"] + if !ok { + t.Fatalf("unable to find test output in migrated state") + } + + // Assert the backend state file describes the new backend location, + statePath := filepath.Join(c.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + if err := sMgr.RefreshState(); err != nil { + t.Fatal(err) + } + backendState := sMgr.State() + if backendState.StateStore != nil { + t.Fatalf("expected backend state file to not describe a state_store during backend=>backend, but it's set") + } + if backendState.Backend == nil { + t.Fatalf("expected backend state file to describe a backend during backend=>backend, but it's nil") + } + if backendState.Backend.Type != "local" { + t.Fatalf("expected backend state file to describe the destination backend \"local\", but got %q", backendState.Backend.Type) + } + if got, want := normalizeJSON(t, backendState.Backend.ConfigRaw), `{"path":"destination-backend.tfstate","workspace_dir":null}`; got != want { + t.Errorf("wrong config\ngot: %s\nwant: %s", got, want) + } }) - s, err := statefile.Read(f) - if err != nil { - t.Fatal(err) - } - _, ok := s.State.RootOutputValues["test"] - if !ok { - t.Fatalf("unable to find test output in migrated state") - } + t.Run("assert only JSON view output", func(t *testing.T) { + fixture := "state-migrate-backend-to-backend" + wd := tempWorkingDirFixture(t, fixture) + t.Chdir(wd.RootModuleDir()) - // Assert the backend state file describes the new backend location, - statePath := filepath.Join(c.DataDir(), DefaultStateFilename) - sMgr := &clistate.LocalState{Path: statePath} - if err := sMgr.RefreshState(); err != nil { - t.Fatal(err) - } - backendState := sMgr.State() - if backendState.StateStore != nil { - t.Fatalf("expected backend state file to not describe a state_store during backend=>backend, but it's set") - } - if backendState.Backend == nil { - t.Fatalf("expected backend state file to describe a backend during backend=>backend, but it's nil") - } - if backendState.Backend.Type != "local" { - t.Fatalf("expected backend state file to describe the destination backend \"local\", but got %q", backendState.Backend.Type) - } - if got, want := normalizeJSON(t, backendState.Backend.ConfigRaw), `{"path":"destination-backend.tfstate","workspace_dir":null}`; got != want { - t.Errorf("wrong config\ngot: %s\nwant: %s", got, want) - } + ui := testUiWrapped(t) + view, done := testView(t) + c := &StateMigrateCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: wd, + AllowExperimentalFeatures: true, + }, + } + + args := []string{ + "-input=false", + "-json", + "-force-copy", // deal with interactive prompts to approve the migration + } + code := c.Run(args) + out := done(t) + if code != 0 { + t.Fatalf("expected exit code 0, got %d\nstdout: %q\nstderr: %q", code, out.Stdout(), out.Stderr()) + } + + // Assert expected JSON output is made + checkGoldenReference(t, out, fixture) + + // No further assertions; done in previous sub test + }) } func TestStateMigrate_fromBackendToStateStore(t *testing.T) { - fixture := "state-migrate-backend-to-state-store" - wd := tempWorkingDirFixture(t, fixture) - t.Chdir(wd.RootModuleDir()) + t.Run("full assertions, including asserting human view output", func(t *testing.T) { + fixture := "state-migrate-backend-to-state-store" + wd := tempWorkingDirFixture(t, fixture) + t.Chdir(wd.RootModuleDir()) - p := mockPluggableStateStorageProvider(mockSingleStateStoreSchema("test_store")) - p.MockStates = testing_provider.NewMockStateBytesWithStateIds("test_store", []string{"default"}) - providerSource := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.2.3"}, - }) + p := mockPluggableStateStorageProvider(mockSingleStateStoreSchema("test_store")) + p.MockStates = testing_provider.NewMockStateBytesWithStateIds("test_store", []string{"default"}) + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) - ui := testUiWrapped(t) - view, done := testView(t) - c := &StateMigrateCommand{ - Meta: Meta{ - Ui: ui, - View: view, - WorkingDir: wd, - AllowExperimentalFeatures: true, - testingOverrides: metaOverridesForProvider(p), - ProviderSource: providerSource, - }, - } + ui := testUiWrapped(t) + view, done := testView(t) + c := &StateMigrateCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: wd, + AllowExperimentalFeatures: true, + testingOverrides: metaOverridesForProvider(p), + ProviderSource: providerSource, + }, + } - _ = testInputMap(t, map[string]string{ - "backend-migrate-copy-to-empty": "yes", + _ = testInputMap(t, map[string]string{ + "backend-migrate-copy-to-empty": "yes", + }) + + args := []string{"-no-color"} + code := c.Run(args) + out := done(t) + if code != 0 { + t.Fatalf("unexpected exit: %d\nstderr: %q", code, out.Stderr()) + } + + // Assert expected human output is made + checkGoldenReferenceHumanOutput(t, out, fixture) + + // Assert the migrated state contains expected content + b, err := p.MockStates.Read("test_store", "default") + if err != nil { + t.Fatalf("unable to find migrated state in mock provider: %s", err) + } + s, err := statefile.Read(bytes.NewBuffer(b)) + if err != nil { + t.Fatal(err) + } + _, ok := s.State.RootOutputValues["test"] + if !ok { + t.Fatalf("unable to find test output in migrated state") + } + + // Assert the backend state file describes the new state store location. + statePath := filepath.Join(c.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + if err := sMgr.RefreshState(); err != nil { + t.Fatal(err) + } + backendState := sMgr.State() + if backendState.Backend != nil { + t.Fatalf("expected backend state file to not describe a backend during backend=>state_store migration, but it's set") + } + if backendState.StateStore == nil { + t.Fatalf("expected backend state file to describe a state store during backend=>state_store migration, but it's nil") + } + if backendState.StateStore.Provider.Source.Type != "test" { + t.Fatalf("expected backend state file to describe the destination state store provider \"test\", but got %q", backendState.StateStore.Provider) + } + if backendState.StateStore.Provider.Version.String() != "1.2.3" { + t.Fatalf("expected backend state file to describe the destination state store provider version \"1.2.3\", but got %q", backendState.StateStore.Provider.Version) + } + if backendState.StateStore.Type != "test_store" { + t.Fatalf("expected backend state file to describe the destination state store type \"test_store\", but got %q", backendState.StateStore.Type) + } }) + t.Run("assert only JSON view output", func(t *testing.T) { + fixture := "state-migrate-backend-to-state-store" + wd := tempWorkingDirFixture(t, fixture) + t.Chdir(wd.RootModuleDir()) - args := []string{"-no-color"} - code := c.Run(args) - out := done(t) - if code != 0 { - t.Fatalf("unexpected exit: %d\nstderr: %q", code, out.Stderr()) - } + p := mockPluggableStateStorageProvider(mockSingleStateStoreSchema("test_store")) + p.MockStates = testing_provider.NewMockStateBytesWithStateIds("test_store", []string{"default"}) + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) - // Assert expected human output is made - checkGoldenReferenceHumanOutput(t, out, fixture) + ui := testUiWrapped(t) + view, done := testView(t) + c := &StateMigrateCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: wd, + AllowExperimentalFeatures: true, + testingOverrides: metaOverridesForProvider(p), + ProviderSource: providerSource, + }, + } - // Assert the migrated state contains expected content - b, err := p.MockStates.Read("test_store", "default") - if err != nil { - t.Fatalf("unable to find migrated state in mock provider: %s", err) - } - s, err := statefile.Read(bytes.NewBuffer(b)) - if err != nil { - t.Fatal(err) - } - _, ok := s.State.RootOutputValues["test"] - if !ok { - t.Fatalf("unable to find test output in migrated state") - } + args := []string{ + "-input=false", + "-json", + "-force-copy", // deal with interactive prompts to approve the migration + } + code := c.Run(args) + out := done(t) + if code != 0 { + t.Fatalf("unexpected exit: %d\nstderr: %q", code, out.Stderr()) + } - // Assert the backend state file describes the new state store location. - statePath := filepath.Join(c.DataDir(), DefaultStateFilename) - sMgr := &clistate.LocalState{Path: statePath} - if err := sMgr.RefreshState(); err != nil { - t.Fatal(err) - } - backendState := sMgr.State() - if backendState.Backend != nil { - t.Fatalf("expected backend state file to not describe a backend during backend=>state_store migration, but it's set") - } - if backendState.StateStore == nil { - t.Fatalf("expected backend state file to describe a state store during backend=>state_store migration, but it's nil") - } - if backendState.StateStore.Provider.Source.Type != "test" { - t.Fatalf("expected backend state file to describe the destination state store provider \"test\", but got %q", backendState.StateStore.Provider) - } - if backendState.StateStore.Provider.Version.String() != "1.2.3" { - t.Fatalf("expected backend state file to describe the destination state store provider version \"1.2.3\", but got %q", backendState.StateStore.Provider.Version) - } - if backendState.StateStore.Type != "test_store" { - t.Fatalf("expected backend state file to describe the destination state store type \"test_store\", but got %q", backendState.StateStore.Type) - } + // Assert expected human output is made + checkGoldenReference(t, out, fixture) + }) } // Testing migration between two state stores in a single provider. @@ -542,6 +616,79 @@ provider "registry.terraform.io/hashicorp/test2" { // Assert the backend state file describes the new state store location. assertBackendStateFile(t, c) }) + t.Run("assert only JSON view output: source provider already in the dependency lock file, destination is not", func(t *testing.T) { + fixture := "state-store-changed/provider-used" + wd := tempWorkingDirFixture(t, fixture) + t.Chdir(wd.RootModuleDir()) + + b, err := os.ReadFile("source-pss.tfstate") + if err != nil { + t.Fatal(err) + } + + // hashicorp/test + sourcePssSchema := map[string]providers.Schema{ + "test_src": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{}, + }, + }, + } + sourceProvider := mockPluggableStateStorageProvider(sourcePssSchema) + sourceProvider.MockStates = testing_provider.MockStateBytes{ + "test_src": map[string][]byte{"default": []byte(b)}, + } + // hashicorp/test2 + destinationPssSchema := map[string]providers.Schema{ + "test2_store": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{}, + }, + }, + } + destinationProvider := mockPluggableStateStorageProvider(destinationPssSchema) + destinationProvider.MockStates = testing_provider.MockStateBytes{ + "test2_store": map[string][]byte{}, // No existing state in the destination + } + providerSource := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + "hashicorp/test2": {"3.2.1"}, + }) + + ui := testUiWrapped(t) + view, done := testView(t) + c := &StateMigrateCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: wd, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(sourceProvider), + addrs.NewDefaultProvider("test2"): providers.FactoryFixed(destinationProvider), + }, + }, + ProviderSource: providerSource, + }, + } + + args := []string{ + "-input=false", + "-json", + "-force-copy", // deal with interactive prompts to approve the migration + } + code := c.Run(args) + out := done(t) + if code != 0 { + t.Fatalf("unexpected exit: %d\nstderr: %q", code, out.Stderr()) + } + + // Assert expected JSON output is made + // Parameterized due to output referencing the current platform. + checkParameterizedGoldenReference(t, out, fixture, getproviders.CurrentPlatform.String()) + }) + t.Run("destination provider already in the dependency lock file, source is not", func(t *testing.T) { wd := tempWorkingDirFixture(t, "state-store-changed/provider-used") t.Chdir(wd.RootModuleDir()) diff --git a/internal/command/testdata/state-migrate-backend-to-backend/output.jsonlog b/internal/command/testdata/state-migrate-backend-to-backend/output.jsonlog new file mode 100644 index 000000000000..c0740a4a22bc --- /dev/null +++ b/internal/command/testdata/state-migrate-backend-to-backend/output.jsonlog @@ -0,0 +1,3 @@ +{"@level":"info","@message":"Terraform 1.16.0-dev","@module":"terraform.ui","@timestamp":"2026-07-20T17:37:42.389757+01:00","terraform":"1.16.0-dev","type":"version","ui":"1.3"} +{"@level":"info","@message":"[reset][bold]Migrating state from backend \"local\" to backend \"local\"...[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T17:37:42.392448+01:00"} +{"@level":"info","@message":"[reset][bold]Finished migrating state from backend \"local\" to backend \"local\".[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T17:37:42.402661+01:00"} \ No newline at end of file diff --git a/internal/command/testdata/state-migrate-backend-to-state-store/output.jsonlog b/internal/command/testdata/state-migrate-backend-to-state-store/output.jsonlog new file mode 100644 index 000000000000..cfc4ce917c11 --- /dev/null +++ b/internal/command/testdata/state-migrate-backend-to-state-store/output.jsonlog @@ -0,0 +1,7 @@ +{"@level":"info","@message":"Terraform 1.16.0-dev","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:15.987666+01:00","terraform":"1.16.0-dev","type":"version","ui":"1.3"} +{"@level":"info","@message":"Initializing provider hashicorp/test (1.2.3) for state store \"test_store\"...","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:19.353736+01:00","type":"state_store_provider_initialization_start"} +{"@level":"info","@message":"hashicorp/test: Reusing version 1.2.3 from the dependency lock file","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:19.353950+01:00","type":"reusing_previous_version_info"} +{"@level":"info","@message":"Installing provider version: hashicorp/test v1.2.3...","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:19.354164+01:00","type":"installing_provider_message"} +{"@level":"info","@message":"Installed provider version: hashicorp/test v1.2.3 (verified checksum)","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:19.357634+01:00","type":"installed_provider_version_info"} +{"@level":"info","@message":"[reset][bold]Migrating state from backend \"local\" to state store \"test_store\" (hashicorp/test 1.2.3)...[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:23.501107+01:00"} +{"@level":"info","@message":"[reset][bold]Finished migrating state from backend \"local\" to state store \"test_store\" (hashicorp/test 1.2.3).[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T18:24:23.510351+01:00"} diff --git a/internal/command/testdata/state-store-changed/provider-used/output-parameterized.jsonlog b/internal/command/testdata/state-store-changed/provider-used/output-parameterized.jsonlog new file mode 100644 index 000000000000..2337716a911b --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-used/output-parameterized.jsonlog @@ -0,0 +1,13 @@ +{"@level":"info","@message":"Terraform 1.16.0-dev","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.803855+01:00","terraform":"1.16.0-dev","type":"version","ui":"1.3"} +{"@level":"info","@message":"Initializing provider plugin for state store \"test_src\"...","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.806751+01:00","type":"initializing_state_store_provider_plugin_message"} +{"@level":"info","@message":"hashicorp/test: Reusing previous version from the dependency lock file","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.806774+01:00","type":"reusing_previous_version_info"} +{"@level":"info","@message":"Installing provider version: hashicorp/test v1.2.3...","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.806837+01:00","type":"installing_provider_message"} +{"@level":"info","@message":"Installed provider version: hashicorp/test v1.2.3 (verified checksum)","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.807983+01:00","type":"installed_provider_version_info"} +{"@level":"info","@message":"Initializing provider plugin for state store \"test2_store\"...","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.808229+01:00","type":"initializing_state_store_provider_plugin_message"} +{"@level":"info","@message":"hashicorp/test2: Finding latest version...","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.808240+01:00","type":"finding_latest_version_message"} +{"@level":"info","@message":"Installing provider version: hashicorp/test2 v3.2.1...","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.808531+01:00","type":"installing_provider_message"} +{"@level":"info","@message":"Installed provider version: hashicorp/test2 v3.2.1 (verified checksum)","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.809792+01:00","type":"installed_provider_version_info"} +{"@level":"info","@message":"[reset][bold]Migrating state from state store \"test_src\" (hashicorp/test) to state store \"test2_store\" (hashicorp/test2)...[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.809984+01:00"} +{"@level":"info","@message":"Terraform 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.","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.810446+01:00"} +{"@level":"warn","@message":"Warning: Incomplete lock file information for providers","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.815998+01:00","diagnostic":{"severity":"warning","summary":"Incomplete lock file information for providers","detail":"Due to your customized provider installation methods, Terraform was forced to calculate lock file checksums locally for the following providers:\n - hashicorp/test\n - hashicorp/test2\n\nThe current .terraform.lock.hcl file only includes checksums for %s, so Terraform running on another platform will fail to install these providers.\n\nTo calculate additional checksums for another platform, run:\n terraform providers lock -platform=linux_amd64\n(where linux_amd64 is the platform to generate)"},"type":"diagnostic"} +{"@level":"info","@message":"[reset][bold]Finished migrating state from state store \"test_src\" (hashicorp/test) to state store \"test2_store\" (hashicorp/test2).[reset]","@module":"terraform.ui","@timestamp":"2026-07-20T19:11:22.816430+01:00"} \ No newline at end of file diff --git a/internal/command/views/init.go b/internal/command/views/init.go index 5719093463ea..e1a87cbea41d 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -10,6 +10,7 @@ import ( tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/tfdiags" @@ -26,6 +27,8 @@ type Init interface { ProviderInstallationLogger DependencyLockingLogger + StateStoreProviderTrustLogger + prepareMessage(messageCode InitMessageCode, params ...any) string Spacer // The `init` command logs empty lines to space-out different sections of human-readable output @@ -75,94 +78,121 @@ func (v *InitHuman) PolicyResult(addr string, resp policy.EvaluationResponse) { } func (v *InitHuman) Output(messageCode InitMessageCode, params ...any) { - v.view.streams.Println(v.prepareMessage(messageCode, params...)) + v.print(v.prepareMessage(messageCode, params...)) } -func (v *InitHuman) LogInitializingStateStoreProviderPlugin(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { +func (v *InitHuman) LogInitializingStateStoreProviderStart(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { consSuffix := "" if len(cons) > 0 { consSuffix = fmt.Sprintf(" (%s)", getproviders.VersionConstraintsString(cons)) } params := []any{pAddr.ForDisplay(), consSuffix, storeType} - v.view.streams.Println(v.prepareMessage(InitializingStateStoreProviderPluginMessage, params...)) + msg := fmt.Sprintf(logInitializingStateStoreProviderStartMessageHuman, params...) + v.print(msg) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitHuman) LogInteractiveApproval() { + v.print(logInteractiveApprovalMessageHuman) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitHuman) LogInteractiveRejection() { + v.print(logInteractiveRejectionMessageHuman) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitHuman) LogAutomaticApproval() { + v.print(logInteractiveAutomaticApprovalMessageHuman) } func (v *InitHuman) LogFindingMatchingVersion(providerAddr addrs.Provider, versionConstraints getproviders.VersionConstraints) { params := []any{providerAddr.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)} - v.view.streams.Println(v.prepareMessage(FindingMatchingVersionMessage, params...)) + v.print(v.prepareMessage(FindingMatchingVersionMessage, params...)) } func (v *InitHuman) LogFindingLatestVersion(providerAddr addrs.Provider) { params := []any{providerAddr.ForDisplay()} - v.view.streams.Println(v.prepareMessage(FindingLatestVersionMessage, params...)) + v.print(v.prepareMessage(FindingLatestVersionMessage, params...)) } func (v *InitHuman) LogProviderVersionAlreadyInstalled(providerAddr addrs.Provider, version getproviders.Version) { params := []any{providerAddr.ForDisplay(), version} - v.view.streams.Println(v.prepareMessage(ProviderAlreadyInstalledMessage, params...)) + v.print(v.prepareMessage(ProviderAlreadyInstalledMessage, params...)) } func (v *InitHuman) LogUsingProviderVersionFromCacheDir(providerAddr addrs.Provider, version getproviders.Version) { params := []any{providerAddr.ForDisplay(), version} - v.view.streams.Println(v.prepareMessage(UsingProviderFromCacheDirInfo, params...)) + v.print(v.prepareMessage(UsingProviderFromCacheDirInfo, params...)) } func (v *InitHuman) LogBuiltInProviderAvailable(providerAddr addrs.Provider) { params := []any{providerAddr.ForDisplay()} - v.view.streams.Println(v.prepareMessage(BuiltInProviderAvailableMessage, params...)) + v.print(v.prepareMessage(BuiltInProviderAvailableMessage, params...)) } func (v *InitHuman) LogInstallingProviderVersion(providerAddr addrs.Provider, version getproviders.Version) { params := []any{providerAddr.ForDisplay(), version} - v.view.streams.Println(v.prepareMessage(InstallingProviderMessage, params...)) + v.print(v.prepareMessage(InstallingProviderMessage, params...)) } func (v *InitHuman) LogReusingPreviousProviderVersion(providerAddr addrs.Provider, version getproviders.Version) { params := []any{version, providerAddr.ForDisplay()} - v.view.streams.Println(v.prepareMessage(ReusingPreviousVersionInfo, params...)) + v.print(v.prepareMessage(ReusingPreviousVersionInfo, params...)) } func (v *InitHuman) LogProviderVersionSuccess(providerAddr addrs.Provider, version getproviders.Version, auth *getproviders.PackageAuthenticationResult) { params := []any{providerAddr.ForDisplay(), version, auth, ""} // add empty key id to the end - v.view.streams.Println(v.prepareMessage(InstalledProviderVersionInfo, params...)) + v.print(v.prepareMessage(InstalledProviderVersionInfo, params...)) } func (v *InitHuman) LogProviderVersionSuccessWithKeyID(providerAddr addrs.Provider, version getproviders.Version, auth *getproviders.PackageAuthenticationResult, keyID string) { keyDetails := fmt.Sprintf(", key ID [reset][bold]%s[reset]", keyID) // key id needs to be formatted for human output params := []any{providerAddr.ForDisplay(), version, auth, keyDetails} - v.view.streams.Println(v.prepareMessage(InstalledProviderVersionInfo, params...)) + v.print(v.prepareMessage(InstalledProviderVersionInfo, params...)) } func (v *InitHuman) LogPartnerAndCommunityProviders() { - v.view.streams.Println(v.prepareMessage(PartnerAndCommunityProvidersMessage)) + v.print(v.prepareMessage(PartnerAndCommunityProvidersMessage)) } // Implements DependencyLockingLogger func (v *InitHuman) LogDependencyLockfileCreated() { params := []any{} - v.view.streams.Println(v.prepareMessage(LockInfo, params...)) + v.print(v.prepareMessage(LockInfo, params...)) } // Implements DependencyLockingLogger func (v *InitHuman) LogDependencyLockfileUpdated() { params := []any{} - v.view.streams.Println(v.prepareMessage(DependenciesLockChangesInfo, params...)) + v.print(v.prepareMessage(DependenciesLockChangesInfo, params...)) } // Implements ModuleInstallationLogger // // See logging in hook_module_install.go func (v *InitHuman) LogModuleDownload(message string) { - v.view.streams.Println(strings.TrimSpace(message)) + v.print(strings.TrimSpace(message)) } // Implements ModuleInstallationLogger // // See logging in hook_module_install.go func (v *InitHuman) LogModuleInstallation(message string) { - v.view.streams.Println(strings.TrimSpace(message)) + v.print(message) +} + +// print formats (trims whitespace & applies colour) and +// prints the formatted message to the stdout stream. +func (v *InitHuman) print(message string) { + message = v.view.colorize.Color(strings.TrimSpace(message)) + v.view.streams.Println(message) } +// prepareMessage retrieves a message template matching the InitMessageCode and +// returns a formatted string made using the template and param argument(s). +// +// As this is implemented on InitHuman the human message template is used. func (v *InitHuman) prepareMessage(messageCode InitMessageCode, params ...any) string { message, ok := MessageRegistry[messageCode] if !ok { @@ -174,7 +204,7 @@ func (v *InitHuman) prepareMessage(messageCode InitMessageCode, params ...any) s panic("unexpected empty message for init message code: " + string(messageCode)) } - return v.view.colorize.Color(strings.TrimSpace(fmt.Sprintf(message.HumanValue, params...))) + return fmt.Sprintf(message.HumanValue, params...) } // The InitJSON implementation renders streaming JSON logs, suitable for @@ -244,16 +274,42 @@ func (v *InitJSON) logInitMessage(messageCode InitMessageCode, params ...any) { v.view.Log(preppedMessage) } -func (v *InitJSON) LogInitializingStateStoreProviderPlugin(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { +func (v *InitJSON) LogInitializingStateStoreProviderStart(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { consSuffix := "" if len(cons) > 0 { consSuffix = fmt.Sprintf(" (%s)", getproviders.VersionConstraintsString(cons)) } params := []any{pAddr.ForDisplay(), consSuffix, storeType} + msg := fmt.Sprintf(logInitializingStateStoreProviderStartMessageJSON, params...) - // This was previously logged via Output, so we need to match implementation of that method - // to ensure the same JSON log is produced. - v.Output(InitializingStateStoreProviderPluginMessage, params...) + v.view.log.Info( + msg, + "type", json.InitializingStateStoreProviderStart, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitJSON) LogInteractiveApproval() { + v.view.log.Info( + logInteractiveApprovalMessageJSON, + "type", json.StateStoreProviderInteractiveApproval, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitJSON) LogInteractiveRejection() { + v.view.log.Info( + logInteractiveRejectionMessageJSON, + "type", json.StateStoreProviderInteractiveRejection, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (v *InitJSON) LogAutomaticApproval() { + v.view.log.Info( + logInteractiveAutomaticApprovalMessageJSON, + "type", json.StateStoreProviderAutomationApproval, + ) } func (v *InitJSON) LogFindingMatchingVersion(providerAddr addrs.Provider, versionConstraints getproviders.VersionConstraints) { @@ -365,6 +421,10 @@ func (v *InitJSON) LogModuleInstallation(message string) { v.view.Log(message) } +// prepareMessage retrieves a message template matching the InitMessageCode and +// returns a formatted string made using the template and param argument(s). +// +// As this is implemented on InitJSON the JSON message template is used. func (v *InitJSON) prepareMessage(messageCode InitMessageCode, params ...any) string { message, ok := MessageRegistry[messageCode] if !ok { @@ -430,26 +490,10 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe HumanValue: "\n[reset][bold]Initializing provider plugins...", JSONValue: "Initializing provider plugins...", }, - "initializing_state_store_provider_plugin_message": { - HumanValue: "\n[reset][bold]Initializing provider %s%s for state store %q...", - JSONValue: "Initializing provider %s%s for state store %q...", - }, "initializing_state_store_message": { HumanValue: "\n[reset][bold]Initializing the state store %q...", JSONValue: "Initializing the state store %q...", }, - "state_store_provider_interactive_approved_message": { - HumanValue: "\n[reset][bold]The state store provider was approved by the user.", - JSONValue: "The state store provider was approved by the user.", - }, - "state_store_provider_interactive_rejected_message": { - HumanValue: "\n[reset][bold]The state store provider was rejected by the user.", - JSONValue: "The state store provider was rejected by the user.", - }, - "state_store_provider_automation_approved_message": { - HumanValue: "\n[reset][bold]The state store provider was approved automatically.", - JSONValue: "The state store provider was approved automatically.", - }, "dependencies_lock_changes_info": { HumanValue: dependenciesLockChangesInfo, JSONValue: dependenciesLockChangesInfo, @@ -546,24 +590,20 @@ const ( // Following message codes are used and documented EXTERNALLY // Keep docs/internals/machine-readable-ui.mdx up to date with // this list when making changes here. - CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" - OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" - OutputInitSuccessMessage InitMessageCode = "output_init_success_message" - OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" - OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" - OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" - UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" - InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" - InitializingModulesMessage InitMessageCode = "initializing_modules_message" - InitializingBackendMessage InitMessageCode = "initializing_backend_message" - InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" - InitializingStateStoreProviderPluginMessage InitMessageCode = "initializing_state_store_provider_plugin_message" - StateStoreProviderInteractiveApprovedMessage InitMessageCode = "state_store_provider_interactive_approved_message" - StateStoreProviderInteractiveRejectedMessage InitMessageCode = "state_store_provider_interactive_rejected_message" - StateStoreProviderAutomationApprovedMessage InitMessageCode = "state_store_provider_automation_approved_message" - InitializingProviderPluginMessage InitMessageCode = "initializing_provider_plugin_message" - LockInfo InitMessageCode = "lock_info" - DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" + CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" + OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" + OutputInitSuccessMessage InitMessageCode = "output_init_success_message" + OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" + OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" + OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" + UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" + InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" + InitializingModulesMessage InitMessageCode = "initializing_modules_message" + InitializingBackendMessage InitMessageCode = "initializing_backend_message" + InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" + InitializingProviderPluginMessage InitMessageCode = "initializing_provider_plugin_message" + LockInfo InitMessageCode = "lock_info" + DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" //// Message codes below are ONLY used INTERNALLY (for now) diff --git a/internal/command/views/init_test.go b/internal/command/views/init_test.go index 0ce789c65da3..6d5aee771db6 100644 --- a/internal/command/views/init_test.go +++ b/internal/command/views/init_test.go @@ -880,7 +880,7 @@ func TestNewInit_LogPartnerAndCommunityProviders_json(t *testing.T) { } } -func TestNewInit_LogInitializingStateStoreProviderPlugin_json(t *testing.T) { +func TestNewInit_LogInitializingStateStoreProviderStart_json(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := NewView(streams) initView := NewInit(arguments.ViewJSON, view) @@ -888,7 +888,7 @@ func TestNewInit_LogInitializingStateStoreProviderPlugin_json(t *testing.T) { pAddr := addrs.NewDefaultProvider("test") cons := getproviders.MustParseVersionConstraints("~> 1.0") storeType := "test_store" - initView.LogInitializingStateStoreProviderPlugin(pAddr, cons, storeType) + initView.LogInitializingStateStoreProviderStart(pAddr, cons, storeType) // Assert output output := done(t) @@ -897,8 +897,7 @@ func TestNewInit_LogInitializingStateStoreProviderPlugin_json(t *testing.T) { `"@message":"Initializing provider hashicorp/test (~\u003e 1.0) for state store \"test_store\"..."`, `"@module":"terraform.ui"`, //@timestamp is dynamic - `"message_code":"initializing_state_store_provider_plugin_message"`, - `"type":"init_output"`, + `"type":"state_store_provider_initialization_start"`, } for _, snippet := range expectedOutputFields { if !strings.Contains(output.Stdout(), snippet) { diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 95cad8f93dbc..313f025ddd7a 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -63,4 +63,14 @@ const ( MessagePolicyInfo MessageType = "policy_info" MessagePolicyDiagnostic MessageType = "policy_diagnostic" MessagePolicyEvaluationResult MessageType = "policy_result" + + // Provider installation messages + InitializingStateStoreProviderStart MessageType = "state_store_provider_initialization_start" + DependencyLockFileCreated MessageType = "dependency_lockfile_created" + DependencyLockFileUpdated MessageType = "dependency_lockfile_updated" + + // PSS messages + StateStoreProviderInteractiveApproval MessageType = "state_store_provider_interactive_approval" + StateStoreProviderInteractiveRejection MessageType = "state_store_provider_interactive_rejection" + StateStoreProviderAutomationApproval MessageType = "state_store_provider_automatic_approval" ) diff --git a/internal/command/views/provider_installation_logger.go b/internal/command/views/provider_installation_logger.go index 0ff38e53b35a..07bdf1902b0c 100644 --- a/internal/command/views/provider_installation_logger.go +++ b/internal/command/views/provider_installation_logger.go @@ -45,10 +45,15 @@ type ProviderInstallationLogger interface { // Log that a provider successfully fetched in this operation is maintained by third-parties and describe how these are signed LogPartnerAndCommunityProviders() - // LogInitializingStateStoreProviderPlugin indicates progress during installation of a state store provider plugin - LogInitializingStateStoreProviderPlugin(providerAddr addrs.Provider, cons getproviders.VersionConstraints, storeType string) + // LogInitializingStateStoreProviderStart indicates progress during installation of a state store provider plugin + LogInitializingStateStoreProviderStart(providerAddr addrs.Provider, cons getproviders.VersionConstraints, storeType string) prepareMessage(messageCode InitMessageCode, params ...any) string Spacer // output from provider installation is spaced out from following human-readable output log lines } + +const ( + logInitializingStateStoreProviderStartMessageHuman = "[reset][bold]Initializing provider %s%s for state store %q..." + logInitializingStateStoreProviderStartMessageJSON = "Initializing provider %s%s for state store %q..." +) diff --git a/internal/command/views/state_migrate.go b/internal/command/views/state_migrate.go index fa92d6756f94..9219cfc6eafc 100644 --- a/internal/command/views/state_migrate.go +++ b/internal/command/views/state_migrate.go @@ -10,6 +10,7 @@ import ( tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -52,15 +53,21 @@ type StateMigrate interface { ProviderInstallationLogger DependencyLockingLogger + StateStoreProviderTrustLogger + Spacer // The `state migrate` command logs empty lines to space-out different sections of human-readable output } func NewStateMigrate(viewType arguments.ViewType, view *View) StateMigrate { switch viewType { case arguments.ViewHuman: - return &StateMigrateHuman{view: view} + return &StateMigrateHuman{ + view: view, + } default: - panic(fmt.Sprintf("unsupported view type: %s", viewType)) + return &StateMigrateJSON{ + view: NewJSONView(view), + } } } @@ -78,10 +85,12 @@ func (s *StateMigrateHuman) Diagnostics(diags tfdiags.Diagnostics) { s.view.Diagnostics(diags) } +// Plain logging of messages, without using message codes func (s *StateMigrateHuman) Log(message string, params ...any) { s.log(fmt.Sprintf(message, params...)) } +// log is reused to ensure human output is always trimmed and colourised before printing to the output stream. func (s *StateMigrateHuman) log(preparedMessage string) { msg := s.view.colorize.Color(strings.TrimSpace(preparedMessage)) s.view.streams.Println(msg) @@ -102,16 +111,31 @@ func (s *StateMigrateHuman) Output(code InitMessageCode, params ...any) { } // Implements ProviderInstallationLogger interface. -func (s *StateMigrateHuman) LogInitializingStateStoreProviderPlugin(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { +func (s *StateMigrateHuman) LogInitializingStateStoreProviderStart(pAddr tfaddr.Provider, cons getproviders.VersionConstraints, storeType string) { consSuffix := "" if len(cons) > 0 { consSuffix = fmt.Sprintf(" (%s)", getproviders.VersionConstraintsString(cons)) } params := []any{pAddr.ForDisplay(), consSuffix, storeType} - msg := s.prepareMessage(InitializingStateStoreProviderPluginMessage, params...) + msg := fmt.Sprintf(logInitializingStateStoreProviderStartMessageHuman, params...) s.log(msg) } +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateHuman) LogInteractiveApproval() { + s.log(logInteractiveApprovalMessageHuman) +} + +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateHuman) LogInteractiveRejection() { + s.log(logInteractiveRejectionMessageHuman) +} + +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateHuman) LogAutomaticApproval() { + s.log(logInteractiveAutomaticApprovalMessageHuman) +} + // Implements ProviderInstallationLogger interface. func (s *StateMigrateHuman) LogFindingMatchingVersion(providerAddr addrs.Provider, versionConstraints getproviders.VersionConstraints) { params := []any{providerAddr.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)} @@ -183,12 +207,12 @@ func (s *StateMigrateHuman) LogPartnerAndCommunityProviders() { s.log(msg) } -// Implements DependencyLockLogger interface. +// Implements DependencyLockingLogger interface. func (s *StateMigrateHuman) LogDependencyLockfileCreated() { s.log(previousLockInfoHuman) } -// Implements DependencyLockLogger interface. +// Implements DependencyLockingLogger interface. func (s *StateMigrateHuman) LogDependencyLockfileUpdated() { s.log(dependenciesLockChangesInfo) } @@ -197,8 +221,7 @@ func (s *StateMigrateHuman) LogDependencyLockfileUpdated() { func (s *StateMigrateHuman) prepareMessage(code InitMessageCode, params ...any) string { message, ok := MessageRegistry[code] if !ok { - // display the message code as fallback if not found in the message registry - return string(code) + panic("missing message for init message code " + string(code)) } if message.HumanValue == "" { @@ -208,3 +231,213 @@ func (s *StateMigrateHuman) prepareMessage(code InitMessageCode, params ...any) return s.view.colorize.Color(strings.TrimSpace(fmt.Sprintf(message.HumanValue, params...))) } + +var ( + _ StateMigrate = (*StateMigrateJSON)(nil) + _ ProviderInstallationLogger = (*StateMigrateJSON)(nil) + _ Spacer = (*StateMigrateJSON)(nil) +) + +type StateMigrateJSON struct { + view *JSONView +} + +func (s *StateMigrateJSON) Diagnostics(diags tfdiags.Diagnostics) { + s.view.Diagnostics(diags) +} + +// Implements Spacer +func (s *StateMigrateJSON) Spacer() { + // no-op for JSON output, since we don't want to log empty messages in JSON +} + +// Plain logging of messages +// Logged data includes by default: +// @level as "info" +// @module as "terraform.ui" (See NewJSONView) +// @timestamp formatted in the default way +// type as "log". +// +// No additional fields supplied. +func (s *StateMigrateJSON) Log(message string, params ...any) { + msg := strings.TrimSpace(fmt.Sprintf(message, params...)) + s.view.log.Info(msg) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) Output(code InitMessageCode, params ...any) { + msg, ok := MessageRegistry[code] + if !ok || msg.JSONValue == "" { + panic(fmt.Sprintf("missing message for init message code %s, got: %s", string(code), msg.JSONValue)) + } + s.Log(msg.JSONValue, params...) +} + +// Implements ProviderInstallationLogger interface. +func (s *StateMigrateJSON) LogInitializingStateStoreProviderStart(providerAddr addrs.Provider, cons getproviders.VersionConstraints, storeType string) { + consSuffix := "" + if len(cons) > 0 { + consSuffix = fmt.Sprintf(" (%s)", getproviders.VersionConstraintsString(cons)) + } + params := []any{providerAddr.ForDisplay(), consSuffix, storeType} + msg := fmt.Sprintf(logInitializingStateStoreProviderStartMessageJSON, params...) + + s.view.log.Info( + msg, + "type", json.InitializingStateStoreProviderStart, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateJSON) LogInteractiveApproval() { + s.view.log.Info( + logInteractiveApprovalMessageJSON, + "type", json.StateStoreProviderInteractiveApproval, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateJSON) LogInteractiveRejection() { + s.view.log.Info( + logInteractiveRejectionMessageJSON, + "type", json.StateStoreProviderInteractiveRejection, + ) +} + +// Implements StateStoreProviderTrustLogger interface. +func (s *StateMigrateJSON) LogAutomaticApproval() { + s.view.log.Info( + logInteractiveAutomaticApprovalMessageJSON, + "type", json.StateStoreProviderAutomationApproval, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogFindingMatchingVersion(providerAddr addrs.Provider, versionConstraints getproviders.VersionConstraints) { + params := []any{providerAddr.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)} + msg := s.prepareMessage(FindingMatchingVersionMessage, params...) + s.view.log.Info( + msg, + "type", FindingMatchingVersionMessage, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogFindingLatestVersion(providerAddr addrs.Provider) { + params := []any{providerAddr.ForDisplay()} + msg := s.prepareMessage(FindingLatestVersionMessage, params...) + s.view.log.Info( + msg, + "type", FindingLatestVersionMessage, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogProviderVersionAlreadyInstalled(providerAddr addrs.Provider, version getproviders.Version) { + params := []any{providerAddr.ForDisplay(), version} + msg := s.prepareMessage(ProviderAlreadyInstalledMessage, params...) + s.view.log.Info( + msg, + "type", ProviderAlreadyInstalledMessage, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogUsingProviderVersionFromCacheDir(providerAddr addrs.Provider, version getproviders.Version) { + params := []any{providerAddr.ForDisplay(), version} + msg := s.prepareMessage(UsingProviderFromCacheDirInfo, params...) + s.view.log.Info( + msg, + "type", UsingProviderFromCacheDirInfo, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogBuiltInProviderAvailable(providerAddr addrs.Provider) { + params := []any{providerAddr.ForDisplay()} + msg := s.prepareMessage(BuiltInProviderAvailableMessage, params...) + s.view.log.Info( + msg, + "type", BuiltInProviderAvailableMessage, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogInstallingProviderVersion(providerAddr addrs.Provider, version getproviders.Version) { + params := []any{providerAddr.ForDisplay(), version} + msg := s.prepareMessage(InstallingProviderMessage, params...) + s.view.log.Info( + msg, + "type", InstallingProviderMessage, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogReusingPreviousProviderVersion(providerAddr addrs.Provider, version getproviders.Version) { + params := []any{providerAddr.ForDisplay(), version.String()} + msg := s.prepareMessage(ReusingPreviousVersionInfo, params...) + s.view.log.Info( + msg, + "type", ReusingPreviousVersionInfo, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogProviderVersionSuccess(providerAddr addrs.Provider, version getproviders.Version, auth *getproviders.PackageAuthenticationResult) { + params := []any{providerAddr.ForDisplay(), version, auth, ""} // add empty key id to the end + msg := s.prepareMessage(InstalledProviderVersionInfo, params...) + s.view.log.Info( + msg, + "type", InstalledProviderVersionInfo, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogProviderVersionSuccessWithKeyID(providerAddr addrs.Provider, version getproviders.Version, auth *getproviders.PackageAuthenticationResult, keyID string) { + keyDetails := fmt.Sprintf("key_id: %s", keyID) // key id needs to be formatted for JSON output + params := []any{providerAddr.ForDisplay(), version, auth, keyDetails} + + msg := s.prepareMessage(InstalledProviderVersionInfo, params...) + s.view.log.Info( + msg, + "type", InstalledProviderVersionInfo, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) LogPartnerAndCommunityProviders() { + msg := s.prepareMessage(PartnerAndCommunityProvidersMessage) + s.view.log.Info( + msg, + "type", PartnerAndCommunityProvidersMessage, + ) +} + +// Implements DependencyLockingLogger interface. +func (s *StateMigrateJSON) LogDependencyLockfileCreated() { + s.view.log.Info( + previousLockInfoJSON, + "type", json.DependencyLockFileCreated, + ) +} + +// Implements DependencyLockingLogger interface. +func (s *StateMigrateJSON) LogDependencyLockfileUpdated() { + s.view.log.Info( + dependenciesLockChangesInfo, + "type", json.DependencyLockFileUpdated, + ) +} + +// Implements ProviderInstaller interface. +func (s *StateMigrateJSON) prepareMessage(code InitMessageCode, params ...any) string { + message, ok := MessageRegistry[code] + if !ok || message.JSONValue == "" { + // We neither want to log a non-existent message code, nor an empty message in JSON. + // Empty messages should only be intentionally logged via the Spacer method, which is a no-op in the JSON view. + + panic(fmt.Sprintf("missing message for init message code %s, got: %s", string(code), message.JSONValue)) + } + + return strings.TrimSpace(fmt.Sprintf(message.JSONValue, params...)) +} diff --git a/internal/command/views/state_migrate_test.go b/internal/command/views/state_migrate_test.go index a57b1cf6c3ca..177c83154aad 100644 --- a/internal/command/views/state_migrate_test.go +++ b/internal/command/views/state_migrate_test.go @@ -4,8 +4,11 @@ package views import ( + "fmt" + "strings" "testing" + "github.com/apparentlymart/go-versions/versions" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/getproviders" @@ -35,6 +38,24 @@ func TestNewStateMigrate_LogProviderVersionSuccess(t *testing.T) { t.Fatalf("expected %q, got %q", expectedOutput, output.Stdout()) } }) + t.Run("no auth result - json view", func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + ver := getproviders.MustParseVersion("1.2.3") + var authResult *getproviders.PackageAuthenticationResult = nil + + smView.LogProviderVersionSuccess(p, ver, authResult) + + // Assert output + output := done(t) + expectedOutput := `"@message":"Installed provider version: hashicorp/test v1.2.3 (unauthenticated)"` + if !strings.Contains(output.Stdout(), expectedOutput) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", expectedOutput, output.Stdout()) + } + }) t.Run("verified checksum auth result - human view", func(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := NewView(streams) @@ -53,6 +74,24 @@ func TestNewStateMigrate_LogProviderVersionSuccess(t *testing.T) { t.Fatalf("expected %q, got %q", expectedOutput, output.Stdout()) } }) + t.Run("verified checksum auth result - json view", func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + ver := getproviders.MustParseVersion("1.2.3") + authResult := getproviders.NewPackageAuthenticationResult(verifiedChecksum, noKey) + + smView.LogProviderVersionSuccess(p, ver, authResult) + + // Assert output - human + output := done(t) + expectedOutput := `"@message":"Installed provider version: hashicorp/test v1.2.3 (verified checksum)"` + if !strings.Contains(output.Stdout(), expectedOutput) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", expectedOutput, output.Stdout()) + } + }) t.Run("official provider auth result - human view", func(t *testing.T) { streams, done := terminal.StreamsForTesting(t) view := NewView(streams) @@ -72,8 +111,28 @@ func TestNewStateMigrate_LogProviderVersionSuccess(t *testing.T) { t.Fatalf("expected %q, got %q", expectedOutput, output.Stdout()) } }) + t.Run("official provider auth result - json view", func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + ver := getproviders.MustParseVersion("1.2.3") + key := "key-id-123" + authResult := getproviders.NewPackageAuthenticationResult(officialProvider, key) + + smView.LogProviderVersionSuccess(p, ver, authResult) + + // Assert output - human + output := done(t) + expectedOutput := `"@message":"Installed provider version: hashicorp/test v1.2.3 (signed by HashiCorp)"` + if !strings.Contains(output.Stdout(), expectedOutput) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", expectedOutput, output.Stdout()) + } + }) } +// Assert message content func TestNewStateMigrate_LogProviderVersionSuccessWithKeyID(t *testing.T) { const partnerProvider = 2 @@ -96,4 +155,332 @@ func TestNewStateMigrate_LogProviderVersionSuccessWithKeyID(t *testing.T) { t.Fatalf("expected %q, got %q", expectedOutput, output.Stdout()) } }) + t.Run("partner provider auth result -json view", func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + ver := getproviders.MustParseVersion("1.2.3") + key := "key-id-123" + authResult := getproviders.NewPackageAuthenticationResult(partnerProvider, key) + + smView.LogProviderVersionSuccessWithKeyID(p, ver, authResult, key) + + // Assert output + output := done(t) + expectedOutput := `"@message":"Installed provider version: hashicorp/test v1.2.3 (signed by a HashiCorp partnerkey_id: key-id-123)"` + if !strings.Contains(output.Stdout(), expectedOutput) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", expectedOutput, output.Stdout()) + } + }) +} + +// Assert JSON log content, including log type and additional fields +func TestNewStateMigrate_LogProviderVersionSuccess_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + officialProvider := 1 + authResult := getproviders.NewPackageAuthenticationResult(officialProvider, "key-id-123") + smView.LogProviderVersionSuccess(p, v, authResult) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Installed provider version: hashicorp/test v1.0.0 (signed by HashiCorp)"`, + `"@module":"terraform.ui"`, + `"type":"installed_provider_version_info"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_ProviderAlreadyInstalled_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + smView.LogProviderVersionAlreadyInstalled(p, v) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"hashicorp/test v1.0.0: Using previously-installed provider version"`, + `"@module":"terraform.ui"`, + `"type":"provider_already_installed_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +// Assert JSON log content, including log type and additional fields +// +// Note - in calling code this is only ever used for partner providers +func TestNewStateMigrate_LogProviderVersionSuccessWithKeyID_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + partnerProvider := 2 + keyID := "key-id-123" + authResult := getproviders.NewPackageAuthenticationResult(partnerProvider, keyID) + smView.LogProviderVersionSuccessWithKeyID(p, v, authResult, keyID) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Installed provider version: hashicorp/test v1.0.0 (signed by a HashiCorp partnerkey_id: key-id-123)"`, + `"@module":"terraform.ui"`, + `"type":"installed_provider_version_info"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_ReusingPreviousVersion_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + initView := NewInit(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + initView.LogReusingPreviousProviderVersion(p, v) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"hashicorp/test: Reusing previous version from the dependency lock file"`, + `"@module":"terraform.ui"`, + `"type":"log"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_FindingMatchingVersion_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + constraint, _ := getproviders.ParseVersionConstraints("1.0.0") + smView.LogFindingMatchingVersion(p, constraint) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Finding matching versions for provider: hashicorp/test, version_constraint: \"1.0.0\""`, + `"@module":"terraform.ui"`, + `"type":"finding_matching_version_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_FindingLatestVersion_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + smView.LogFindingLatestVersion(p) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"hashicorp/test: Finding latest version..."`, + `"@module":"terraform.ui"`, + `"type":"finding_latest_version_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_InstallingProvider_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + smView.LogInstallingProviderVersion(p, v) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Installing provider version: hashicorp/test v1.0.0..."`, + `"@module":"terraform.ui"`, + `"type":"installing_provider_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_BuiltInProviderAvailable_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + smView.LogBuiltInProviderAvailable(p) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"hashicorp/test is built in to Terraform"`, + `"@module":"terraform.ui"`, + `"type":"built_in_provider_available_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_UsingProviderFromCacheDirInfo_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + v := versions.MustParseVersion("1.0.0") + smView.LogUsingProviderVersionFromCacheDir(p, v) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"hashicorp/test v1.0.0: Using from the shared cache directory"`, + `"@module":"terraform.ui"`, + `"type":"using_provider_from_cache_dir_info"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_PartnerAndCommunityProviders_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + smView.LogPartnerAndCommunityProviders() + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Partner and community providers are signed by their developers.\nIf you'd like to know more about provider signing, you can read about it here:\nhttps://developer.hashicorp.com/terraform/cli/plugins/signing"`, + `"@module":"terraform.ui"`, + `"type":"partner_and_community_providers_message"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_InitializingStateStoreProviderPlugin_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + p := addrs.MustParseProviderSourceString("hashicorp/test") + vc := getproviders.MustParseVersionConstraints("~> 1.1") + storeType := "test_store" + smView.LogInitializingStateStoreProviderStart(p, vc, storeType) + + // Assert output + output := done(t) + expectedOutputFields := []string{ + `"@level":"info"`, + `"@message":"Initializing provider hashicorp/test (~\u003e 1.1) for state store \"test_store\"..."`, + `"@module":"terraform.ui"`, + `"type":"state_store_provider_initialization_start"`, + } + for _, snippet := range expectedOutputFields { + if !strings.Contains(output.Stdout(), snippet) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", snippet, output.Stdout()) + } + } +} + +func TestNewStateMigrate_Log_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + templateMessage := "This is a test log message with a parameter (%s), and both trailing whitespace and newline. \n" + parameter := "test_parameter" + smView.Log(templateMessage, parameter) + + // Assert output + output := done(t) + expected := fmt.Sprintf( + `"@message":"%s"`, + strings.TrimSpace(fmt.Sprintf(templateMessage, parameter)), + ) + + if !strings.Contains(output.Stdout(), expected) { + t.Fatalf("output didn't include expected snippet:\n expected: %s\n got:\n %s", expected, output.Stdout()) + } +} + +func TestNewStateMigrate_Spacer_json(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + smView := NewStateMigrate(arguments.ViewJSON, view) + + smView.Spacer() + + // Assert output + output := done(t) + + // We cannot simply assert no output as the JSON view logs the version message on initialization + // Splitting on \n when there's only the version log will get an array of the log and an empty string. + // If there are more logs there'll be >2 elements. + if x := strings.Split(output.Stdout(), "\n"); len(x) != 2 { + t.Fatalf("expected no additional output after version message, got: %s", output.Stdout()) + } } diff --git a/internal/command/views/state_store_provider_trust_logger.go b/internal/command/views/state_store_provider_trust_logger.go new file mode 100644 index 000000000000..1e4e7728ef3e --- /dev/null +++ b/internal/command/views/state_store_provider_trust_logger.go @@ -0,0 +1,25 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package views + +type StateStoreProviderTrustLogger interface { + LogInteractiveApproval() + LogInteractiveRejection() + LogAutomaticApproval() + + Spacer +} + +// Human-readable messages, one version for human-readable output and the 'json' version for the +// human-readable @message field in the JSON output. +const ( + logInteractiveApprovalMessageHuman = "[reset][bold]The state store provider was approved by the user." + logInteractiveApprovalMessageJSON = "The state store provider was approved by the user." + + logInteractiveRejectionMessageHuman = "[reset][bold]The state store provider was rejected by the user." + logInteractiveRejectionMessageJSON = "The state store provider was rejected by the user." + + logInteractiveAutomaticApprovalMessageHuman = "[reset][bold]The state store provider was approved automatically." + logInteractiveAutomaticApprovalMessageJSON = "The state store provider was approved automatically." +)