diff --git a/command/apply_destroy_test.go b/command/apply_destroy_test.go index 6e1277cca..f8e37f8cb 100644 --- a/command/apply_destroy_test.go +++ b/command/apply_destroy_test.go @@ -111,7 +111,7 @@ func TestApply_destroyLockedState(t *testing.T) { statePath := testStateFile(t, originalState) - unlock, err := testLockState(statePath) + unlock, err := testLockState("./testdata", statePath) if err != nil { t.Fatal(err) } diff --git a/command/apply_test.go b/command/apply_test.go index 3981b01ad..86028d52f 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -63,7 +63,7 @@ func TestApply(t *testing.T) { func TestApply_lockedState(t *testing.T) { statePath := testTempFile(t) - unlock, err := testLockState(statePath) + unlock, err := testLockState("./testdata", statePath) if err != nil { t.Fatal(err) } diff --git a/command/command_test.go b/command/command_test.go index d3bded69a..abf134331 100644 --- a/command/command_test.go +++ b/command/command_test.go @@ -535,7 +535,9 @@ func testRemoteState(t *testing.T, s *terraform.State, c int) (*terraform.Remote // testlockState calls a separate process to the lock the state file at path. // deferFunc should be called in the caller to properly unlock the file. -func testLockState(path string) (func(), error) { +// Since many tests change the working durectory, the sourcedir argument must be +// supplied to locate the statelocker.go source. +func testLockState(sourceDir, path string) (func(), error) { // build and run the binary ourselves so we can quickly terminate it for cleanup buildDir, err := ioutil.TempDir("", "locker") if err != nil { @@ -545,8 +547,10 @@ func testLockState(path string) (func(), error) { os.RemoveAll(buildDir) } + source := filepath.Join(sourceDir, "statelocker.go") lockBin := filepath.Join(buildDir, "statelocker") - out, err := exec.Command("go", "build", "-o", lockBin, "testdata/statelocker.go").CombinedOutput() + + out, err := exec.Command("go", "build", "-o", lockBin, source).CombinedOutput() if err != nil { cleanFunc() return nil, fmt.Errorf("%s %s", err, out) diff --git a/command/plan_test.go b/command/plan_test.go index 786bae78e..b2f628fc6 100644 --- a/command/plan_test.go +++ b/command/plan_test.go @@ -46,7 +46,7 @@ func TestPlan_lockedState(t *testing.T) { } testPath := testFixturePath("plan") - unlock, err := testLockState(filepath.Join(testPath, DefaultStateFilename)) + unlock, err := testLockState("./testdata", filepath.Join(testPath, DefaultStateFilename)) if err != nil { t.Fatal(err) } diff --git a/command/refresh_test.go b/command/refresh_test.go index 4c79aec68..52aa33112 100644 --- a/command/refresh_test.go +++ b/command/refresh_test.go @@ -63,7 +63,7 @@ func TestRefresh_lockedState(t *testing.T) { state := testState() statePath := testStateFile(t, state) - unlock, err := testLockState(statePath) + unlock, err := testLockState("./testdata", statePath) if err != nil { t.Fatal(err) } diff --git a/command/taint_test.go b/command/taint_test.go index 26c42b6a5..9aa9b2664 100644 --- a/command/taint_test.go +++ b/command/taint_test.go @@ -63,7 +63,7 @@ func TestTaint_lockedState(t *testing.T) { } statePath := testStateFile(t, state) - unlock, err := testLockState(statePath) + unlock, err := testLockState("./testdata", statePath) if err != nil { t.Fatal(err) } diff --git a/command/unlock.go b/command/unlock.go new file mode 100644 index 000000000..f277df693 --- /dev/null +++ b/command/unlock.go @@ -0,0 +1,131 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" +) + +// UnlockCommand is a cli.Command implementation that manually unlocks +// the state. +type UnlockCommand struct { + Meta +} + +func (c *UnlockCommand) Run(args []string) int { + args = c.Meta.process(args, false) + + force := false + cmdFlags := c.Meta.flagSet("force-unlock") + cmdFlags.BoolVar(&force, "force", false, "force") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + // assume everything is initialized. The user can manually init if this is + // required. + configPath, err := ModulePath(cmdFlags.Args()) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + // Load the backend + b, err := c.Backend(&BackendOpts{ + ConfigPath: configPath, + }) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to load backend: %s", err)) + return 1 + } + + st, err := b.State() + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to load state: %s", err)) + return 1 + } + + s, ok := st.(state.Locker) + if !ok { + c.Ui.Error("The remote state backend in use does not support locking, and therefor\n" + + "cannot be unlocked.") + return 1 + } + + isLocal := false + switch s := st.(type) { + case *state.BackupState: + if _, ok := s.Real.(*state.LocalState); ok { + isLocal = true + } + case *state.LocalState: + isLocal = true + } + + if !force { + // Forcing this doesn't do anything, but doesn't break anything either, + // and allows us to run the basic command test too. + if isLocal { + c.Ui.Error("Local state cannot be unlocked by another process") + return 1 + } + + desc := "Terraform will remove the lock on the remote state.\n" + + "This will allow local Terraform commands to modify this state, even though it\n" + + "may be still be in use. Only 'yes' will be accepted to confirm." + + v, err := c.UIInput().Input(&terraform.InputOpts{ + Id: "force-unlock", + Query: "Do you really want to force-unlock?", + Description: desc, + }) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error asking for confirmation: %s", err)) + return 1 + } + if v != "yes" { + c.Ui.Output("force-unlock cancelled.") + return 1 + } + } + + if err := s.Unlock(); err != nil { + c.Ui.Error(fmt.Sprintf("Failed to unlock state: %s", err)) + return 1 + } + + c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputUnlockSuccess))) + return 0 +} + +func (c *UnlockCommand) Help() string { + helpText := ` +Usage: terraform force-unlock [DIR] + + Manually unlock the state for the defined configuration. + + This will not modify your infrastructure. This command removes the lock on the + state for the current configuration. The behavior of this lock is dependent + on the backend being used. Local state files cannot be unlocked by another + process. + +Options: + + -force Don't ask for input for unlock confirmation. +` + return strings.TrimSpace(helpText) +} + +func (c *UnlockCommand) Synopsis() string { + return "Manually unlock the terraform state" +} + +const outputUnlockSuccess = ` +[reset][bold][red]Terraform state has been successfully unlocked![reset][red] + +The state has been unlocked, and Terraform commands should now be able to +obtain a new lock on the remote state. +` diff --git a/command/unlock_test.go b/command/unlock_test.go new file mode 100644 index 000000000..2496f3fe6 --- /dev/null +++ b/command/unlock_test.go @@ -0,0 +1,46 @@ +package command + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +// Since we can't unlock a local state file, just test that calling unlock +// doesn't fail. +// TODO: mock remote state for UI testing +func TestUnlock(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // Write the legacy state + statePath := DefaultStateFilename + { + f, err := os.Create(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + err = terraform.WriteState(testState(), f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + } + + p := testProvider() + ui := new(cli.MockUi) + c := &UnlockCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + if code := c.Run([]string{"-force"}); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } +} diff --git a/command/untaint_test.go b/command/untaint_test.go index ed63de78d..80bf7bd72 100644 --- a/command/untaint_test.go +++ b/command/untaint_test.go @@ -68,7 +68,7 @@ func TestUntaint_lockedState(t *testing.T) { }, } statePath := testStateFile(t, state) - unlock, err := testLockState(statePath) + unlock, err := testLockState("./testdata", statePath) if err != nil { t.Fatal(err) } diff --git a/commands.go b/commands.go index ec4d03793..20e2ff892 100644 --- a/commands.go +++ b/commands.go @@ -75,6 +75,12 @@ func init() { }, nil }, + "force-unlock": func() (cli.Command, error) { + return &command.UnlockCommand{ + Meta: meta, + }, nil + }, + "get": func() (cli.Command, error) { return &command.GetCommand{ Meta: meta,