Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
229f42f
Add a generic method for loading an operations backend in non-init co…
SarahFrench Sep 8, 2025
082f481
Refactor commands to use new prepareBackend method: group 1
SarahFrench Sep 8, 2025
70c4f39
Refactor commands to use new prepareBackend method: group 2, where co…
SarahFrench Sep 8, 2025
44b9e4a
Refactor commands to use new prepareBackend method: group 3, where we…
SarahFrench Sep 8, 2025
5bacdce
Additional, more nested, places where logic for accessing backends ne…
SarahFrench Sep 8, 2025
7d5f88d
Remove duplicated comment
SarahFrench Sep 8, 2025
323b179
Add test coverage of `(m *Meta) prepareBackend()`
SarahFrench Sep 9, 2025
1afde44
Add TODO related to using plans for backend/state_store config in app…
SarahFrench Oct 20, 2025
5ba4a6f
Add `testStateStoreMockWithChunkNegotiation` test helper
SarahFrench Oct 20, 2025
ae6db5c
Add assertions to tests about the backend (remote-state, local, etc) …
SarahFrench Oct 20, 2025
be43ec6
Stop prepareBackend taking locks as argument
SarahFrench Oct 20, 2025
823c5b4
Code comment in prepareBackend
SarahFrench Oct 20, 2025
96269e9
Replace c.Meta.prepareBackend with c.prepareBackend
SarahFrench Oct 22, 2025
b71931c
Change `c.Meta.loadSingleModule` to `c.loadSingleModule`
SarahFrench Oct 22, 2025
9ca9710
Rename (Meta).prepareBackend to (Meta).backend, update godoc comment…
SarahFrench Oct 22, 2025
c8b001c
Revert change from config.Module to config.Root.Module
SarahFrench Oct 22, 2025
6698109
Update `(m *Meta) backend` method to parse config itself, and also to…
SarahFrench Oct 24, 2025
84fb3f0
Update all tests and calling code following previous commit
SarahFrench Oct 24, 2025
733618f
Change how an operations backend is obtained by autocomplete code
SarahFrench Oct 30, 2025
7497796
Update autocomplete to return nil if no workspace names are returned …
SarahFrench Oct 30, 2025
0cf0769
Add test coverage for autocompleting workspace names when using a plu…
SarahFrench Oct 31, 2025
b07a903
Fix output command: pass view type data to new `backend` method
SarahFrench Oct 31, 2025
99fc261
Fix in plan command: pass correct view type to `backend` method
SarahFrench Oct 31, 2025
e9b15ec
Fix `providers schema` command to use correct viewtype when preparing…
SarahFrench Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions internal/command/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,19 +219,15 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.WrappedPlanFile, args *
))
return nil, diags
}
// TODO: Update BackendForLocalPlan to use state storage, and plan to be able to contain State Store config details
be, beDiags = c.BackendForLocalPlan(plan.Backend)
Comment on lines 221 to 223
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is scoped to this ticket: https://hashicorp.atlassian.net/browse/TF-28374
I've linked here from that ticket.

} else {
// Both new plans and saved cloud plans load their backend from config.
backendConfig, configDiags := c.loadBackendConfig(".")
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, diags
}

be, beDiags = c.Backend(&BackendOpts{
BackendConfig: backendConfig,
ViewType: viewType,
})
// Load the backend
//
// Note: Both new plans and saved cloud plans load their backend from config,
// hence the config parsing in the method below.
be, beDiags = c.backend(".", viewType)
}

diags = diags.Append(beDiags)
Expand Down
14 changes: 7 additions & 7 deletions internal/command/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package command

import (
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/posener/complete"
)

Expand Down Expand Up @@ -48,19 +49,18 @@ func (m *Meta) completePredictWorkspaceName() complete.Predictor {
return nil
}

backendConfig, diags := m.loadBackendConfig(configPath)
b, diags := m.backend(configPath, arguments.ViewHuman)
if diags.HasErrors() {
return nil
}

