diff --git a/internal/configs/module.go b/internal/configs/module.go index 5437360713c9..99ac81134e75 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -41,6 +41,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 @@ -107,9 +109,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 } @@ -651,6 +651,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 diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 401092f2da43..f4403fd70945 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -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. // diff --git a/internal/configs/parser_config_dir.go b/internal/configs/parser_config_dir.go index 8f4fc607d533..83313703e394 100644 --- a/internal/configs/parser_config_dir.go +++ b/internal/configs/parser_config_dir.go @@ -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(path, 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(basePath string, paths []string) ([]*QueryFile, 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 { diff --git a/internal/configs/parser_config_dir_test.go b/internal/configs/parser_config_dir_test.go index c6f0478b712b..c271c462b7af 100644 --- a/internal/configs/parser_config_dir_test.go +++ b/internal/configs/parser_config_dir_test.go @@ -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) { diff --git a/internal/configs/parser_file_matcher.go b/internal/configs/parser_file_matcher.go index e9e66c40ae28..ccd241a7ba81 100644 --- a/internal/configs/parser_file_matcher.go +++ b/internal/configs/parser_file_matcher.go @@ -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 +} diff --git a/internal/configs/state_migrate_file.go b/internal/configs/state_migrate_file.go new file mode 100644 index 000000000000..e304a3dd8b97 --- /dev/null +++ b/internal/configs/state_migrate_file.go @@ -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"}, + }, + }, +} diff --git a/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/migrate_from_backend.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/migrate_from_backend.tfmigrate.hcl new file mode 100644 index 000000000000..45ef572717cf --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/migrate_from_backend.tfmigrate.hcl @@ -0,0 +1,5 @@ +from { + backend "s3" { + bucket = "foobar" + } +} diff --git a/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/state_store_provider.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/state_store_provider.tfmigrate.hcl new file mode 100644 index 000000000000..45e8e5ba94aa --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-multiple-files/state_store_provider.tfmigrate.hcl @@ -0,0 +1,6 @@ +state_store_provider { + test = { + source = "hashicorp/test" + version = "1.0.0" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-same-file/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-same-file/main.tfmigrate.hcl new file mode 100644 index 000000000000..cfc876f71925 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/backend-and-state-store-provider-same-file/main.tfmigrate.hcl @@ -0,0 +1,12 @@ +state_store_provider { + test = { + source = "hashicorp/test" + version = "1.0.0" + } +} + +from { + backend "s3" { + bucket = "foobar" + } +} diff --git a/internal/configs/testdata/state-migration-files/invalid/both-nested-state-store-and-backend-blocks/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/both-nested-state-store-and-backend-blocks/main.tfmigrate.hcl new file mode 100644 index 000000000000..1db4ce065603 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/both-nested-state-store-and-backend-blocks/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/different-providers-between-blocks/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/different-providers-between-blocks/main.tfmigrate.hcl new file mode 100644 index 000000000000..5dd748a0acfa --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/different-providers-between-blocks/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/1-file.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/1-file.tfmigrate.hcl new file mode 100644 index 000000000000..48bfd310da5e --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/1-file.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/2-file.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/2-file.tfmigrate.hcl new file mode 100644 index 000000000000..aad0d1cee9d0 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-multiple-files/2-file.tfmigrate.hcl @@ -0,0 +1,8 @@ +from { + state_store "test_store2" { + provider "test" { + provider_attr = "foobar" + } + store_attr = "foobar" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-same-file/1-file.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-same-file/1-file.tfmigrate.hcl new file mode 100644 index 000000000000..ed3d9ba88ef1 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-from-block-same-file/1-file.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-backend-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-backend-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..a46e81db360b --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-backend-block/main.tfmigrate.hcl @@ -0,0 +1,8 @@ +from { + backend "s3" { + bucket = "foobar" + } + backend "gcs" { + bucket = "foobar" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-state-store-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-state-store-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..e3b734688d6f --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-nested-state-store-block/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/1-file.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/1-file.tfmigrate.hcl new file mode 100644 index 000000000000..f8dd052da68a --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/1-file.tfmigrate.hcl @@ -0,0 +1,6 @@ +state_store_provider { + test1 = { + source = "hashicorp/test1" + version = "1.0.0" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/2-file.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/2-file.tfmigrate.hcl new file mode 100644 index 000000000000..7daddd8c33b5 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-multiple-files/2-file.tfmigrate.hcl @@ -0,0 +1,6 @@ +state_store_provider { + test2 = { + source = "hashicorp/test2" + version = "1.0.0" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-same-file/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-same-file/main.tfmigrate.hcl new file mode 100644 index 000000000000..93fed0c260c7 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/duplicate-state-store-provider-block-same-file/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/invalid-version-state-store-provider-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/invalid-version-state-store-provider-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..a40a24855975 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/invalid-version-state-store-provider-block/main.tfmigrate.hcl @@ -0,0 +1,6 @@ +state_store_provider { + test = { + source = "hashicorp/test" + version = "~>1.0.0" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/multiple-providers-in-state-store-provider-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/multiple-providers-in-state-store-provider-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..cdc43a8ee474 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/multiple-providers-in-state-store-provider-block/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/no-blocks/empty.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/no-blocks/empty.tfmigrate.hcl new file mode 100644 index 000000000000..e8fa1fcb2dab --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/no-blocks/empty.tfmigrate.hcl @@ -0,0 +1 @@ +# No blocks here! \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/only-state-store-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/only-state-store-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..ca15e5bb044a --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/only-state-store-block/main.tfmigrate.hcl @@ -0,0 +1,8 @@ +from { + state_store "test_store" { + provider "test" { + provider_attr = "foobar" + } + store_attr = "foobar" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/invalid/only-state-store-provider-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/only-state-store-provider-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..693b9182ddc0 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/only-state-store-provider-block/main.tfmigrate.hcl @@ -0,0 +1,6 @@ +state_store_provider { + test = { + source = "hashicorp/test" + version = "1.0.0" + } +} diff --git a/internal/configs/testdata/state-migration-files/invalid/unexpected-attribute-state-store-provider-block/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/invalid/unexpected-attribute-state-store-provider-block/main.tfmigrate.hcl new file mode 100644 index 000000000000..76a93e9d8c4d --- /dev/null +++ b/internal/configs/testdata/state-migration-files/invalid/unexpected-attribute-state-store-provider-block/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/valid/migration-from-backend/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/valid/migration-from-backend/main.tfmigrate.hcl new file mode 100644 index 000000000000..810a0b61aa62 --- /dev/null +++ b/internal/configs/testdata/state-migration-files/valid/migration-from-backend/main.tfmigrate.hcl @@ -0,0 +1,5 @@ +from { + backend "s3" { + bucket = "foobar" + } +} \ No newline at end of file diff --git a/internal/configs/testdata/state-migration-files/valid/migration-from-state-store/main.tfmigrate.hcl b/internal/configs/testdata/state-migration-files/valid/migration-from-state-store/main.tfmigrate.hcl new file mode 100644 index 000000000000..4c67f944ce0d --- /dev/null +++ b/internal/configs/testdata/state-migration-files/valid/migration-from-state-store/main.tfmigrate.hcl @@ -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" + } +} \ No newline at end of file