PSS: Add parsing of `.tfmigrate.hcl` files to define state migration operations (#38526)

* feat: Parsing a configuration directory can include .tfmigrate.hcl files. These files include `state_store_provider` blocks and `from` blocks that contains either a single `backend` or `state_store` block.

Validation will enforce that despite multiple .tfmigrate.hcl files being parsed you can only have one state_store_provider or from block, and nested backend/state_store blocks are mutually exclusive. Use of state_store_provider is only valid when state_store is in use, and the single provider described in both blocks must be in agreement. Also, a directory's .tfmigrate.hcl files cannot be empty once combined.
SarahFrench-patch-1
Sarah French 3 days ago committed by GitHub
parent 008c92d91f
commit 634db2dcc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -40,6 +40,8 @@ type Module struct {
ProviderLocalNames map[addrs.Provider]string
ProviderMetas map[addrs.Provider]*ProviderMeta
StateMigrationInstructions *StateMigrationInstructions
Variables map[string]*Variable
Locals map[string]*Local
Outputs map[string]*Output
@ -106,9 +108,7 @@ type File struct {
// test files.
func NewModuleWithTests(primaryFiles, overrideFiles []*File, testFiles map[string]*TestFile) (*Module, hcl.Diagnostics) {
mod, diags := NewModule(primaryFiles, overrideFiles)
if mod != nil {
mod.Tests = testFiles
}
mod.Tests = testFiles
return mod, diags
}
@ -650,6 +650,59 @@ func (m *Module) appendQueryFile(file *QueryFile) hcl.Diagnostics {
return diags
}
// appendStateMigrationFile controls how multiple .tfmigrate.hcl files are combined
// to result in the final state migration configuration. This enables multiple blocks
// to be defined across multiple files.
func (m *Module) appendStateMigrationFile(file *StateMigrationFile) hcl.Diagnostics {
var diags hcl.Diagnostics
// Validate process of combining data from across multiple files.
// This includes identifying duplications or conflicts across files.
// Note: Validation of individual files should have happened earlier when they were parsed.
if file.StateStoreProvider != nil {
if m.StateMigrationInstructions.StateStoreProvider == nil {
m.StateMigrationInstructions.StateStoreProvider = file.StateStoreProvider
} else {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "state_store_provider" configuration block`,
Detail: fmt.Sprintf(`A "state_store_provider" block was already declared at %s. Only one of these blocks can be included in a module's state migration files.`, m.StateMigrationInstructions.StateStoreProvider.DeclRange),
Subject: &file.StateStoreProvider.DeclRange,
})
}
}
if file.StateStore != nil {
if m.StateMigrationInstructions.StateStore == nil {
m.StateMigrationInstructions.StateStore = file.StateStore
} else {
// If we're encountering a duplicate 'state_store' description it means that a duplicate
// 'from' block is present, so we report it as such.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "from" configuration block`,
Detail: `Only one "from" block is allowed in a directory's .tfmigrate.hcl files.`,
Subject: file.fromBlockSource,
})
}
}
if file.Backend != nil {
if m.StateMigrationInstructions.Backend == nil {
m.StateMigrationInstructions.Backend = file.Backend
} else {
// If we're encountering a duplicate 'backend' description it means that a duplicate
// 'from' block is present, so we report it as such.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "from" configuration block`,
Detail: `Only one "from" block is allowed in a directory's .tfmigrate.hcl files.`,
Subject: file.fromBlockSource,
})
}
}
return diags
}
func (m *Module) mergeFile(file *File) hcl.Diagnostics {
var diags hcl.Diagnostics

@ -59,6 +59,17 @@ func (p *Parser) LoadQueryFile(path string) (*QueryFile, hcl.Diagnostics) {
return query, diags
}
func (p *Parser) LoadStateMigrationFile(path string) (*StateMigrationFile, hcl.Diagnostics) {
body, diags := p.LoadHCLFile(path)
if body == nil {
return nil, diags
}
stateMigrations, stateMigrationsDiags := loadStateMigrationFile(body)
diags = diags.Extend(stateMigrationsDiags)
return stateMigrations, diags
}
// LoadMockDataFile reads the file at the given path and parses it as a
// Terraform mock data file.
//

@ -28,6 +28,7 @@ const (
// MatchTestFiles option, or from the default test directory.
// If this option is not specified, test files will not be loaded.
// Query files (.tfquery.hcl) are also loaded from the given directory.
// State Migration files (.tfmigrate.hcl) are also loaded from the given directory.
//
// If this method returns nil, that indicates that the given directory does not
// exist at all or could not be opened for some reason. Callers may wish to
@ -59,30 +60,110 @@ func (p *Parser) LoadConfigDir(path string, opts ...Option) (*Module, hcl.Diagno
// Initialize the module
mod, modDiags := NewModule(primary, override)
mod.SourceDir = path
diags = diags.Extend(modDiags)
// Check if we need to load test files
if len(fileSet.Tests) > 0 {
testFiles, fDiags := p.loadTestFiles(path, fileSet.Tests)
diags = diags.Extend(fDiags)
if mod != nil {
mod.Tests = testFiles
}
mod.Tests = testFiles
}
// Check if we need to load query files
if len(fileSet.Queries) > 0 {
queryFiles, fDiags := p.loadQueryFiles(fileSet.Queries)
diags = append(diags, fDiags...)
if mod != nil {
for _, qf := range queryFiles {
diags = diags.Extend(mod.appendQueryFile(qf))
}
for _, qf := range queryFiles {
diags = diags.Extend(mod.appendQueryFile(qf))
}
}
// Check if we need to load state migration files
if len(fileSet.StateMigrations) > 0 {
stateMigrationFiles, fDiags := p.loadStateMigrateFiles(path, fileSet.StateMigrations)
diags = append(diags, fDiags...)
// If there are errors they may be duplicated below, so return early.
// We return an incomplete module representation.
if diags.HasErrors() {
mod.SourceDir = path
return mod, diags
}
mod.StateMigrationInstructions = &StateMigrationInstructions{}
for _, smf := range stateMigrationFiles {
diags = diags.Extend(mod.appendStateMigrationFile(smf))
}
// If there are errors that might raise false positive below, so return early.
// We return an incomplete module representation.
if diags.HasErrors() {
mod.SourceDir = path
return mod, diags
}
if mod != nil {
mod.SourceDir = path
// Now, we perform some final checks that can only be done once all .tfmigrate.hcl files are loaded.
// Note: Other checks, like mutual exclusivity, were already performed when parsing single files or appending files.
ssp := mod.StateMigrationInstructions.StateStoreProvider
ss := mod.StateMigrationInstructions.StateStore
b := mod.StateMigrationInstructions.Backend
switch {
case ssp == nil && ss == nil && b == nil:
// Files present but all empty
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Empty state migration configuration`,
Detail: `The configuration includes .tfmigrate.hcl files, but they are empty. Please make sure they include the necessary blocks to define a state migration, or remove the files from your project.`,
})
case ss != nil && b != nil:
// Mutually exclusive 'from { backend }' and 'from { state_store }' both present
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid combination of "backend" and "state_store"`,
Detail: `A configuration cannot include both "backend" and "state_store" blocks. Remove one of these blocks from inside the "from" block. The remaining block should describe where your existing state should be migrated from.`,
// Sourceless because we don't know which block isn't needed.
})
case ssp != nil && b != nil:
// Mutually exclusive 'from { backend }' and 'state_store_provider' both present
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid combination of "backend" and "state_store_provider"`,
Detail: `The "state_store_provider" block can only be used in combination with a "state_store" block. Either remove the unused "state_store_provider" block, or replace the "backend" block with a "state_store" block.`,
// Blame the state_store_provider block as the problem, as this case will only be evaluated if
// there isn't a migrate_from_state_store block also present.
Subject: &ssp.DeclRange,
})
case ss != nil && ssp == nil:
// Missing 'state_store_provider' block
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Missing "state_store_provider" block for state store migration`,
Detail: `The configuration includes a "state_store" block but is missing the required "state_store_provider" block. Add a "state_store_provider" block to specify the provider to use when migrating state out of that state store.`,
})
case ss == nil && ssp != nil:
// Missing 'from { state_store }' block
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Missing "state_store" block for state store migration`,
Detail: `The configuration includes a "state_store_provider" block but is missing the required "state_store" block. Add a "state_store" block, nested in a "from" block, to specify the state store to migrate from.`,
})
case ss != nil && ssp != nil:
// Both 'from { state_store }' and 'state_store_provider' blocks are present,
// but are they in agreement with each other?
if ss.Provider.Name != ssp.Name {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Inconsistent provider information for state migration`,
Detail: fmt.Sprintf(`The configuration's "state_store_provider" block defines a provider called %q but the "migrate_from_state_store" block uses a provider called %q instead. Please update the blocks so that they are in agreement.`,
ssp.Name,
ss.Provider.Name,
),
})
} else {
// They match, so copy across relevant data.
ss.ProviderAddr = ssp.Type
}
}
}
mod.SourceDir = path
return mod, diags
}
@ -220,6 +301,19 @@ func (p *Parser) loadQueryFiles(paths []string) ([]*QueryFile, hcl.Diagnostics)
return files, diags
}
func (p *Parser) loadStateMigrateFiles(basePath string, paths []string) ([]*StateMigrationFile, hcl.Diagnostics) {
var diags hcl.Diagnostics
files := make([]*StateMigrationFile, 0, len(paths))
for _, path := range paths {
f, fDiags := p.LoadStateMigrationFile(path)
diags = append(diags, fDiags...)
files = append(files, f)
}
return files, diags
}
// fileExt returns the Terraform configuration extension of the given
// path, or a blank string if it is not a recognized extension.
func fileExt(path string) string {

@ -7,11 +7,13 @@ import (
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/addrs"
)
// TestParseLoadConfigDirSuccess is a simple test that just verifies that
@ -120,7 +122,6 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {
}
})
}
}
func TestParserLoadConfigDirWithTests(t *testing.T) {
@ -137,7 +138,6 @@ func TestParserLoadConfigDirWithTests(t *testing.T) {
for _, directory := range directories {
t.Run(directory, func(t *testing.T) {
testDirectory := DefaultTestDirectory
if directory == "testdata/valid-modules/with-tests-very-nested" {
testDirectory = "very/nested"
@ -238,8 +238,212 @@ func TestParserLoadConfigDirWithQueries(t *testing.T) {
}
}
func TestParserLoadTestFiles_Invalid(t *testing.T) {
// Testing happy path use of 'from { backend }'.
func TestParserLoadConfigDirWithStateMigrations_from_backend(t *testing.T) {
testFixtures := "testdata/state-migration-files/valid/migration-from-backend"
// Below are specified in the config above
backendType := "s3"
bucketName := "foobar"
// Parse the directory, including .tfmigrate.hcl files
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir(testFixtures, MatchStateMigrateFiles())
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags)
}
if mod.StateMigrationInstructions == nil || mod.StateMigrationInstructions.Backend == nil {
t.Fatalf("expected mod.StateMigrationInstructions.MigrateFromBackend to be initialized, got:\n mod.StateMigrationInstructions = %#v\n mod.StateMigrationInstructions.MigrateFromBackend = %#v",
mod.StateMigrationInstructions,
mod.StateMigrationInstructions.Backend,
)
}
// Assert that the module includes expected information from 'from { backend }' block
b := mod.StateMigrationInstructions.Backend
if b.Type != backendType {
t.Fatalf("wrong backend type, got %q, want %q", b.Type, backendType)
}
attributes, diags := b.Config.JustAttributes()
if diags.HasErrors() {
t.Fatalf("unexpected error inspecting backend config: %s", diags)
}
gotBucketName, diags := attributes["bucket"].Expr.Value(nil)
if diags.HasErrors() {
t.Fatalf("unexpected error inspecting bucket attribute: %s", diags)
}
if gotBucketName.AsString() != bucketName {
t.Fatalf("wrong bucket name, got %q, want %q", gotBucketName, bucketName)
}
}
// Testing happy path use of 'from { state_store }'. This requires use of the state_store_provider
// block as well, so this also checks the happy path for that block.
func TestParserLoadConfigDirWithStateMigrations_from_state_store(t *testing.T) {
testFixtures := "testdata/state-migration-files/valid/migration-from-state-store"
// Below are specified in the config above
stateStoreType := "test_store"
// Parse the directory, including .tfmigrate.hcl files
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir(testFixtures, MatchStateMigrateFiles())
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags)
}
if mod.StateMigrationInstructions == nil || mod.StateMigrationInstructions.StateStore == nil || mod.StateMigrationInstructions.StateStoreProvider == nil {
t.Fatalf("expected MigrateFromStateStore and StateStoreProvider to be initialized, got:\n mod.StateMigrationInstructions = %#v\n mod.StateMigrationInstructions.MigrateFromStateStore = %#v\n mod.StateMigrationInstructions.StateStoreProvider = %#v",
mod.StateMigrationInstructions,
mod.StateMigrationInstructions.StateStore,
mod.StateMigrationInstructions.StateStoreProvider,
)
}
// Assert that the module includes expected information from 'from { state_store }' block
ss := mod.StateMigrationInstructions.StateStore
if ss.Type != stateStoreType {
t.Fatalf("wrong state store type, got %q, want %q", ss.Type, stateStoreType)
}
if ss.Config == nil {
t.Fatalf("expected config to be non-nil")
}
if !ss.ProviderAddr.Equals(mod.StateMigrationInstructions.StateStoreProvider.Type) {
t.Fatalf("expected state store description's provider addr to have been populated with %q, but got %q", mod.StateMigrationInstructions.StateStoreProvider.Type.ForDisplay(), ss.ProviderAddr.ForDisplay())
}
if ss.ProviderSupplyMode != "" {
// This is expected to be populated by calling code
// that is reading the config, not by the parser itself.
t.Fatal("unexpected data in ProviderSupplyMode")
}
// Assert that the module includes expected information from state_store_provider block
ssp := mod.StateMigrationInstructions.StateStoreProvider
if ssp.Name != "test" || ssp.Source != "hashicorp/test" || !ssp.Type.Equals(addrs.NewDefaultProvider("test")) {
t.Fatalf("unexpected state store provider info, got:\n Name: %q\n Source: %q\n Type: %q\n VersionConstraint: %q",
ssp.Name, ssp.Source, ssp.Type, ssp.Requirement,
)
}
expectedConstraint := "1.0.0"
if ssp.Requirement.Required.String() != expectedConstraint {
t.Fatalf("unexpected version constraint, got %q, want %q", ssp.Requirement.Required.String(), expectedConstraint)
}
}
func TestParserLoadConfigDirWithStateMigrations_error_cases(t *testing.T) {
tests := []struct {
name string
directory string
diagnosticSummary string
source string
}{
// Duplicated blocks
{
name: "duplicated 'from' block",
directory: "testdata/state-migration-files/invalid/duplicate-from-block-same-file",
diagnosticSummary: "Duplicate \"from\" configuration block",
// Assert the source because we reference the second parsed 'from' block
source: "1-file.tfmigrate.hcl:17,1-5",
},
{
name: "duplicated 'from' block across multiple files",
directory: "testdata/state-migration-files/invalid/duplicate-from-block-multiple-files",
diagnosticSummary: "Duplicate \"from\" configuration block",
// Assert the source because we reference the 'from' block in the second parsed file
source: "2-file.tfmigrate.hcl:1,1-5",
},
{
name: "duplicate 'backend' block in 'from' block",
directory: "testdata/state-migration-files/invalid/duplicate-nested-backend-block",
diagnosticSummary: "Duplicate \"backend\" configuration block",
},
{
name: "duplicate 'state_store' block in 'from' block",
directory: "testdata/state-migration-files/invalid/duplicate-nested-state-store-block",
diagnosticSummary: "Duplicate \"state_store\" configuration block",
},
// Mutually exclusive blocks
{
name: "backend and state_store are mutually exclusive in same 'from' block",
directory: "testdata/state-migration-files/invalid/both-nested-state-store-and-backend-blocks",
diagnosticSummary: `Invalid combination of "backend" and "state_store"`,
// Assert the source because we reference the 'from' block as incorrect, instead of one of the nested blocks
source: "main.tfmigrate.hcl:4,1-5",
},
{
name: "backend and state_store_provider are mutually exclusive",
directory: "testdata/state-migration-files/invalid/backend-and-state-store-provider-same-file",
diagnosticSummary: `Invalid combination of "backend" and "state_store_provider"`,
},
{
name: "backend and state_store_provider are mutually exclusive across multiple files",
directory: "testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files",
diagnosticSummary: `Invalid combination of "backend" and "state_store_provider"`,
},
// Missing blocks
{
name: "only state_store_provider block, missing state_store",
directory: "testdata/state-migration-files/invalid/only-state-store-provider-block",
diagnosticSummary: `Missing "state_store" block for state store migration`,
},
{
name: "only state_store block, missing state_store_provider",
directory: "testdata/state-migration-files/invalid/only-state-store-block",
diagnosticSummary: `Missing "state_store_provider" block for state store migration`,
},
{
name: "no blocks present in the files",
directory: "testdata/state-migration-files/invalid/no-blocks",
diagnosticSummary: `Empty state migration configuration`,
},
// Invalid contents of state_store_provider block
{
name: "invalid version constraint in state_store_provider block",
directory: "testdata/state-migration-files/invalid/invalid-version-state-store-provider-block",
diagnosticSummary: `Invalid provider version in "state_store_provider" configuration block`,
},
{
name: "unexpected attribute in state_store_provider block",
directory: "testdata/state-migration-files/invalid/unexpected-attribute-state-store-provider-block",
diagnosticSummary: `Invalid state_store_provider object; state_store_provider objects can only contain "version" and "source" attributes.`,
},
{
name: "different providers in migrate_from_state_store and state_store_provider blocks",
directory: "testdata/state-migration-files/invalid/different-providers-between-blocks",
diagnosticSummary: `Inconsistent provider information for state migration`,
},
{
name: "multiple providers described in a state_store_provider block",
directory: "testdata/state-migration-files/invalid/multiple-providers-in-state-store-provider-block",
diagnosticSummary: `Unexpected number of providers described in "state_store_provider" configuration block.`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parser := NewParser(nil)
_, diags := parser.LoadConfigDir(test.directory, MatchStateMigrateFiles())
if !diags.HasErrors() {
t.Fatalf("expected errors but got none: %s", diags)
}
if len(diags) != 1 {
for _, diag := range diags {
t.Log(diag)
}
t.Fatalf("expected only a single diagnostic to be returned, but got %d: \n%#v", len(diags), diags)
}
if !strings.Contains(diags.Error(), test.diagnosticSummary) {
t.Fatalf("expected error to contain %q, but got %q", test.diagnosticSummary, diags.Error())
}
if test.source != "" {
// We're only asserting source content in cases where the fromBlockSource value is used.
expectedSource := path.Join(test.directory, test.source)
if diags[0].Subject.String() != expectedSource {
t.Fatalf("expected error subject to be %q, but got %q", expectedSource, diags[0].Subject.String())
}
}
})
}
}
func TestParserLoadTestFiles_Invalid(t *testing.T) {
tcs := map[string][]string{
"duplicate_data_overrides": {
"duplicate_data_overrides.tftest.hcl:7,3-16: Duplicate override_data block; An override_data block targeting data.aws_instance.test has already been defined at duplicate_data_overrides.tftest.hcl:2,3-16.",
@ -424,7 +628,6 @@ func TestParserLoadConfigDirFailure(t *testing.T) {
}
})
}
}
func TestIsEmptyDir(t *testing.T) {

@ -16,10 +16,11 @@ import (
// ConfigFileSet holds the different types of configuration files found in a directory.
type ConfigFileSet struct {
Primary []string // Regular .tf and .tf.json files
Override []string // Override files (override.tf or *_override.tf)
Tests []string // Test files (.tftest.hcl or .tftest.json)
Queries []string // Query files (.tfquery.hcl)
Primary []string // Regular .tf and .tf.json files
Override []string // Override files (override.tf or *_override.tf)
Tests []string // Test files (.tftest.hcl or .tftest.json)
Queries []string // Query files (.tfquery.hcl)
StateMigrations []string // State migration files (.tfmigrate.hcl)
}
// FileMatcher is an interface for components that can match and process specific file types
@ -51,10 +52,11 @@ type parserConfig struct {
func (p *Parser) dirFileSet(dir string, opts ...Option) (ConfigFileSet, hcl.Diagnostics) {
var diags hcl.Diagnostics
fileSet := ConfigFileSet{
Primary: []string{},
Override: []string{},
Tests: []string{},
Queries: []string{},
Primary: []string{},
Override: []string{},
Tests: []string{},
Queries: []string{},
StateMigrations: []string{},
}
// Set up the parser configuration
@ -122,6 +124,8 @@ func (p *Parser) rootFiles(dir string, matchers []FileMatcher, fileSet *ConfigFi
fileSet.Tests = append(fileSet.Tests, fullPath)
case *queryFiles:
fileSet.Queries = append(fileSet.Queries, fullPath)
case *stateMigrateFiles:
fileSet.StateMigrations = append(fileSet.StateMigrations, fullPath)
}
break // Stop checking other matchers once a match is found
}
@ -146,6 +150,13 @@ func MatchQueryFiles() Option {
}
}
// MatchStateMigrateFiles adds a matcher for Terraform state migrate files (.tfmigrate.hcl only)
func MatchStateMigrateFiles() Option {
return func(o *parserConfig) {
o.matchers = append(o.matchers, &stateMigrateFiles{})
}
}
// moduleFiles matches regular Terraform configuration files (.tf and .tf.json)
type moduleFiles struct{}
@ -242,3 +253,17 @@ func (q *queryFiles) Matches(name string) bool {
func (q *queryFiles) DirFiles(dir string, options *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics {
return nil
}
// stateMigrateFiles matches Terraform state migrate files (.tfmigrate.hcl only)
type stateMigrateFiles struct{}
var _ FileMatcher = (*stateMigrateFiles)(nil)
func (s *stateMigrateFiles) Matches(name string) bool {
return strings.HasSuffix(name, ".tfmigrate.hcl")
}
func (s *stateMigrateFiles) DirFiles(dir string, options *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics {
// There are no special directories for .tfmigrate.hcl files.
return nil
}

@ -0,0 +1,374 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package configs
import (
"fmt"
"maps"
"slices"
"github.com/apparentlymart/go-versions/versions"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/zclconf/go-cty/cty"
)
// StateMigrationInstructions represents the sum of all state migration files within a
// configuration directory.
//
// A state migration file contains blocks that define how resource state has previously
// been stored for a given project. In combination with an updated Terraform configuration,
// the two pieces of information describe the source and destination of state that the user
// wishes to migrate.
//
// When creating a StateMigrationInstructions struct, calling code must ensure that there
// are no duplicated or mutually-exclusive pieces of information in the original file(s).
type StateMigrationInstructions struct {
StateStoreProvider *RequiredProvider
StateStore *StateStore
Backend *Backend
}
// StateMigrationFile represents a single state migration file within a configuration directory.
// A project can include multiple files of this type, and their contents is aggregated.
type StateMigrationFile struct {
StateMigrationInstructions
// fromBlockSource is the source range of the 'from' block in the HCL file,
// intended to be used in error diagnostics from parsing,
// e.g. multiple from blocks across multiple files.
fromBlockSource *hcl.Range
}
func loadStateMigrationFile(body hcl.Body) (*StateMigrationFile, hcl.Diagnostics) {
var diags hcl.Diagnostics
file := &StateMigrationFile{}
content, contentDiags := body.Content(stateMigrationFileSchema)
diags = append(diags, contentDiags...)
for _, block := range content.Blocks {
switch block.Type {
case "state_store_provider":
p, pDiags := decodeStateStoreProviderBlock(block)
diags = diags.Extend(pDiags)
if file.StateStoreProvider != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "state_store_provider" configuration block`,
Detail: `Only one "state_store_provider" block is allowed in a directory's .tfmigrate.hcl files.`,
Subject: block.DefRange.Ptr(),
})
continue // Keep file.StateStoreProvider as first parsed block in this scenario
}
if p != nil {
file.StateStoreProvider = p
file.fromBlockSource = &block.DefRange
}
case "from":
if file.StateStore != nil || file.Backend != nil {
// A from block has already been parsed.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "from" configuration block`,
Detail: `Only one "from" block is allowed in a directory's .tfmigrate.hcl files.`,
Subject: block.DefRange.Ptr(),
})
continue
}
// We're parsing the first encountered 'from' block.
// There could still be duplications within that block, which is detected by the function.
i, fromDiags := decodeFromBlock(block)
diags = diags.Extend(fromDiags)
if !fromDiags.HasErrors() {
file.fromBlockSource = &block.DefRange
// Only one of the below is non-nil
file.StateStore = i.StateStore
file.Backend = i.Backend
}
default:
// We don't expect other block types in state migration files.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid block type",
Detail: fmt.Sprintf("This block type is not valid within a state migration file: %s", block.Type),
Subject: block.DefRange.Ptr(),
})
}
}
// Check for mutually exclusive blocks, etc.
// Defining two conflicting sources of state for migration.
if file.Backend != nil && file.StateStore != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid combination of "backend" and "state_store"`,
Detail: `The "backend" and "state_store" blocks are mutually-exclusive inside a "from" block. Only one should be used in a directory's .tfmigrate.hcl files.`,
Subject: file.fromBlockSource, // We can blame the 'from' block as being invalid.
})
}
// Unnecessary state store-related data supplied alongside description of a backend.
if file.Backend != nil && file.StateStoreProvider != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid combination of "backend" and "state_store_provider"`,
Detail: `The "state_store_provider" block can only be used in combination with a "state_store" block. Either remove the unused "state_store_provider" block, or update your "from" block to contain a "state_store" block instead.`,
// No Subject because we don't know which is correct or incorrect.
})
}
return file, diags
}
// decodeFromBlock decodes a 'from' block that can only contain one of 'state_store' or 'backend' blocks.
func decodeFromBlock(block *hcl.Block) (*StateMigrationInstructions, hcl.Diagnostics) {
var diags hcl.Diagnostics
fromData := StateMigrationInstructions{}
fromContent, fromContentDiags := block.Body.Content(fromBlockSchema)
diags = diags.Extend(fromContentDiags)
for _, block := range fromContent.Blocks {
switch block.Type {
case "state_store":
ss, ssDiags := decodeStateStoreBlock(block)
diags = diags.Extend(ssDiags)
if fromData.StateStore != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "state_store" configuration block`,
Detail: `Only one "state_store" block, nested in a "from" block, is allowed in a directory's .tfmigrate.hcl files.`,
Subject: block.DefRange.Ptr(),
})
continue // Keep fromData.MigrateFromStateStore as first parsed block in this scenario
}
if ss != nil {
fromData.StateStore = ss
}
case "backend":
b, bDiags := decodeBackendBlock(block)
diags = diags.Extend(bDiags)
if fromData.Backend != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Duplicate "backend" configuration block`,
Detail: `Only one "backend" block, nested in a "from" block, is allowed in a directory's .tfmigrate.hcl files.`,
Subject: block.DefRange.Ptr(),
})
continue // Keep fromData.MigrateFromBackend as first parsed block in this scenario
}
if b != nil {
fromData.Backend = b
}
default:
// We don't expect other block types nested inside from blocks.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid block type",
Detail: fmt.Sprintf("This block type is not valid to be nested inside 'from' blocks within a state migration file: %s", block.Type),
Subject: block.DefRange.Ptr(),
})
}
}
return &fromData, diags
}
func decodeStateStoreProviderBlock(block *hcl.Block) (*RequiredProvider, hcl.Diagnostics) {
// state_store_provider blocks are similar to required_provider blocks but different, so we need logic
// similar to that in decodeProviderRequirementsBlock but distinct. E.g. version constraints must be
// exact versions, not a range. The similarity is sufficient that we can return a RequiredProvider pointer.
var diags hcl.Diagnostics
attrs, hclDiags := block.Body.JustAttributes()
diags = diags.Extend(hclDiags)
// Only one provider should be in the block
localNames := slices.Collect(maps.Keys(attrs))
if len(localNames) != 1 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Unexpected number of providers described in "state_store_provider" configuration block.`,
Detail: fmt.Sprintf(`The "state_store_provider" block is only expected to include a single provider, but %d were found.`, len(localNames)),
Subject: block.DefRange.Ptr(),
})
return nil, diags
}
localName := localNames[0] // Local name
attr := attrs[localName] // Block containing source and version info
// verify that the local name is already localized or produce an error.
nameDiags := checkProviderNameNormalized(localName, attr.Expr.Range())
if nameDiags.HasErrors() {
diags = append(diags, nameDiags...)
return nil, diags
}
kvs, mapDiags := hcl.ExprMap(attr.Expr)
if mapDiags.HasErrors() {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid "state_store_provider" object`,
Detail: "The provider described inside state_store_provider must be an object",
Subject: attr.Expr.Range().Ptr(),
})
return nil, diags
}
// Process the data inside the object describing the provider
ssProvider := RequiredProvider{
Name: localName,
DeclRange: attr.Range,
}
for _, kv := range kvs {
key, keyDiags := kv.Key.Value(nil)
if keyDiags.HasErrors() {
diags = append(diags, keyDiags...)
return nil, diags
}
if key.Type() != cty.String {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid Attribute",
Detail: fmt.Sprintf("Invalid attribute value for provider requirement described by state_store_provider block: %#v", key),
Subject: kv.Key.Range().Ptr(),
})
return nil, diags
}
switch key.AsString() {
case "version":
vc := VersionConstraint{
DeclRange: attr.Range,
}
versionString, valDiags := kv.Value.Value(nil)
if valDiags.HasErrors() || !versionString.Type().Equals(cty.String) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid provider version in "state_store_provider" configuration block`,
Detail: "Version must be a string, specifying a single version.",
Subject: kv.Value.Range().Ptr(),
})
continue
}
v, err := versions.ParseVersion(versionString.AsString())
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid provider version in "state_store_provider" configuration block`,
Detail: "The version attribute must specify a single, specific version (e.g. \"1.0.0\") and cannot be a version constraint with an operator.",
Subject: kv.Value.Range().Ptr(),
})
return nil, diags
}
// We ensure user input can be parsed as a version, but we need to
// create a constraint to be part of the returned RequiredProvider struct.
// The constraint will pin to a specific version set by the config.
constraints, err := version.NewConstraint(v.String())
if err != nil {
// NewConstraint doesn't return user-friendly errors, so we'll just
// ignore the provided error and produce our own generic one.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Unable to create version constraint from provider version`,
Detail: fmt.Sprintf("Terraform was unable to create an 'exact' version constraint from the provided version string: %s.", v.String()),
Subject: kv.Value.Range().Ptr(),
})
return nil, diags
}
vc.Required = constraints
ssProvider.Requirement = vc
case "source":
source, err := kv.Value.Value(nil)
if err != nil || !source.Type().Equals(cty.String) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid source in "state_store_provider" configuration block`,
Detail: "Source must be specified as a string.",
Subject: kv.Value.Range().Ptr(),
})
return nil, diags
}
fqn, sourceDiags := addrs.ParseProviderSourceString(source.AsString())
if sourceDiags.HasErrors() {
hclDiags := sourceDiags.ToHCL()
// The diagnostics from ParseProviderSourceString don't contain
// source location information because it has no context to compute
// them from, and so we'll add those in quickly here before we
// return.
for _, diag := range hclDiags {
if diag.Subject == nil {
diag.Subject = kv.Value.Range().Ptr()
}
}
diags = append(diags, hclDiags...)
return nil, diags
}
ssProvider.Source = source.AsString()
ssProvider.Type = fqn
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid state_store_provider object",
Detail: `state_store_provider objects can only contain "version" and "source" attributes.`,
Subject: kv.Key.Range().Ptr(),
})
return nil, diags
}
}
return &ssProvider, diags
}
// stateMigrationFileSchema is the schema for a .tfmigrate.hcl file, for use with
// the `state migrate` command.
// Whereas the current Terraform config (.tf) defines the destination that state should
// be migrated to, these files define how a backend or state store was previously configured.
// Due to this, these files define the source where migrated state is copied from.
var stateMigrationFileSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "state_store_provider",
},
{
Type: "from",
},
},
}
// fromBlockSchema is the schema for 'from' blocks within .tfmigrate.hcl files.
var fromBlockSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "state_store",
LabelNames: []string{"type"},
},
{
Type: "backend",
LabelNames: []string{"type"},
},
},
}

@ -0,0 +1,6 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}

@ -0,0 +1,12 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
from {
backend "s3" {
bucket = "foobar"
}
}

@ -0,0 +1,14 @@
# No state_store_provider block here as that would trigger a different error
# i.e. it is mutually exclusive with 'backend'.
from {
backend "s3" {
bucket = "foobar"
}
state_store "test_store1" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,17 @@
state_store_provider {
foobar = {
source = "hashicorp/foobar"
version = "1.0.0"
}
}
# The state store below references a different provider to the definition above
from {
state_store "test_store" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,15 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
from {
state_store "test_store1" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,8 @@
from {
state_store "test_store2" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,24 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
from {
state_store "test_store1" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}
from {
state_store "test_store2" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,8 @@
from {
backend "s3" {
bucket = "foobar"
}
backend "gcs" {
bucket = "foobar"
}
}

@ -0,0 +1,21 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
from {
state_store "test_store1" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
state_store "test_store2" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,6 @@
state_store_provider {
test1 = {
source = "hashicorp/test1"
version = "1.0.0"
}
}

@ -0,0 +1,6 @@
state_store_provider {
test2 = {
source = "hashicorp/test2"
version = "1.0.0"
}
}

@ -0,0 +1,13 @@
state_store_provider {
test1 = {
source = "hashicorp/test1"
version = "1.0.0"
}
}
state_store_provider {
test2 = {
source = "hashicorp/test2"
version = "1.0.0"
}
}

@ -0,0 +1,6 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "~>1.0.0"
}
}

@ -0,0 +1,19 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
foobar = {
source = "hashicorp/foobar"
version = "1.0.0"
}
}
from {
state_store "test_store" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,8 @@
from {
state_store "test_store" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,6 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}

@ -0,0 +1,16 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
foobar = "this shouldn't be here"
}
}
from {
state_store "test_store" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}

@ -0,0 +1,15 @@
state_store_provider {
test = {
source = "hashicorp/test"
version = "1.0.0"
}
}
from {
state_store "test_store" {
provider "test" {
provider_attr = "foobar"
}
store_attr = "foobar"
}
}
Loading…
Cancel
Save