Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
52058f5
feat: Parsing a configuration directory can include .tfmigrate.hcl fi…
SarahFrench May 5, 2026
869c169
feat: Add ability to parse `state_store_provider` blocks from .tfmigr…
SarahFrench May 5, 2026
868a455
feat: Block using both `migrate_from_backend` and `state_store_provid…
SarahFrench May 5, 2026
1ceba3e
feat: Add validation that catches if a `state_store_provider` block i…
SarahFrench May 5, 2026
278b0b1
feat: Add ability to parse `migrate_from_state_store` blocks from .tf…
SarahFrench May 5, 2026
cbf5a4f
refactor: Avoid potentially nil `mod` variable
SarahFrench May 5, 2026
657a47f
fix: Add validation checks to ensure the same provider is described a…
SarahFrench May 6, 2026
45b0ec8
feat: Add validation that blocks .tfmigrate.hcl files being present b…
SarahFrench May 6, 2026
984ad8d
refactor: Replace if...else blocks with switch statement for clarity.
SarahFrench May 6, 2026
9880c07
feat: Add validation that state_store_provider blocks contain only a …
SarahFrench May 6, 2026
6c093df
test: Add test case demonstrating parsing of objects inside the state…
SarahFrench May 6, 2026
c7b58cb
test: Reorganise and group test cases for error scenarios parsing .tf…
SarahFrench May 6, 2026
d52a776
refactor: Parse version attr value as version and then make constrain…
SarahFrench May 8, 2026
21f402f
Respond to reviewer feedback
SarahFrench May 8, 2026
6686dbb
Change language to use from blocks, with nested state_store or backed…
SarahFrench May 11, 2026
e38eaff
refactor: StateMigrationInstructions field names simplified + use emb…
SarahFrench May 11, 2026
13948f8
test: Update tests to assert diagnostic sources that reference from b…
SarahFrench May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions internal/configs/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions internal/configs/parser_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
112 changes: 103 additions & 9 deletions internal/configs/parser_config_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
SarahFrench marked this conversation as resolved.
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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading