diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d64b772..b97861335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Add `NotificationTriggerAssessmentCheckFailed` notification trigger type by @rexredinger [#549](https://github.com/hashicorp/go-tfe/pull/549) * Add `RemoteTFEVersion()` to the `Client` interface, which exposes the `X-TFE-Version` header set by a remote TFE instance by @sebasslash [#563](https://github.com/hashicorp/go-tfe/pull/563) * Validate the module version as a version instead of an ID [#409](https://github.com/hashicorp/go-tfe/pull/409) +* Add `ForceExecute()` to `Runs` to allow force executing a run by @annawinkler [#570](https://github.com/hashicorp/go-tfe/pull/570) # v1.11.0 diff --git a/mocks/run_mocks.go b/mocks/run_mocks.go index 6d40fc1cf..4c1f156ef 100644 --- a/mocks/run_mocks.go +++ b/mocks/run_mocks.go @@ -106,6 +106,20 @@ func (mr *MockRunsMockRecorder) ForceCancel(ctx, runID, options interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ForceCancel", reflect.TypeOf((*MockRuns)(nil).ForceCancel), ctx, runID, options) } +// ForceExecute mocks base method. +func (m *MockRuns) ForceExecute(ctx context.Context, runID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ForceExecute", ctx, runID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ForceExecute indicates an expected call of ForceExecute. +func (mr *MockRunsMockRecorder) ForceExecute(ctx, runID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ForceExecute", reflect.TypeOf((*MockRuns)(nil).ForceExecute), ctx, runID) +} + // List mocks base method. func (m *MockRuns) List(ctx context.Context, workspaceID string, options *tfe.RunListOptions) (*tfe.RunList, error) { m.ctrl.T.Helper() diff --git a/run.go b/run.go index 1212e3209..4720f079a 100644 --- a/run.go +++ b/run.go @@ -36,6 +36,9 @@ type Runs interface { // Force-cancel a run by its ID. ForceCancel(ctx context.Context, runID string, options RunForceCancelOptions) error + // Force execute a run by its ID. + ForceExecute(ctx context.Context, runID string) error + // Discard a run by its ID. Discard(ctx context.Context, runID string, options RunDiscardOptions) error } @@ -464,6 +467,26 @@ func (s *runs) ForceCancel(ctx context.Context, runID string, options RunForceCa return req.Do(ctx, nil) } +// ForceExecute is used to forcefully execute a run by its ID. +// +// Note: While useful at times, force-executing a run circumvents the typical +// workflow of applying runs using Terraform Cloud. It is not intended for +// regular use. If you find yourself using it frequently, please reach out to +// HashiCorp Support for help in developing an alternative approach. +func (s *runs) ForceExecute(ctx context.Context, runID string) error { + if !validStringID(&runID) { + return ErrInvalidRunID + } + + u := fmt.Sprintf("runs/%s/actions/force-execute", url.QueryEscape(runID)) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + // Discard a run by its ID. func (s *runs) Discard(ctx context.Context, runID string, options RunDiscardOptions) error { if !validStringID(&runID) { diff --git a/run_integration_test.go b/run_integration_test.go index c4c21af84..a844e6d3f 100644 --- a/run_integration_test.go +++ b/run_integration_test.go @@ -526,6 +526,64 @@ func TestRunsForceCancel(t *testing.T) { }) } +func TestRunsForceExecute(t *testing.T) { + skipIfNotCINode(t) + + client := testClient(t) + ctx := context.Background() + + wTest, wTestCleanup := createWorkspace(t, client, nil) + defer wTestCleanup() + + // We need to create 2 runs here: + // - The first run will automatically be planned so that the second + // run can't be executed. + // - The second run will be pending until the first run is confirmed or + // discarded, so we will force execute this run. + rToCancel, _ := createPlannedRun(t, client, wTest) + rTest, _ := createRunWaitForStatus(t, client, wTest, RunPending) + + t.Run("a successful force-execute", func(t *testing.T) { + // The user has permission to force-execute the run + assert.True(t, rTest.Permissions.CanForceExecute) + + err := client.Runs.ForceExecute(ctx, rTest.ID) + require.NoError(t, err) + + timeout := 2 * time.Minute + ctxPollRunForceExecute, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // The second run then has a status that's planned or cost estimated + // RunCostEstimated, RunPlanned, RunErrored + rTest = pollRunStatus(t, + client, + ctxPollRunForceExecute, + rTest, + applyableStatuses(rTest)) + if rTest.Status == RunErrored { + fatalDumpRunLog(t, client, ctx, rTest) + } + + // Refresh the view of the run + rToCancel, err = client.Runs.Read(ctx, rToCancel.ID) + require.NoError(t, err) + + // The first run was discarded + assert.Equal(t, RunDiscarded, rToCancel.Status) + }) + + t.Run("when the run does not exist", func(t *testing.T) { + err := client.Runs.ForceExecute(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("with invalid run ID", func(t *testing.T) { + err := client.Runs.ForceExecute(ctx, badIdentifier) + assert.EqualError(t, err, ErrInvalidRunID.Error()) + }) +} + func TestRunsDiscard(t *testing.T) { skipIfNotCINode(t)