b, diags := m.Backend(&BackendOpts{
BackendConfig: backendConfig,
})
if diags.HasErrors() {
names, _ := b.Workspaces()
if len(names) == 0 {
// Presence of the "default" isn't always guaranteed
// Backends will report it as always existing, pluggable
// state stores will only do so if it _actually_ exists.
return nil
}

names, _ := b.Workspaces()
return names
})
}
11 changes: 1 addition & 10 deletions internal/command/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,8 @@ func (c *ConsoleCommand) Run(args []string) int {

var diags tfdiags.Diagnostics

backendConfig, backendDiags := c.loadBackendConfig(configPath)
diags = diags.Append(backendDiags)
if diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}

// Load the backend
b, backendDiags := c.Backend(&BackendOpts{
BackendConfig: backendConfig,
})
b, backendDiags := c.backend(configPath, arguments.ViewHuman)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
Expand Down
11 changes: 1 addition & 10 deletions internal/command/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,8 @@ func (c *GraphCommand) Run(args []string) int {

var diags tfdiags.Diagnostics

backendConfig, backendDiags := c.loadBackendConfig(configPath)
diags = diags.Append(backendDiags)
if diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}

// Load the backend
b, backendDiags := c.Backend(&BackendOpts{
BackendConfig: backendConfig,
})
b, backendDiags := c.backend(".", arguments.ViewHuman)
Copy link
Member

@radeksimko radeksimko Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this isn't related to your PR really but it's interesting to note the graph command output is always "machine-readable" in the sense that it's DOT language which a human would first pipe into something like graphviz before they can read it. 😅

Though it's not JSON, so neither of the two view types we have are a good match. 🤷🏻

And yes - mimicking the existing default behaviour is a very sensible approach!

diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
Expand Down
4 changes: 1 addition & 3 deletions internal/command/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ func (c *ImportCommand) Run(args []string) int {
}

// Load the backend
b, backendDiags := c.Backend(&BackendOpts{
BackendConfig: config.Module.Backend,
})
b, backendDiags := c.backend(".", arguments.ViewHuman)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
Expand Down
76 changes: 76 additions & 0 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,82 @@ func (m *Meta) updateSavedBackendHash(cHash int, sMgr *clistate.LocalState) tfdi
return diags
}

// backend returns an operations backend that may use a backend, cloud, or state_store block for state storage.
// Based on the supplied config, it prepares arguments to pass into (Meta).Backend, which returns the operations backend.
//
// This method should be used in NON-init operations only; it's incapable of processing new init command CLI flags used
// for partial configuration, however it will use the backend state file to use partial configuration from a previous
// init command.
func (m *Meta) backend(configPath string, viewType arguments.ViewType) (backendrun.OperationsBackend, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics

if configPath == "" {
configPath = "."
}

// Only return error diagnostics at this point. Any warnings will be caught
// again later and duplicated in the output.
root, mDiags := m.loadSingleModule(configPath)
if mDiags.HasErrors() {
diags = diags.Append(mDiags)
return nil, diags
}
Comment on lines +1596 to +1602
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made the new backend method resume being the code to parse the configuration for backend/state_store config. This means that some commands parse the configuration twice, as this code is no longer able to accept parsed config as input.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, I expect this will have some performance impact but also I hope/assume it will be negligible in the grand scheme of things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agreed - I think only a few commands would be negatively affected so it's not too bad


var opts *BackendOpts
switch {
case root.Backend != nil:
opts = &BackendOpts{
BackendConfig: root.Backend,
ViewType: viewType,
}
case root.CloudConfig != nil:
backendConfig := root.CloudConfig.ToBackendConfig()
opts = &BackendOpts{
BackendConfig: &backendConfig,
ViewType: viewType,
}
case root.StateStore != nil:
// In addition to config, use of a state_store requires
// provider factory and provider locks data
locks, lDiags := m.lockedDependencies()
diags = diags.Append(lDiags)
if lDiags.HasErrors() {
return nil, diags
}

factory, fDiags := m.GetStateStoreProviderFactory(root.StateStore, locks)
diags = diags.Append(fDiags)
if fDiags.HasErrors() {
return nil, diags
}

opts = &BackendOpts{
StateStoreConfig: root.StateStore,
ProviderFactory: factory,
Locks: locks,
ViewType: viewType,
}
default:
// there is no config; defaults to local state storage
opts = &BackendOpts{
ViewType: viewType,
}
}

// This method should not be used for init commands,
// so we always set this value as false.
opts.Init = false

// Load the backend
be, beDiags := m.Backend(opts)
diags = diags.Append(beDiags)
if beDiags.HasErrors() {
return nil, diags
}

return be, diags
}

