| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package command | |
| import ( | |
| "fmt" | |
| "strings" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| "github.com/hashicorp/terraform/internal/backend/backendrun" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/command/clistate" | |
| "github.com/hashicorp/terraform/internal/command/views" | |
| "github.com/hashicorp/terraform/internal/states" | |
| "github.com/hashicorp/terraform/internal/terraform" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| // StateMvCommand is a Command implementation that changes bindings | |
| // in Terraform state so that existing remote objects bind to new resource instances. | |
| type StateMvCommand struct { | |
| StateMeta | |
| } | |
| func (c *StateMvCommand) Run(args []string) int { | |
| parsedArgs, parseDiags := arguments.ParseStateMv(c.Meta.process(args)) | |
| if parseDiags.HasErrors() { | |
| c.showDiagnostics(parseDiags) | |
| return 1 | |
| } | |
| c.backupPath = parsedArgs.BackupPath | |
| c.Meta.stateLock = parsedArgs.StateLock | |
| c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout | |
| c.statePath = parsedArgs.StatePath | |
| c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion | |
| loader, err := c.initConfigLoader() | |
| if err != nil { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(err) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| var varDiags tfdiags.Diagnostics | |
| c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { | |
| loader.Parser().ForceFileSource(filename, src) | |
| }) | |
| if varDiags.HasErrors() { | |
| c.showDiagnostics(varDiags) | |
| return 1 | |
| } | |
| if diags := c.Meta.checkRequiredVersion(); diags != nil { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| // If backup or backup-out options are set | |
| // and the state option is not set, make sure | |
| // the backend is local | |
| backupOptionSetWithoutStateOption := c.backupPath != "-" && c.statePath == "" | |
| backupOutOptionSetWithoutStateOption := parsedArgs.BackupOutPath != "-" && c.statePath == "" | |
| var setLegacyLocalBackendOptions []string | |
| if backupOptionSetWithoutStateOption { | |
| setLegacyLocalBackendOptions = append(setLegacyLocalBackendOptions, "-backup") | |
| } | |
| if backupOutOptionSetWithoutStateOption { | |
| setLegacyLocalBackendOptions = append(setLegacyLocalBackendOptions, "-backup-out") | |
| } | |
| if len(setLegacyLocalBackendOptions) > 0 { | |
| currentBackend, diags := c.backendFromConfig(&BackendOpts{}) | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| // If currentBackend is nil and diags didn't have errors, | |
| // this means we have an implicit local backend | |
| _, isLocalBackend := currentBackend.(backendrun.Local) | |
| if currentBackend != nil && !isLocalBackend { | |
| diags = diags.Append( | |
| tfdiags.Sourceless( | |
| tfdiags.Error, | |
| fmt.Sprintf("Invalid command line options: %s", strings.Join(setLegacyLocalBackendOptions[:], ", ")), | |
| "Command line options -backup and -backup-out are legacy options that operate on a local state file only. You must specify a local state file with the -state option or switch to the local backend.", | |
| ), | |
| ) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| } | |
| // Read the from state | |
| view := arguments.ViewHuman | |
| stateFromMgr, err := c.State(view) | |
| if err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateLoadingState, err)) | |
| return 1 | |
| } | |
| if c.stateLock { | |
| stateLocker := clistate.NewLocker(c.stateLockTimeout, views.NewStateLocker(arguments.ViewHuman, c.View)) | |
| if diags := stateLocker.Lock(stateFromMgr, "state-mv"); diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| defer func() { | |
| if diags := stateLocker.Unlock(); diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| } | |
| }() | |
| } | |
| if err := stateFromMgr.RefreshState(); err != nil { | |
| c.Ui.Error(fmt.Sprintf("Failed to refresh source state: %s", err)) | |
| return 1 | |
| } | |
| stateFrom := stateFromMgr.State() | |
| if stateFrom == nil { | |
| c.Ui.Error(errStateNotFound) | |
| return 1 | |
| } | |
| // Read the destination state | |
| stateToMgr := stateFromMgr | |
| stateTo := stateFrom | |
| if parsedArgs.StateOutPath != "" { | |
| c.statePath = parsedArgs.StateOutPath | |
| c.backupPath = parsedArgs.BackupOutPath | |
| stateToMgr, err = c.State(view) | |
| if err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateLoadingState, err)) | |
| return 1 | |
| } | |
| if c.stateLock { | |
| stateLocker := clistate.NewLocker(c.stateLockTimeout, views.NewStateLocker(arguments.ViewHuman, c.View)) | |
| if diags := stateLocker.Lock(stateToMgr, "state-mv"); diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| defer func() { | |
| if diags := stateLocker.Unlock(); diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| } | |
| }() | |
| } | |
| if err := stateToMgr.RefreshState(); err != nil { | |
| c.Ui.Error(fmt.Sprintf("Failed to refresh destination state: %s", err)) | |
| return 1 | |
| } | |
| stateTo = stateToMgr.State() | |
| if stateTo == nil { | |
| stateTo = states.NewState() | |
| } | |
| } | |
| var diags tfdiags.Diagnostics | |
| sourceAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, parsedArgs.SourceAddr) | |
| diags = diags.Append(moreDiags) | |
| destAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, parsedArgs.DestAddr) | |
| diags = diags.Append(moreDiags) | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| prefix := "Move" | |
| if parsedArgs.DryRun { | |
| prefix = "Would move" | |
| } | |
| const msgInvalidSource = "Invalid source address" | |
| const msgInvalidTarget = "Invalid target address" | |
| var moved int | |
| ssFrom := stateFrom.SyncWrapper() | |
| sourceAddrs := c.sourceObjectAddrs(stateFrom, sourceAddr) | |
| if len(sourceAddrs) == 0 { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidSource, | |
| fmt.Sprintf("Cannot move %s: does not match anything in the current state.", sourceAddr), | |
| )) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| for _, rawAddrFrom := range sourceAddrs { | |
| switch addrFrom := rawAddrFrom.(type) { | |
| case addrs.ModuleInstance: | |
| search := sourceAddr.(addrs.ModuleInstance) | |
| addrTo, ok := destAddr.(addrs.ModuleInstance) | |
| if !ok { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidTarget, | |
| fmt.Sprintf("Cannot move %s to %s: the target must also be a module.", addrFrom, destAddr), | |
| )) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| if len(search) < len(addrFrom) { | |
| n := make(addrs.ModuleInstance, 0, len(addrTo)+len(addrFrom)-len(search)) | |
| n = append(n, addrTo...) | |
| n = append(n, addrFrom[len(search):]...) | |
| addrTo = n | |
| } | |
| if stateTo.Module(addrTo) != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateMv, "destination module already exists")) | |
| return 1 | |
| } | |
| ms := ssFrom.Module(addrFrom) | |
| if ms == nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidSource, | |
| fmt.Sprintf("The current state does not contain %s.", addrFrom), | |
| )) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| moved++ | |
| c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), addrTo.String())) | |
| if !parsedArgs.DryRun { | |
| ssFrom.RemoveModule(addrFrom) | |
| // Update the address before adding it to the state. | |
| ms.Addr = addrTo | |
| stateTo.Modules[addrTo.String()] = ms | |
| } | |
| case addrs.AbsResource: | |
| addrTo, ok := destAddr.(addrs.AbsResource) | |
| if !ok { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidTarget, | |
| fmt.Sprintf("Cannot move %s to %s: the source is a whole resource (not a resource instance) so the target must also be a whole resource.", addrFrom, destAddr), | |
| )) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| diags = diags.Append(c.validateResourceMove(addrFrom, addrTo)) | |
| if stateTo.Resource(addrTo) != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidTarget, | |
| fmt.Sprintf("Cannot move to %s: there is already a resource at that address in the current state.", addrTo), | |
| )) | |
| } | |
| rs := ssFrom.Resource(addrFrom) | |
| if rs == nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidSource, | |
| fmt.Sprintf("The current state does not contain %s.", addrFrom), | |
| )) | |
| } | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| moved++ | |
| c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), addrTo.String())) | |
| if !parsedArgs.DryRun { | |
| ssFrom.RemoveResource(addrFrom) | |
| // Update the address before adding it to the state. | |
| rs.Addr = addrTo | |
| stateTo.EnsureModule(addrTo.Module).Resources[addrTo.Resource.String()] = rs | |
| } | |
| case addrs.AbsResourceInstance: | |
| addrTo, ok := destAddr.(addrs.AbsResourceInstance) | |
| if !ok { | |
| ra, ok := destAddr.(addrs.AbsResource) | |
| if !ok { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidTarget, | |
| fmt.Sprintf("Cannot move %s to %s: the target must also be a resource instance.", addrFrom, destAddr), | |
| )) | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| addrTo = ra.Instance(addrs.NoKey) | |
| } | |
| diags = diags.Append(c.validateResourceMove(addrFrom.ContainingResource(), addrTo.ContainingResource())) | |
| if stateTo.Module(addrTo.Module) == nil { | |
| // moving something to a mew module, so we need to ensure it exists | |
| stateTo.EnsureModule(addrTo.Module) | |
| } | |
| if stateTo.ResourceInstance(addrTo) != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidTarget, | |
| fmt.Sprintf("Cannot move to %s: there is already a resource instance at that address in the current state.", addrTo), | |
| )) | |
| } | |
| is := ssFrom.ResourceInstance(addrFrom) | |
| if is == nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidSource, | |
| fmt.Sprintf("The current state does not contain %s.", addrFrom), | |
| )) | |
| } | |
| if diags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| moved++ | |
| c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), parsedArgs.DestAddr)) | |
| if !parsedArgs.DryRun { | |
| fromResourceAddr := addrFrom.ContainingResource() | |
| fromResource := ssFrom.Resource(fromResourceAddr) | |
| fromProviderAddr := fromResource.ProviderConfig | |
| ssFrom.ForgetResourceInstanceAll(addrFrom) | |
| ssFrom.RemoveResourceIfEmpty(fromResourceAddr) | |
| rs := stateTo.Resource(addrTo.ContainingResource()) | |
| if rs == nil { | |
| // If we're moving to an address without an index then that | |
| // suggests the user's intent is to establish both the | |
| // resource and the instance at the same time (since the | |
| // address covers both). If there's an index in the | |
| // target then allow creating the new instance here. | |
| resourceAddr := addrTo.ContainingResource() | |
| stateTo.SyncWrapper().SetResourceProvider( | |
| resourceAddr, | |
| fromProviderAddr, // in this case, we bring the provider along as if we were moving the whole resource | |
| ) | |
| rs = stateTo.Resource(resourceAddr) | |
| } | |
| rs.Instances[addrTo.Resource.Key] = is | |
| } | |
| default: | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidSource, | |
| fmt.Sprintf("Cannot move %s: Terraform doesn't know how to move this object.", rawAddrFrom), | |
| )) | |
| } | |
| // Look for any dependencies that may be effected and | |
| // remove them to ensure they are recreated in full. | |
| for _, mod := range stateTo.Modules { | |
| for _, res := range mod.Resources { | |
| for _, ins := range res.Instances { | |
| if ins.Current == nil { | |
| continue | |
| } | |
| for _, dep := range ins.Current.Dependencies { | |
| // check both directions here, since we may be moving | |
| // an instance which is in a resource, or a module | |
| // which can contain a resource. | |
| if dep.TargetContains(rawAddrFrom) || rawAddrFrom.TargetContains(dep) { | |
| ins.Current.Dependencies = nil | |
| break | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if parsedArgs.DryRun { | |
| if moved == 0 { | |
| c.Ui.Output("Would have moved nothing.") | |
| } | |
| return 0 // This is as far as we go in dry-run mode | |
| } | |
| // Load the backend | |
| b, backendDiags := c.backend(".", view) | |
| diags = diags.Append(backendDiags) | |
| if backendDiags.HasErrors() { | |
| c.showDiagnostics(diags) | |
| return 1 | |
| } | |
| // Get schemas, if possible, before writing state | |
| var schemas *terraform.Schemas | |
| if isCloudMode(b) { | |
| var schemaDiags tfdiags.Diagnostics | |
| schemas, schemaDiags = c.MaybeGetSchemas(stateTo, nil) | |
| diags = diags.Append(schemaDiags) | |
| } | |
| // Write the new state | |
| if err := stateToMgr.WriteState(stateTo); err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateRmPersist, err)) | |
| return 1 | |
| } | |
| if err := stateToMgr.PersistState(schemas); err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateRmPersist, err)) | |
| return 1 | |
| } | |
| // Write the old state if it is different | |
| if stateTo != stateFrom { | |
| if err := stateFromMgr.WriteState(stateFrom); err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateRmPersist, err)) | |
| return 1 | |
| } | |
| if err := stateFromMgr.PersistState(schemas); err != nil { | |
| c.Ui.Error(fmt.Sprintf(errStateRmPersist, err)) | |
| return 1 | |
| } | |
| } | |
| c.showDiagnostics(diags) | |
| if moved == 0 { | |
| c.Ui.Output("No matching objects found.") | |
| } else { | |
| c.Ui.Output(fmt.Sprintf("Successfully moved %d object(s).", moved)) | |
| } | |
| return 0 | |
| } | |
| // sourceObjectAddrs takes a single source object address and expands it to | |
| // potentially multiple objects that need to be handled within it. | |
| // | |
| // In particular, this handles the case where a module is requested directly: | |
| // if it has any child modules, then they must also be moved. It also resolves | |
| // the ambiguity that an index-less resource address could either be a resource | |
| // address or a resource instance address, by making a decision about which | |
| // is intended based on the current state of the resource in question. | |
| func (c *StateMvCommand) sourceObjectAddrs(state *states.State, matched addrs.Targetable) []addrs.Targetable { | |
| var ret []addrs.Targetable | |
| switch addr := matched.(type) { | |
| case addrs.ModuleInstance: | |
| for _, mod := range state.Modules { | |
| if len(mod.Addr) < len(addr) { | |
| continue // can't possibly be our selection or a child of it | |
| } | |
| if !mod.Addr[:len(addr)].Equal(addr) { | |
| continue | |
| } | |
| ret = append(ret, mod.Addr) | |
| } | |
| case addrs.AbsResource: | |
| // If this refers to a resource without "count" or "for_each" set then | |
| // we'll assume the user intended it to be a resource instance | |
| // address instead, to allow for requests like this: | |
| // terraform state mv aws_instance.foo aws_instance.bar[1] | |
| // That wouldn't be allowed if aws_instance.foo had multiple instances | |
| // since we can't move multiple instances into one. | |
| if rs := state.Resource(addr); rs != nil { | |
| if _, ok := rs.Instances[addrs.NoKey]; ok { | |
| ret = append(ret, addr.Instance(addrs.NoKey)) | |
| } else { | |
| ret = append(ret, addr) | |
| } | |
| } | |
| default: | |
| ret = append(ret, matched) | |
| } | |
| return ret | |
| } | |
| func (c *StateMvCommand) validateResourceMove(addrFrom, addrTo addrs.AbsResource) tfdiags.Diagnostics { | |
| const msgInvalidRequest = "Invalid state move request" | |
| var diags tfdiags.Diagnostics | |
| if addrFrom.Resource.Mode != addrTo.Resource.Mode { | |
| switch addrFrom.Resource.Mode { | |
| case addrs.ManagedResourceMode: | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidRequest, | |
| fmt.Sprintf("Cannot move %s to %s: a managed resource can be moved only to another managed resource address.", addrFrom, addrTo), | |
| )) | |
| case addrs.DataResourceMode: | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidRequest, | |
| fmt.Sprintf("Cannot move %s to %s: a data resource can be moved only to another data resource address.", addrFrom, addrTo), | |
| )) | |
| default: | |
| // In case a new mode is added in future, this unhelpful error is better than nothing. | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidRequest, | |
| fmt.Sprintf("Cannot move %s to %s: cannot change resource mode.", addrFrom, addrTo), | |
| )) | |
| } | |
| } | |
| if addrFrom.Resource.Type != addrTo.Resource.Type { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| msgInvalidRequest, | |
| fmt.Sprintf("Cannot move %s to %s: resource types don't match.", addrFrom, addrTo), | |
| )) | |
| } | |
| return diags | |
| } | |
| func (c *StateMvCommand) Help() string { | |
| helpText := ` | |
| Usage: terraform [global options] state mv [options] SOURCE DESTINATION | |
| This command will move an item matched by the address given to the | |
| destination address. This command can also move to a destination address | |
| in a completely different state file. | |
| This can be used for simple resource renaming, moving items to and from | |
| a module, moving entire modules, and more. And because this command can also | |
| move data to a completely new state, it can also be used for refactoring | |
| one configuration into multiple separately managed Terraform configurations. | |
| This command will output a backup copy of the state prior to saving any | |
| changes. The backup cannot be disabled. Due to the destructive nature | |
| of this command, backups are required. | |
| If you're moving an item to a different state file, a backup will be created | |
| for each state file. | |
| Options: | |
| -dry-run If set, prints out what would've been moved but doesn't | |
| actually move anything. | |
| -lock=false Don't hold a state lock during the operation. This is | |
| dangerous if others might concurrently run commands | |
| against the same workspace. | |
| -lock-timeout=0s Duration to retry a state lock. | |
| -ignore-remote-version A rare option used for the remote backend only. See | |
| the remote backend documentation for more information. | |
| -var 'foo=bar' Set a value for one of the input variables in the root | |
| module of the configuration. Use this option more than | |
| once to set more than one variable. | |
| -var-file=filename Load variable values from the given file, in addition | |
| to the default files terraform.tfvars and *.auto.tfvars. | |
| Use this option more than once to include more than one | |
| variables file. | |
| -state, state-out, and -backup are legacy options supported for the local | |
| backend only. For more information, see the local backend's documentation. | |
| ` | |
| return strings.TrimSpace(helpText) | |
| } | |
| func (c *StateMvCommand) Synopsis() string { | |
| return "Move an item in the state" | |
| } | |
| const errStateMv = `Error moving state: %s | |
| Please ensure your addresses and state paths are valid. No | |
| state was persisted. Your existing states are untouched.` |