backend+command: Alias names for backend types

This introduces the concept of "backend aliases", which are alternative
names that can be used to refer to a given backend.

Each backend type has one canonical name and zero or more alias names. The
"backend" block in the root module can specify either a canonical backend
type or an alias, but internally OpenTofu will always track the backend
type using its canonical name.

In particular, the following are all true when the configuration specifies
an alias instead of a canonical backend type:
- The "tofu init" output includes a brief extra message saying which
  backend type OpenTofu actually used, because that is the name that we'd
  prioritize in our documentation and so an operator can use the canonical
  type to find the relevant docs when needed.
- The .terraform/terraform.tfstate file that tracks the working directory's
  currently-initialized backend settings always uses the canonical backend
  type, and so it's possible to freely switch between aliases and canonical
  without "tofu init" thinking that a state migration might be needed.
- Plan files similarly use the canonical backend type to track which
  backend was active when the plan was created, which doesn't have any
  significant user-facing purpose, but is consistent with the previous
  point since the settings in the plan file effectively substitute for
  the .terraform/terraform.tfstate file when applying a saved plan.
- The terraform_remote_state data source in the provider
  terraform.io/builtin/terraform accepts both canonical and alias in its
  backend type argument, treating both as equivalent for the purpose of
  fetching the state snapshot for the configured workspace.

The primary motivation for this new facility is to allow the planned
"oracle_oci" backend to have an alias "oci" to allow writing configurations
that are cross-compatible with HashiCorp Terraform, since that software
has chosen to have unqualified OCI mean Oracle's system, whereas OpenTofu
has previously established that unqualified OCI means "Open Container
Initiative" in our ecosystem.

In particular, this design makes it possible in principle to bring an
existing Terraform configuration specifying backend "oci" over to OpenTofu
without modifications, and then to optionally switch it to specifying
backend "oracle-oci" at a later time without a spurious prompt to migrate
state snapshots to the same physical location where they are already
stored.

This commit doesn't actually introduce any aliases and therefore doesn't
have any tests for the new mechanism because our backend system uses a
global table that isn't friendly to mocking for testing purposes. I've
tested this manually using a placeholder alias to have confidence that it
works, and I expect that a subsequent commit introducing the new
"oracle_oci" backend will also introduce its "oci" alias and will include
tests that cover use of the alias and migration from the alias to the
canonical name and vice-versa.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
Martin Atkins
2025-09-10 14:16:24 -07:00
parent 79e5070b47
commit 5fa35c5601
7 changed files with 146 additions and 39 deletions

View File