//-------------------------------------------------------------------
// State Store Config Scenarios
// The functions below cover handling all the various scenarios that
Expand Down
136 changes: 136 additions & 0 deletions internal/command/meta_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/workdir"
"github.com/hashicorp/terraform/internal/configs"
Expand Down Expand Up @@ -2962,6 +2963,126 @@ func Test_getStateStorageProviderVersion(t *testing.T) {
})
}

func TestMetaBackend_prepareBackend(t *testing.T) {

t.Run("it returns a cloud backend from cloud backend config", func(t *testing.T) {
// Create a temporary working directory with cloud configuration in
td := t.TempDir()
testCopyDir(t, testFixturePath("cloud-config"), td)
t.Chdir(td)

m := testMetaBackend(t, nil)

// We cannot initialize a cloud backend so we instead check
// the init error is referencing HCP Terraform
_, bDiags := m.backend(td, arguments.ViewHuman)
if !bDiags.HasErrors() {
t.Fatal("expected error but got none")
}
wantErr := "HCP Terraform or Terraform Enterprise initialization required: please run \"terraform init\""
if !strings.Contains(bDiags.Err().Error(), wantErr) {
t.Fatalf("expected error to contain %q, but got: %q",
wantErr,
bDiags.Err())
}
})

t.Run("it returns a backend from backend config", func(t *testing.T) {
// Create a temporary working directory with backend configuration in
td := t.TempDir()
testCopyDir(t, testFixturePath("backend-unchanged"), td)
t.Chdir(td)

m := testMetaBackend(t, nil)

b, bDiags := m.backend(td, arguments.ViewHuman)
if bDiags.HasErrors() {
t.Fatal("unexpected error: ", bDiags.Err())
}

if _, ok := b.(*local.Local); !ok {
t.Fatal("expected returned operations backend to be a Local backend")
}
// Check the type of backend inside the Local via schema
// In this case a `local` backend should have been returned by default.
//
// Look for the path attribute.
schema := b.ConfigSchema()
if _, ok := schema.Attributes["path"]; !ok {
t.Fatalf("expected the operations backend to report the schema of a local backend, but got something unexpected: %#v", schema)
}
})

t.Run("it returns a local backend when there is empty configuration", func(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("empty"), td)
t.Chdir(td)

m := testMetaBackend(t, nil)
b, bDiags := m.backend(td, arguments.ViewHuman)
if bDiags.HasErrors() {
t.Fatal("unexpected error: ", bDiags.Err())
}

if _, ok := b.(*local.Local); !ok {
t.Fatal("expected returned operations backend to be a Local backend")
}
// Check the type of backend inside the Local via schema
// In this case a `local` backend should have been returned by default.
//
// Look for the path attribute.
schema := b.ConfigSchema()
if _, ok := schema.Attributes["path"]; !ok {
t.Fatalf("expected the operations backend to report the schema of a local backend, but got something unexpected: %#v", schema)
}
})

