diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index 9a41f5e4a..ade6d1134 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -504,11 +504,21 @@ func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace str } func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { - panic("not implemented") + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.Locked = true + return w, nil } func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - panic("not implemented") + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.Locked = false + return w, nil } func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { diff --git a/backend/remote/backend_state.go b/backend/remote/backend_state.go index 1c124de4f..5c2a6cbfb 100644 --- a/backend/remote/backend_state.go +++ b/backend/remote/backend_state.go @@ -8,12 +8,14 @@ import ( "fmt" tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/state" "github.com/hashicorp/terraform/state/remote" "github.com/hashicorp/terraform/terraform" ) type remoteClient struct { client *tfe.Client + lockInfo *state.LockInfo organization string runID string workspace string @@ -108,3 +110,72 @@ func (r *remoteClient) Delete() error { return nil } + +// Lock the remote state. +func (r *remoteClient) Lock(info *state.LockInfo) (string, error) { + ctx := context.Background() + + lockErr := &state.LockError{Info: r.lockInfo} + + // Retrieve the workspace to lock. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + lockErr.Err = err + return "", lockErr + } + + // Check if the workspace is already locked. + if w.Locked { + lockErr.Err = fmt.Errorf( + "remote state already\nlocked (lock ID: \"%s/%s\")", r.organization, r.workspace) + return "", lockErr + } + + // Lock the workspace. + w, err = r.client.Workspaces.Lock(ctx, w.ID, tfe.WorkspaceLockOptions{ + Reason: tfe.String("Locked by Terraform"), + }) + if err != nil { + lockErr.Err = err + return "", lockErr + } + + r.lockInfo = info + + return r.lockInfo.ID, nil +} + +// Unlock the remote state. +func (r *remoteClient) Unlock(id string) error { + ctx := context.Background() + + lockErr := &state.LockError{Info: r.lockInfo} + + // Verify the expected lock ID. + if r.lockInfo != nil && r.lockInfo.ID != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Verify the optional force-unlock lock ID. + if r.lockInfo == nil && r.organization+"/"+r.workspace != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Retrieve the workspace to lock. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + lockErr.Err = err + return lockErr + } + + // Unlock the workspace. + w, err = r.client.Workspaces.Unlock(ctx, w.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil +} diff --git a/backend/remote/backend_state_test.go b/backend/remote/backend_state_test.go index f7dcb581c..b2e82c548 100644 --- a/backend/remote/backend_state_test.go +++ b/backend/remote/backend_state_test.go @@ -5,6 +5,7 @@ import ( "os" "testing" + "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/state/remote" "github.com/hashicorp/terraform/terraform" ) @@ -18,6 +19,22 @@ func TestRemoteClient(t *testing.T) { remote.TestClient(t, client) } +func TestRemoteClient_stateLock(t *testing.T) { + b := testBackendDefault(t) + + s1, err := b.State(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + s2, err := b.State(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client) +} + func TestRemoteClient_withRunID(t *testing.T) { // Set the TFE_RUN_ID environment variable before creating the client! if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { diff --git a/backend/remote/backend_test.go b/backend/remote/backend_test.go index c495daefb..28553cae1 100644 --- a/backend/remote/backend_test.go +++ b/backend/remote/backend_test.go @@ -16,6 +16,18 @@ func TestRemote(t *testing.T) { var _ backend.CLI = New(nil) } +func TestRemote_backendDefault(t *testing.T) { + b := testBackendDefault(t) + backend.TestBackendStates(t, b) + backend.TestBackendStateLocks(t, b, b) + backend.TestBackendStateForceUnlock(t, b, b) +} + +func TestRemote_backendNoDefault(t *testing.T) { + b := testBackendNoDefault(t) + backend.TestBackendStates(t, b) +} + func TestRemote_config(t *testing.T) { cases := map[string]struct { config map[string]interface{} @@ -125,18 +137,6 @@ func TestRemote_nonexistingOrganization(t *testing.T) { } } -func TestRemote_backendDefault(t *testing.T) { - b := testBackendDefault(t) - backend.TestBackendStates(t, b) - backend.TestBackendStateLocks(t, b, b) - backend.TestBackendStateForceUnlock(t, b, b) -} - -func TestRemote_backendNoDefault(t *testing.T) { - b := testBackendNoDefault(t) - backend.TestBackendStates(t, b) -} - func TestRemote_addAndRemoveStatesDefault(t *testing.T) { b := testBackendDefault(t) if _, err := b.States(); err != backend.ErrNamedStatesNotSupported {