@@ -31,18 +31,31 @@ import (
"github.com/opentofu/opentofu/internal/tfdiags"
)
// Backends are hardcoded into OpenTofu because the API for backends uses
// complex structures and supporting that over the plugin system is currently
// prohibitively difficult. For those wanting to implement a custom backend,
// they can do so with recompilation.
// backends is the list of available backends. This is a global variable
// because backends are currently hardcoded into OpenTofu and can't be
// modified without recompilation.
//
// To read an available backend, use the Backend function. This ensures
// safe concurrent read access to the list of built-in backends.
//
// Backends are hardcoded into OpenTofu because the API for backends uses
// complex structures and supporting that over the plugin system is currently
// prohibitively difficult. For those wanting to implement a custom backend,
// they can do so with recompilation.
// safe concurrent read access to the list of built-in backends by holding
// [backendsLock].
var backends map[string]backend.InitFn
// backendAliases complements [backends] by allowing alternative names for some
// backends. The keys are the alias names and the values are the canonical
// names. OpenTofu always normalizes any use of an alias into its canonical
// name so that the two are effectively interchangable.
//
// [backendsLock] also covers access to backendAliases. Use the Backend function
// to safely access both of these maps.
var backendAliases map[string]string
// backendsLock is a mutex that must be held when accessing either [backends]
// or [backendAliases].
var backendsLock sync.Mutex
// RemovedBackends is a record of previously supported backends which have
@@ -78,6 +91,9 @@ func Init(services *disco.Disco) {
// This is an implementation detail only, used for the cloud package
"cloud": func(enc encryption.StateEncryption) backend.Backend { return backendCloud.New(services, enc) },
}
backendAliases = map[string]string{
// There are currently no backend aliases
}
RemovedBackends = map[string]string{
"artifactory": `The "artifactory" backend is not supported in OpenTofu v1.3 or later.`,
@@ -91,10 +107,17 @@ func Init(services *disco.Disco) {
// Backend returns the initialization factory for the given backend, or
// nil if none exists.
func Backend(name string) backend.InitFn {
//
// The second return value is the canonical name for the selected backend,
// if any, which should be used in the UI and in OpenTofu's records of which
// backend is active in a particular working directory.
func Backend(name string) (backend.InitFn, string) {
backendsLock.Lock()
defer backendsLock.Unlock()
return backends[name]
if alias, ok := backendAliases[name]; ok {
name = alias
}
return backends[name], name
}
// Set sets a new backend in the list of backends. If f is nil then the

View File

@@ -13,35 +13,70 @@ import (
)
func TestInit_backend(t *testing.T) {
// Initialize the backends map
// Initialize the backends and backendAliases maps
Init(nil)
backends := []struct {
Name string
Type string
RequestedName string
Type string
CanonicalName string
}{
{"local", "*local.Local"},
{"remote", "*remote.Remote"},
{"azurerm", "*azure.Backend"},
{"consul", "*consul.Backend"},
{"cos", "*cos.Backend"},
{"gcs", "*gcs.Backend"},
{"inmem", "*inmem.Backend"},
{"pg", "*pg.Backend"},
{"s3", "*s3.Backend"},
{"local", "*local.Local", "local"},
{"remote", "*remote.Remote", "remote"},
{"azurerm", "*azure.Backend", "azurerm"},
{"consul", "*consul.Backend", "consul"},
{"cos", "*cos.Backend", "cos"},
{"gcs", "*gcs.Backend", "gcs"},
{"inmem", "*inmem.Backend", "inmem"},
{"pg", "*pg.Backend", "pg"},
{"s3", "*s3.Backend", "s3"},
}
// Make sure we get the requested backend
for _, b := range backends {
t.Run(b.Name, func(t *testing.T) {
f := Backend(b.Name)
t.Run(b.RequestedName, func(t *testing.T) {
f, canonName := Backend(b.RequestedName)
if f == nil {
t.Fatalf("backend %q is not present; should be", b.Name)
t.Fatalf("backend %q is not present; should be", b.RequestedName)
}
bType := reflect.TypeOf(f(encryption.StateEncryptionDisabled())).String()
if bType != b.Type {
t.Fatalf("expected backend %q to be %q, got: %q", b.Name, b.Type, bType)
t.Errorf("expected backend %q to be %q, got: %q", b.RequestedName, b.Type, bType)
}
if b.CanonicalName != canonName {
t.Errorf("expected canonical name to be %q, but got %q", b.CanonicalName, canonName)
}
})
}
}
// TestInit_backendConsistency ensures that the "backends" and "backendAliases"
// maps are kept consistent with one another, so that:
// - Every alias maps to a canonical backend name that is actually defined.
// - No single type name is both an alias _and_ a canonical name.
// - There must be a backend whose canonical name is "local" and no alias
// of that name because package command relies on this in various special
// cases.
func TestInit_backendConsistency(t *testing.T) {
// Initialize the backends and backendAliases maps
Init(nil)
backendsLock.Lock()
defer backendsLock.Unlock()
for aliasType, canonType := range backendAliases {
if _, ok := backends[canonType]; !ok {
t.Errorf("alias %q maps to canonical name %q, but the canonical name is not in the backends map", aliasType, canonType)
}
if _, ok := backends[aliasType]; ok {
t.Errorf("alias map has key %q, which is also a canonical name in the backends map", aliasType)
}
}
if _, ok := backends["local"]; !ok {
t.Error(`"local" must be defined as a an available backend type because lots of code in package command treats it as a special case`)
}
if _, ok := backendAliases["local"]; ok {
t.Error(`"local" must not be defined as an alias because lots of code in package command treats it as a special case`)
}
}

View File

@@ -278,5 +278,9 @@ func getBackendFactory(backendType string) backend.InitFn {
return overrideBackendFactories[backendType]
}
return backendInit.Backend(backendType)
// For the sake of this data source we don't care about the canonical
// name of the backend: canonical names or alias names are both accepted
// and are treated as interchangeable.
factory, _ := backendInit.Backend(backendType)
return factory
}

View File

@@ -752,7 +752,8 @@ func testBackendState(t *testing.T, s *states.State, c int) (*legacy.State, *htt
Config: configs.SynthBody("<testBackendState>", map[string]cty.Value{}),
Eval: configs.NewStaticEvaluator(nil, configs.RootModuleCallForTesting()),
}
b := backendInit.Backend("http")(encryption.StateEncryptionDisabled())
httpBackendInit, _ := backendInit.Backend("http")
b := httpBackendInit(encryption.StateEncryptionDisabled())
configSchema := b.ConfigSchema()
hash, _ := backendConfig.Hash(t.Context(), configSchema)

View File

@@ -495,7 +495,7 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext
return nil, true, diags
}
bf := backendInit.Backend(backendType)
bf, canonType := backendInit.Backend(backendType)
if bf == nil {
detail := fmt.Sprintf("There is no backend type named %q.", backendType)
if msg, removed := backendInit.RemovedBackends[backendType]; removed {
@@ -510,6 +510,9 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext
})
return nil, true, diags
}
if backendType != canonType {
c.Ui.Output(fmt.Sprintf("- %q is an alias for backend type %q", backendType, canonType))
}
b := bf(nil) // This is only used to get the schema, encryption should panic if attempted
backendSchema := b.ConfigSchema()

View File

@@ -311,11 +311,18 @@ func (m *Meta) selectWorkspace(ctx context.Context, b backend.Backend) error {
func (m *Meta) BackendForLocalPlan(ctx context.Context, settings plans.Backend, enc encryption.StateEncryption) (backend.Enhanced, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
f := backendInit.Backend(settings.Type)
f, canonType := backendInit.Backend(settings.Type)
if f == nil {
diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), settings.Type))
return nil, diags
}
if canonType != settings.Type {
// We should always save the canonical name in a plan -- never an alias
// name -- so getting here suggests a bug in the code that generated
// this plan.
diags = diags.Append(fmt.Errorf("saved plan should use canonical backend type %q, not alias %q; this is a bug in OpenTofu", canonType, settings.Type))
return nil, diags
}
b := f(enc)
log.Printf("[TRACE] Meta.BackendForLocalPlan: instantiated backend of type %T", b)
@@ -484,7 +491,7 @@ func (m *Meta) backendConfig(ctx context.Context, opts *BackendOpts) (*configs.B
return nil, 0, nil
}
bf := backendInit.Backend(c.Type)
bf, canonType := backendInit.Backend(c.Type)
if bf == nil {
detail := fmt.Sprintf("There is no backend type named %q.", c.Type)
if msg, removed := backendInit.RemovedBackends[c.Type]; removed {
@@ -521,6 +528,10 @@ func (m *Meta) backendConfig(ctx context.Context, opts *BackendOpts) (*configs.B
// body without affecting others that hold this reference.
configCopy := *c
configCopy.Config = configBody
if c.Type != canonType {
log.Printf("[DEBUG] Meta.Backend: using canonical backend type %q instead of alias %q", canonType, c.Type)
configCopy.Type = canonType
}
return &configCopy, configHash, diags
}
@@ -538,6 +549,11 @@ func (m *Meta) backendConfig(ctx context.Context, opts *BackendOpts) (*configs.B
// which case this function will error.
func (m *Meta) backendFromConfig(ctx context.Context, opts *BackendOpts, enc encryption.StateEncryption) (backend.Backend, tfdiags.Diagnostics) {
// Get the local backend configuration.
// Note that [Meta.backendConfig] returns a possibly-modified copy of
// the original configuration, and in particular has the backend type
// already translated from an alias to the canonical name so everything
// using "c" after this can assume that c.Type is definitely the canonical
// name for a backend that actually exists and is not an alias.
c, cHash, diags := m.backendConfig(ctx, opts)
if diags.HasErrors() {
return nil, diags
@@ -814,11 +830,19 @@ func (m *Meta) backendFromState(ctx context.Context, enc encryption.StateEncrypt
if s.Backend.Type == "" {
return backendLocal.New(enc), diags
}
f := backendInit.Backend(s.Backend.Type)
f, canonType := backendInit.Backend(s.Backend.Type)
if f == nil {
diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Backend.Type))
return nil, diags
}
if canonType != s.Backend.Type {
// The "state" (really: the working directory state managed by
// the clistate package) should always record the canonical backend
// type, not an alias for it. If we get here then there's a bug in
// the code that generated the s.Backend values.
diags = diags.Append(fmt.Errorf("working directory is configured for backend alias %q instead of the canonical name %q; this is a bug in OpenTofu", s.Backend.Type, canonType))
return nil, diags
}
b := f(enc)
// The configuration saved in the working directory state file is used
@@ -1272,11 +1296,17 @@ func (m *Meta) savedBackend(ctx context.Context, sMgr *clistate.LocalState, enc
s := sMgr.State()
// Get the backend
f := backendInit.Backend(s.Backend.Type)
f, canonName := backendInit.Backend(s.Backend.Type)
if f == nil {
diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Backend.Type))
return nil, diags
}
if s.Backend.Type != canonName {
// We should always save the canonical name in the clistate, so if we
// get here then it's a bug in whatever generated the clistate.
diags = diags.Append(fmt.Errorf("working directory state uses alias %q instead of canonical backend type %q; this is a bug in OpenTofu", s.Backend.Type, canonName))
return nil, diags
}
b := f(enc)
// The configuration saved in the working directory state file is used
@@ -1354,17 +1384,20 @@ func (m *Meta) backendConfigNeedsMigration(ctx context.Context, c *configs.Backe
log.Print("[TRACE] backendConfigNeedsMigration: no cached config, so migration is required")
return true
}
if c.Type != s.Type {
log.Printf("[TRACE] backendConfigNeedsMigration: type changed from %q to %q, so migration is required", s.Type, c.Type)
return true
}
// We need the backend's schema to do our comparison here.
f := backendInit.Backend(c.Type)
f, canonType := backendInit.Backend(c.Type)
if f == nil {
log.Printf("[TRACE] backendConfigNeedsMigration: no backend of type %q, which migration codepath must handle", c.Type)
return true // let the migration codepath deal with the missing backend
}
if canonType != c.Type {
log.Printf("[TRACE] backendConfigNeedsMigration: using canonical backend type %q instead of configured alias %q", canonType, c.Type)
}
if canonType != s.Type {
log.Printf("[TRACE] backendConfigNeedsMigration: type changed from %q to %q, so migration is required", s.Type, canonType)
return true
}
b := f(nil) // We don't need encryption here as it's only used for config/schema
// We use "NoneRequired" here because we're only evaluating the body written directly
@@ -1401,11 +1434,18 @@ func (m *Meta) backendInitFromConfig(ctx context.Context, c *configs.Backend, en
var diags tfdiags.Diagnostics
// Get the backend
f := backendInit.Backend(c.Type)
// Note that Meta.backendConfig should already have rewritten c.Type to be
// canonical before we were called, so we are expecting canonType to
// match c.Type now.
f, canonType := backendInit.Backend(c.Type)
if f == nil {
diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendNewUnknown), c.Type))
return nil, cty.NilVal, diags
}
if c.Type != canonType {
diags = diags.Append(fmt.Errorf("backend configuration still contains alias type %q instead of canonical %q; this is a bug in OpenTofu", c.Type, canonType))
return nil, cty.NilVal, diags
}
b := f(enc)
schema := b.ConfigSchema()
@@ -1428,7 +1468,7 @@ func (m *Meta) backendInitFromConfig(ctx context.Context, c *configs.Backend, en
var err error
configVal, err = m.inputForSchema(configVal, schema)
if err != nil {
diags = diags.Append(fmt.Errorf("Error asking for input to configure backend %q: %w", c.Type, err))
diags = diags.Append(fmt.Errorf("Error asking for input to configure backend %q: %w", canonType, err))
}
// We get an unknown here if the if the user aborted input, but we can't

View File

@@ -518,7 +518,8 @@ func TestPlan_outBackend(t *testing.T) {
t.Errorf("wrong backend workspace %q; want %q", got, want)
}
{
httpBackend := backendinit.Backend("http")(encryption.StateEncryptionDisabled())
httpBackendInit, _ := backendinit.Backend("http")
httpBackend := httpBackendInit(encryption.StateEncryptionDisabled())
schema := httpBackend.ConfigSchema()
got, err := plan.Backend.Config.Decode(schema.ImpliedType())
if err != nil {