t.Run("it returns a state_store from state_store config", func(t *testing.T) {
// Create a temporary working directory with backend configuration in
td := t.TempDir()
testCopyDir(t, testFixturePath("state-store-unchanged"), td)
t.Chdir(td)

m := testMetaBackend(t, nil)
m.AllowExperimentalFeatures = true
mock := testStateStoreMockWithChunkNegotiation(t, 12345) // chunk size needs to be set, value is arbitrary
m.testingOverrides = &testingOverrides{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): providers.FactoryFixed(mock),
},
}

// Prepare appropriate locks; config uses a hashicorp/test provider @ v1.2.3
locks := depsfile.NewLocks()
providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test")
constraint, err := providerreqs.ParseVersionConstraints(">1.0.0")
if err != nil {
t.Fatalf("test setup failed when making constraint: %s", err)
}
locks.SetProvider(
providerAddr,
versions.MustParseVersion("1.2.3"),
constraint,
[]providerreqs.Hash{""},
)

b, bDiags := m.backend(td, arguments.ViewHuman)
if bDiags.HasErrors() {
t.Fatalf("unexpected error: %s", bDiags.Err())
}

if _, ok := b.(*local.Local); !ok {
t.Fatal("expected returned operations backend to be a Local backend")
}
// Check the state_store inside the Local via schema
// Look for the mock state_store's attribute called `value`.
schema := b.ConfigSchema()
if _, ok := schema.Attributes["value"]; !ok {
t.Fatalf("expected the operations backend to report the schema of the state_store, but got something unexpected: %#v", schema)
}
})
}

func testMetaBackend(t *testing.T, args []string) *Meta {
var m Meta
m.Ui = new(cli.MockUi)
Expand Down Expand Up @@ -3011,6 +3132,21 @@ func testStateStoreMock(t *testing.T) *testing_provider.MockProvider {
}
}

// testStateStoreMockWithChunkNegotiation is just like testStateStoreMock but the returned mock is set up so it'll be configured
// without this error: `Failed to negotiate acceptable chunk size`
//
// This is meant to be a convenience method when a test is definitely not testing anything related to state store configuration.
func testStateStoreMockWithChunkNegotiation(t *testing.T, chunkSize int64) *testing_provider.MockProvider {
t.Helper()
mock := testStateStoreMock(t)
mock.ConfigureStateStoreResponse = &providers.ConfigureStateStoreResponse{
Capabilities: providers.StateStoreServerCapabilities{
ChunkSize: chunkSize,
},
}
return mock
}

func configBodyForTest(t *testing.T, config string) hcl.Body {
t.Helper()
f, diags := hclsyntax.ParseConfig([]byte(config), "", hcl.Pos{Line: 1, Column: 1})
Expand Down
4 changes: 2 additions & 2 deletions internal/command/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu
}

// Load the backend
b, backendDiags := c.Backend(nil)
b, backendDiags := c.backend(".", arguments.ViewHuman)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command does support both JSON and Human views so I think we should pass the chosen type as an argument instead of hard-coding to ViewHuman here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! In that case we may be potentially fixing a bug here; the original code didn't pass a view type in.

diags = diags.Append(backendDiags)
if diags.HasErrors() {
if backendDiags.HasErrors() {
return nil, diags
}

Expand Down
13 changes: 2 additions & 11 deletions internal/command/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,9 @@ func (c *PlanCommand) PrepareBackend(args *arguments.State, viewType arguments.V
// difficult but would make their use easier to understand.
c.Meta.applyStateArguments(args)

backendConfig, diags := c.loadBackendConfig(".")
if diags.HasErrors() {
return nil, diags
}

// Load the backend
be, beDiags := c.Backend(&BackendOpts{
BackendConfig: backendConfig,
ViewType: viewType,
})
diags = diags.Append(beDiags)
if beDiags.HasErrors() {
be, diags := c.backend(".", arguments.ViewHuman)
if diags.HasErrors() {
return nil, diags
}

Expand Down
Loading